package executor import ( "context" "fmt" "io" "log/slog" "os" "strings" "testing" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) func TestContainerRunner_BuildDockerArgs(t *testing.T) { runner := &ContainerRunner{ APIURL: "http://localhost:8484", DropsDir: "/data/drops", } workspace := "/tmp/ws" taskID := "task-123" args := runner.buildDockerArgs(workspace, taskID) expected := []string{ "run", "--rm", "-v", "/tmp/ws:/workspace", "-w", "/workspace", "--env-file", "/tmp/ws/.claudomator-env", "-e", "CLAUDOMATOR_API_URL=http://localhost:8484", "-e", "CLAUDOMATOR_TASK_ID=task-123", "-e", "CLAUDOMATOR_DROP_DIR=/data/drops", } if len(args) != len(expected) { t.Fatalf("expected %d args, got %d", len(expected), len(args)) } for i, v := range args { if v != expected[i] { t.Errorf("arg %d: expected %q, got %q", i, expected[i], v) } } } func TestContainerRunner_BuildInnerCmd(t *testing.T) { runner := &ContainerRunner{} t.Run("claude-fresh", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} exec := &storage.Execution{} cmd := runner.buildInnerCmd(tk, exec, false) cmdStr := strings.Join(cmd, " ") if strings.Contains(cmdStr, "--resume") { t.Errorf("unexpected --resume flag in fresh run: %q", cmdStr) } if !strings.Contains(cmdStr, "INST=$(cat /workspace/.claudomator-instructions.txt); claude -p \"$INST\"") { t.Errorf("expected cat instructions in sh command, got %q", cmdStr) } }) t.Run("claude-resume", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "claude"}} exec := &storage.Execution{ResumeSessionID: "orig-session-123"} cmd := runner.buildInnerCmd(tk, exec, true) cmdStr := strings.Join(cmd, " ") if !strings.Contains(cmdStr, "--resume orig-session-123") { t.Errorf("expected --resume flag with correct session ID, got %q", cmdStr) } }) t.Run("gemini", func(t *testing.T) { tk := &task.Task{Agent: task.AgentConfig{Type: "gemini"}} exec := &storage.Execution{} cmd := runner.buildInnerCmd(tk, exec, false) cmdStr := strings.Join(cmd, " ") if !strings.Contains(cmdStr, "gemini -p \"$INST\"") { t.Errorf("expected gemini command with safer quoting, got %q", cmdStr) } }) } func TestContainerRunner_Run_PreservesWorkspaceOnFailure(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) runner := &ContainerRunner{ Logger: logger, Image: "busybox", } // Use an invalid repo URL to trigger failure. tk := &task.Task{ ID: "test-task", RepositoryURL: "/nonexistent/repo", Agent: task.AgentConfig{Type: "claude"}, } exec := &storage.Execution{ID: "test-exec", TaskID: "test-task"} err := runner.Run(context.Background(), tk, exec) if err == nil { t.Fatal("expected error due to invalid repo") } // Verify SandboxDir was set and directory exists. if exec.SandboxDir == "" { t.Fatal("expected SandboxDir to be set even on failure") } if _, statErr := os.Stat(exec.SandboxDir); statErr != nil { t.Errorf("expected sandbox directory to be preserved, but stat failed: %v", statErr) } else { os.RemoveAll(exec.SandboxDir) } } func TestBlockedError_IncludesSandboxDir(t *testing.T) { // This test requires mocking 'docker run' or the whole Run() which is hard. // But we can test that returning BlockedError works. err := &BlockedError{ QuestionJSON: `{"text":"?"}`, SessionID: "s1", SandboxDir: "/tmp/s1", } if !strings.Contains(err.Error(), "task blocked") { t.Errorf("wrong error message: %v", err) } } func TestIsCompletionReport(t *testing.T) { tests := []struct { name string json string expected bool }{ { name: "real question with options", json: `{"text": "Should I proceed with implementation?", "options": ["Yes", "No"]}`, expected: false, }, { name: "real question no options", json: `{"text": "Which approach do you prefer?"}`, expected: false, }, { name: "completion report no options no question mark", json: `{"text": "All tests pass. Implementation complete. Summary written to CLAUDOMATOR_SUMMARY_FILE."}`, expected: true, }, { name: "completion report with empty options", json: `{"text": "Feature implemented and committed.", "options": []}`, expected: true, }, { name: "invalid json treated as not a report", json: `not json`, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isCompletionReport(tt.json) if got != tt.expected { t.Errorf("isCompletionReport(%q) = %v, want %v", tt.json, got, tt.expected) } }) } } func TestTailFile_ReturnsLastNLines(t *testing.T) { f, err := os.CreateTemp("", "tailfile-*") if err != nil { t.Fatal(err) } defer os.Remove(f.Name()) for i := 1; i <= 30; i++ { fmt.Fprintf(f, "line %d\n", i) } f.Close() got := tailFile(f.Name(), 5) lines := strings.Split(strings.TrimSpace(got), "\n") if len(lines) != 5 { t.Fatalf("want 5 lines, got %d: %q", len(lines), got) } if lines[0] != "line 26" || lines[4] != "line 30" { t.Errorf("want lines 26-30, got: %q", got) } } func TestGitSafe_PrependsSafeDirectory(t *testing.T) { got := gitSafe("-C", "/some/path", "status") want := []string{"-c", "safe.directory=*", "-C", "/some/path", "status"} if len(got) != len(want) { t.Fatalf("gitSafe() = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("gitSafe()[%d] = %q, want %q", i, got[i], want[i]) } } }