package tools import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" "time" ) func run(t *testing.T, tool Tool, args any) (string, error) { t.Helper() raw, err := json.Marshal(args) if err != nil { t.Fatal(err) } return tool.Execute(context.Background(), raw) } func TestBashSuccess(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "hello.txt", "hi\n") out, err := run(t, NewBash(dir), BashArgs{Command: "cat hello.txt"}) if err != nil { t.Fatalf("bash: %v", err) } if !strings.Contains(out, "exit code: 0") || !strings.Contains(out, "--- stdout ---\nhi") { t.Errorf("got:\n%s", out) } if strings.Contains(out, "stderr") { t.Errorf("empty streams should be omitted:\n%s", out) } } func TestBashNonZeroExitIsAnErrorCarryingOutput(t *testing.T) { _, err := run(t, NewBash(t.TempDir()), BashArgs{Command: "echo out; echo problem >&2; exit 3"}) if err == nil { t.Fatal("a failing command must come back as an error the model reacts to") } for _, want := range []string{"exit code: 3", "--- stdout ---\nout", "--- stderr ---\nproblem"} { if !strings.Contains(err.Error(), want) { t.Errorf("missing %q in:\n%s", want, err) } } } func TestBashCapsOutputKeepingBothEnds(t *testing.T) { out, err := run(t, NewBash(t.TempDir()), BashArgs{Command: "seq 1 5000"}) if err != nil { t.Fatalf("bash: %v", err) } if !strings.Contains(out, "\n1\n") { t.Error("head was dropped") } if !strings.Contains(out, "5000") { t.Error("tail was dropped — that is where test failures live") } if !strings.Contains(out, "lines elided") { t.Error("truncation should be announced") } if n := strings.Count(out, "\n"); n > bashHeadLines+bashTailLines+10 { t.Errorf("output not capped: %d lines", n) } } func TestBashTimeoutKillsTheProcessGroup(t *testing.T) { if testing.Short() { t.Skip("waits on a real child process") } dir := t.TempDir() marker := filepath.Join(dir, "child-survived") // The child outlives its parent shell unless the whole group is killed. cmd := fmt.Sprintf(`(sleep 2; touch %q) & sleep 5`, marker) start := time.Now() _, err := run(t, NewBash(dir), BashArgs{Command: cmd, TimeoutMS: 300}) if err == nil || !strings.Contains(err.Error(), "timed out") { t.Fatalf("error = %v, want a timeout", err) } if elapsed := time.Since(start); elapsed > 3*time.Second { t.Errorf("took %s: the timeout did not take effect promptly", elapsed) } time.Sleep(3 * time.Second) if _, err := os.Stat(marker); err == nil { t.Error("the orphaned child kept running: the process group was not killed") } } func TestBashRunsInTheWorkingRoot(t *testing.T) { dir := t.TempDir() out, err := run(t, NewBash(dir), BashArgs{Command: "pwd"}) if err != nil { t.Fatalf("bash: %v", err) } resolved, _ := filepath.EvalSymlinks(dir) if !strings.Contains(out, dir) && !strings.Contains(out, resolved) { t.Errorf("pwd = %s, want the working root %s", out, dir) } } func TestBashRespectsCancellation(t *testing.T) { raw, _ := json.Marshal(BashArgs{Command: "sleep 10"}) ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(100 * time.Millisecond); cancel() }() start := time.Now() if _, err := NewBash(t.TempDir()).Execute(ctx, raw); err == nil { t.Fatal("want an error") } if elapsed := time.Since(start); elapsed > 3*time.Second { t.Errorf("took %s: cancellation did not propagate", elapsed) } } func TestBashEmptyCommand(t *testing.T) { if _, err := run(t, NewBash(t.TempDir()), BashArgs{Command: " "}); err == nil || !strings.Contains(err.Error(), "command is required") { t.Errorf("error = %v", err) } } func TestWriteFileCreatesIncludingParents(t *testing.T) { dir := t.TempDir() out, err := run(t, NewWrite(dir, newTracker()), WriteArgs{ Path: "internal/new/thing.go", Content: "package thing\n", }) if err != nil { t.Fatalf("write: %v", err) } got, err := os.ReadFile(filepath.Join(dir, "internal/new/thing.go")) if err != nil { t.Fatalf("file not created: %v", err) } if string(got) != "package thing\n" { t.Errorf("content = %q", got) } if !strings.Contains(out, "Created") || !strings.Contains(out, "14 bytes") { t.Errorf("summary = %q", out) } } func TestWriteFileRefusesToClobberAnUnreadFile(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "existing.go", "package existing\n") _, err := run(t, NewWrite(dir, newTracker()), WriteArgs{Path: "existing.go", Content: "package gone\n"}) if err == nil || !strings.Contains(err.Error(), "has not been read this session") { t.Fatalf("error = %v, want a read-before-write refusal", err) } if !strings.Contains(err.Error(), "replaces the whole file") { t.Errorf("error should say why that matters here: %v", err) } got, _ := os.ReadFile(filepath.Join(dir, "existing.go")) if string(got) != "package existing\n" { t.Error("file must be untouched") } } func TestWriteFileReplacesAReadFile(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "existing.go", "package existing\n") track := newTracker() info, _ := os.Stat(path) track.MarkRead(path, info.ModTime()) out, err := run(t, NewWrite(dir, track), WriteArgs{Path: "existing.go", Content: "package replaced\n"}) if err != nil { t.Fatalf("write: %v", err) } if !strings.Contains(out, "Replaced") { t.Errorf("summary = %q, want it to distinguish a rewrite from a create", out) } got, _ := os.ReadFile(path) if string(got) != "package replaced\n" { t.Errorf("content = %q", got) } } func TestWriteFilePreservesCRLFOnRewrite(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "win.txt", "one\r\ntwo\r\n") track := newTracker() info, _ := os.Stat(path) track.MarkRead(path, info.ModTime()) if _, err := run(t, NewWrite(dir, track), WriteArgs{Path: "win.txt", Content: "three\nfour\n"}); err != nil { t.Fatalf("write: %v", err) } got, _ := os.ReadFile(path) if string(got) != "three\r\nfour\r\n" { t.Errorf("got %q, want CRLF preserved", got) } } func TestRunCommandReportsExitCodeAndBothStreams(t *testing.T) { report, failed := RunCommand(context.Background(), t.TempDir(), []string{"bash", "-c", "echo out; echo problem >&2; exit 3"}, time.Minute) if !failed { t.Error("a non-zero exit is a failure the caller has to be able to see") } for _, want := range []string{"exit code: 3", "--- stdout ---\nout", "--- stderr ---\nproblem"} { if !strings.Contains(report, want) { t.Errorf("missing %q in:\n%s", want, report) } } } func TestRunCommandSucceedsInTheGivenDirectory(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "hello.txt", "hi\n") report, failed := RunCommand(context.Background(), dir, []string{"bash", "-c", "cat hello.txt"}, time.Minute) if failed { t.Errorf("want success, got:\n%s", report) } if !strings.Contains(report, "--- stdout ---\nhi") { t.Errorf("got:\n%s", report) } } func TestRunCommandReportsAMissingBinaryRatherThanAnExitCode(t *testing.T) { report, failed := RunCommand(context.Background(), t.TempDir(), []string{"definitely-not-a-real-binary-xyz"}, time.Minute) if !failed { t.Fatal("a binary that is not there is a failure") } if !strings.Contains(report, "failed to run") { t.Errorf("want a run failure, got:\n%s", report) } }