package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// waitFor polls until cond holds, so a test never sleeps longer than it must
// and never races a process that has not been scheduled yet.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// A dev server is the case this exists for: the call must come back with a
// handle immediately rather than blocking until the server stops.
func TestBackgroundBashReturnsAHandleWithoutWaiting(t *testing.T) {
procs := NewProcesses()
defer procs.Close()
start := time.Now()
out, err := run(t, NewBash(t.TempDir(), WithProcesses(procs)), BashArgs{
Command: "echo started; sleep 30",
Background: true,
})
if err != nil {
t.Fatalf("background run: %v", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Errorf("the call blocked for %s; a background command must return at once", elapsed)
}
if !strings.Contains(out, "bg_1") {
t.Errorf("result %q does not name the handle the model has to poll with", out)
}
if !strings.Contains(out, "process_output") {
t.Errorf("result %q does not say how to read the output", out)
}
}
// Output accumulates while the process runs, and each read returns only what
// is new: polling a chatty server must not re-send its whole log every turn.
func TestProcessOutputIsIncremental(t *testing.T) {
procs := NewProcesses()
defer procs.Close()
dir := t.TempDir()
if _, err := run(t, NewBash(dir, WithProcesses(procs)), BashArgs{
Command: "echo first; sleep 0.4; echo second; sleep 30",
Background: true,
}); err != nil {
t.Fatal(err)
}
outTool := NewProcessOutput(procs)
waitFor(t, "the first line", func() bool {
got, _ := run(t, outTool, ProcessOutputArgs{ID: "bg_1"})
return strings.Contains(body(got), "first")
})
// "first" has been handed over, so it must not come back a second time.
waitFor(t, "the second line", func() bool {
got, _ := run(t, outTool, ProcessOutputArgs{ID: "bg_1"})
if strings.Contains(body(got), "first") {
t.Errorf("output was re-sent after being read:\n%s", got)
}
return strings.Contains(body(got), "second")
})
}
// body is everything after the status line. The status line quotes the command
// itself, so a test looking for a word the command also contains has to skip
// it or it matches the echo rather than the output.
func body(report string) string {
_, rest, _ := strings.Cut(report, "\n")
return rest
}
// Status is what the model acts on: still running, or finished with a code.
func TestProcessOutputReportsExit(t *testing.T) {
procs := NewProcesses()
defer procs.Close()
if _, err := run(t, NewBash(t.TempDir(), WithProcesses(procs)), BashArgs{
Command: "echo done; exit 3", Background: true,
}); err != nil {
t.Fatal(err)
}
outTool := NewProcessOutput(procs)
waitFor(t, "the process to exit", func() bool {
got, _ := run(t, outTool, ProcessOutputArgs{ID: "bg_1"})
return strings.Contains(got, "exit code: 3")
})
got, err := run(t, outTool, ProcessOutputArgs{ID: "bg_1"})
if err != nil {
t.Fatalf("reading a finished process is not an error: %v", err)
}
if !strings.Contains(got, "exit code: 3") {
t.Errorf("a finished process must keep reporting how it ended, got %q", got)
}
}
// Killing must take the children with it. A dev server started from a shell
// is a child of that shell, and killing only the shell leaves the port held.
func TestProcessKillTakesTheWholeGroup(t *testing.T) {
procs := NewProcesses()
defer procs.Close()
dir := t.TempDir()
marker := filepath.Join(dir, "child-survived")
if _, err := run(t, NewBash(dir, WithProcesses(procs)), BashArgs{
// The inner shell outlives its parent unless the group is killed.
Command: "(sleep 1; touch " + marker + ") & sleep 30",
Background: true,
}); err != nil {
t.Fatal(err)
}
waitFor(t, "the process to start", func() bool {
got, _ := run(t, NewProcessOutput(procs), ProcessOutputArgs{ID: "bg_1"})
return strings.Contains(got, "running")
})
if _, err := run(t, NewProcessKill(procs), ProcessKillArgs{ID: "bg_1"}); err != nil {
t.Fatalf("kill: %v", err)
}
time.Sleep(1500 * time.Millisecond)
if _, err := os.Stat(marker); err == nil {
t.Error("a child of the killed process was still alive and finished its work")
}
}
// An id the model made up costs a turn if the error says nothing useful.
func TestUnknownProcessIDNamesTheRealOnes(t *testing.T) {
procs := NewProcesses()
defer procs.Close()
if _, err := run(t, NewBash(t.TempDir(), WithProcesses(procs)), BashArgs{
Command: "sleep 30", Background: true,
}); err != nil {
t.Fatal(err)
}
_, err := run(t, NewProcessOutput(procs), ProcessOutputArgs{ID: "bg_99"})
if err == nil {
t.Fatal("an unknown id must be an error")
}
if !strings.Contains(err.Error(), "bg_1") {
t.Errorf("error %q should list the ids that do exist", err)
}
}
// With no id at all the tool lists what is running, so the model can recover
// from having lost track without guessing.
func TestProcessOutputWithoutIDLists(t *testing.T) {
procs := NewProcesses()
defer procs.Close()
if _, err := run(t, NewBash(t.TempDir(), WithProcesses(procs)), BashArgs{
Command: "sleep 30", Background: true,
}); err != nil {
t.Fatal(err)
}
got, err := run(t, NewProcessOutput(procs), ProcessOutputArgs{})
if err != nil {
t.Fatalf("listing: %v", err)
}
if !strings.Contains(got, "bg_1") || !strings.Contains(got, "sleep 30") {
t.Errorf("listing %q should name each process and the command behind it", got)
}
}
// Nothing may outlive the session: an orphaned dev server holds its port and
// the user never sees the process that is doing it.
func TestCloseKillsEverything(t *testing.T) {
procs := NewProcesses()
dir := t.TempDir()
marker := filepath.Join(dir, "survived")
if _, err := run(t, NewBash(dir, WithProcesses(procs)), BashArgs{
Command: "sleep 0.8; touch " + marker, Background: true,
}); err != nil {
t.Fatal(err)
}
waitFor(t, "the process to start", func() bool {
got, _ := run(t, NewProcessOutput(procs), ProcessOutputArgs{ID: "bg_1"})
return strings.Contains(got, "running")
})
procs.Close()
time.Sleep(1200 * time.Millisecond)
if _, err := os.Stat(marker); err == nil {
t.Error("a background process survived Close and finished its work")
}
}
// A server that logs forever must not grow the buffer without bound.
func TestBackgroundOutputIsCapped(t *testing.T) {
procs := NewProcesses()
defer procs.Close()
if _, err := run(t, NewBash(t.TempDir(), WithProcesses(procs)), BashArgs{
Command: "seq 1 200000", Background: true,
}); err != nil {
t.Fatal(err)
}
outTool := NewProcessOutput(procs)
waitFor(t, "the process to finish", func() bool {
got, _ := run(t, outTool, ProcessOutputArgs{ID: "bg_1"})
return strings.Contains(got, "exit code: 0")
})
if n := procs.buffered("bg_1"); n > maxBackgroundOutput {
t.Errorf("buffered %d bytes, want at most %d", n, maxBackgroundOutput)
}
}
// Without the option the tool is exactly what it was: background is refused
// with an error that says so rather than silently running in the foreground.
func TestBackgroundRefusedWhenUnsupported(t *testing.T) {
_, err := run(t, NewBash(t.TempDir()), BashArgs{Command: "echo hi", Background: true})
if err == nil || !strings.Contains(err.Error(), "background") {
t.Errorf("err = %v, want a refusal naming the unsupported feature", err)
}
}
func TestForegroundStillWorksWithProcesses(t *testing.T) {
procs := NewProcesses()
defer procs.Close()
out, err := run(t, NewBash(t.TempDir(), WithProcesses(procs)), BashArgs{Command: "echo hello"})
if err != nil {
t.Fatalf("foreground: %v", err)
}
if !strings.Contains(out, "hello") || !strings.Contains(out, "exit code: 0") {
t.Errorf("foreground output changed shape: %q", out)
}
}
func TestKillIsIdempotent(t *testing.T) {
procs := NewProcesses()
defer procs.Close()
if _, err := run(t, NewBash(t.TempDir(), WithProcesses(procs)), BashArgs{
Command: "sleep 30", Background: true,
}); err != nil {
t.Fatal(err)
}
kill := NewProcessKill(procs)
if _, err := run(t, kill, ProcessKillArgs{ID: "bg_1"}); err != nil {
t.Fatalf("first kill: %v", err)
}
// Killing something already dead is a no-op, not an error: the model
// cleaning up at the end of a task should not have to check first.
if _, err := run(t, kill, ProcessKillArgs{ID: "bg_1"}); err != nil {
t.Errorf("second kill: %v", err)
}
}
func TestProcessesToolsAreNilWithoutAManager(t *testing.T) {
if NewProcessOutput(nil) != nil || NewProcessKill(nil) != nil {
t.Error("with no manager the tools must not be registered at all")
}
}
var _ = context.Background