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)
}
}