package executor import ( "context" "io" "log/slog" "path/filepath" "runtime" "strings" "testing" "time" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) func TestClaudeRunner_BuildArgs_BasicTask(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "fix the bug", Model: "sonnet", SkipPlanning: true, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") argMap := make(map[string]bool) for _, a := range args { argMap[a] = true } for _, want := range []string{"-p", "fix the bug", "--output-format", "stream-json", "--verbose", "--model", "sonnet"} { if !argMap[want] { t.Errorf("missing arg %q in %v", want, args) } } } func TestClaudeRunner_BuildArgs_FullConfig(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "implement feature", Model: "opus", MaxBudgetUSD: 5.0, PermissionMode: "bypassPermissions", SystemPromptAppend: "Follow TDD", AllowedTools: []string{"Bash", "Edit"}, DisallowedTools: []string{"Write"}, ContextFiles: []string{"/src"}, AdditionalArgs: []string{"--verbose"}, SkipPlanning: true, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") // Check key args are present. argMap := make(map[string]bool) for _, a := range args { argMap[a] = true } requiredArgs := []string{ "-p", "implement feature", "--output-format", "stream-json", "--model", "opus", "--max-budget-usd", "5.00", "--permission-mode", "bypassPermissions", "--append-system-prompt", "Follow TDD", "--allowedTools", "Bash", "Edit", "--disallowedTools", "Write", "--add-dir", "/src", "--verbose", } for _, req := range requiredArgs { if !argMap[req] { t.Errorf("missing arg %q in %v", req, args) } } } func TestClaudeRunner_BuildArgs_DefaultsToBypassPermissions(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do work", SkipPlanning: true, // PermissionMode intentionally not set }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") found := false for i, a := range args { if a == "--permission-mode" && i+1 < len(args) && args[i+1] == "bypassPermissions" { found = true } } if !found { t.Errorf("expected --permission-mode bypassPermissions when PermissionMode is empty, args: %v", args) } } func TestClaudeRunner_BuildArgs_RespectsExplicitPermissionMode(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do work", PermissionMode: "default", SkipPlanning: true, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") for i, a := range args { if a == "--permission-mode" && i+1 < len(args) { if args[i+1] != "default" { t.Errorf("expected --permission-mode default, got %q", args[i+1]) } return } } t.Errorf("--permission-mode flag not found in args: %v", args) } func TestClaudeRunner_BuildArgs_AlwaysIncludesVerbose(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do something", SkipPlanning: true, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") found := false for _, a := range args { if a == "--verbose" { found = true break } } if !found { t.Errorf("--verbose missing from args: %v", args) } } func TestClaudeRunner_BuildArgs_PreamblePrepended(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "fix the bug", SkipPlanning: false, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") // The -p value should start with the preamble and end with the original instructions. if len(args) < 2 || args[0] != "-p" { t.Fatalf("expected -p as first arg, got: %v", args) } if !strings.HasPrefix(args[1], planningPreamble) { t.Errorf("instructions should start with planning preamble") } if !strings.HasSuffix(args[1], "fix the bug") { t.Errorf("instructions should end with original instructions") } } func TestClaudeRunner_BuildArgs_PreambleAddsBash(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do work", AllowedTools: []string{"Read"}, SkipPlanning: false, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") // Bash should be appended to allowed tools. foundBash := false for i, a := range args { if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" { foundBash = true } } if !foundBash { t.Errorf("Bash should be added to --allowedTools when preamble is active: %v", args) } } func TestClaudeRunner_BuildArgs_PreambleBashNotDuplicated(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do work", AllowedTools: []string{"Bash", "Read"}, SkipPlanning: false, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") // Count Bash occurrences in --allowedTools values. bashCount := 0 for i, a := range args { if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" { bashCount++ } } if bashCount != 1 { t.Errorf("Bash should appear exactly once in --allowedTools, got %d: %v", bashCount, args) } } func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) { r := &ClaudeRunner{ BinaryPath: "true", // would succeed if it ran Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), LogDir: t.TempDir(), } tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", ProjectDir: "/nonexistent/path/does/not/exist", SkipPlanning: true, }, } exec := &storage.Execution{ID: "test-exec"} err := r.Run(context.Background(), tk, exec) if err == nil { t.Fatal("expected error for inaccessible working_dir, got nil") } if !strings.Contains(err.Error(), "project_dir") { t.Errorf("expected 'project_dir' in error, got: %v", err) } } func TestClaudeRunner_BinaryPath_Default(t *testing.T) { r := &ClaudeRunner{} if r.binaryPath() != "claude" { t.Errorf("want 'claude', got %q", r.binaryPath()) } } func TestClaudeRunner_BinaryPath_Custom(t *testing.T) { r := &ClaudeRunner{BinaryPath: "/usr/local/bin/claude"} if r.binaryPath() != "/usr/local/bin/claude" { t.Errorf("want custom path, got %q", r.binaryPath()) } } // TestExecOnce_NoGoroutineLeak_OnNaturalExit verifies that execOnce does not // leave behind any goroutines when the subprocess exits normally (no context // cancellation). Both the pgid-kill goroutine and the parseStream goroutine // must have exited before execOnce returns. func TestExecOnce_NoGoroutineLeak_OnNaturalExit(t *testing.T) { logDir := t.TempDir() r := &ClaudeRunner{ BinaryPath: "true", // exits immediately with status 0, produces no output Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), LogDir: logDir, } e := &storage.Execution{ ID: "goroutine-leak-test", TaskID: "task-id", StdoutPath: filepath.Join(logDir, "stdout.log"), StderrPath: filepath.Join(logDir, "stderr.log"), ArtifactDir: logDir, } // Let any goroutines from test infrastructure settle before sampling. runtime.Gosched() baseline := runtime.NumGoroutine() if err := r.execOnce(context.Background(), []string{}, "", e); err != nil { t.Fatalf("execOnce failed: %v", err) } // Give the scheduler a moment to let any leaked goroutines actually exit. // In correct code the goroutines exit before execOnce returns, so this is // just a safety buffer for the scheduler. time.Sleep(10 * time.Millisecond) runtime.Gosched() after := runtime.NumGoroutine() if after > baseline { t.Errorf("goroutine leak: %d goroutines before execOnce, %d after (leaked %d)", baseline, after, after-baseline) } }