diff --git a/cmd/entire/cli/e2e_test/agent_runner.go b/cmd/entire/cli/e2e_test/agent_runner.go new file mode 100644 index 000000000..97b681023 --- /dev/null +++ b/cmd/entire/cli/e2e_test/agent_runner.go @@ -0,0 +1,251 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "time" +) + +// AgentNameClaudeCode is the name for Claude Code agent. +const AgentNameClaudeCode = "claude-code" + +// AgentNameGeminiCLI is the name for Gemini CLI agent. +const AgentNameGeminiCLI = "gemini-cli" + +// AgentRunner abstracts invoking a coding agent for e2e tests. +// This follows the multi-agent pattern from cmd/entire/cli/agent/agent.go. +type AgentRunner interface { + // Name returns the agent name (e.g., "claude-code", "gemini-cli") + Name() string + + // IsAvailable checks if the agent CLI is installed and authenticated + IsAvailable() (bool, error) + + // RunPrompt executes a prompt and returns the result + RunPrompt(ctx context.Context, workDir string, prompt string) (*AgentResult, error) + + // RunPromptWithTools executes with specific allowed tools + RunPromptWithTools(ctx context.Context, workDir string, prompt string, tools []string) (*AgentResult, error) +} + +// AgentResult holds the result of an agent invocation. +type AgentResult struct { + Stdout string + Stderr string + ExitCode int + Duration time.Duration +} + +// AgentRunnerConfig holds configuration for agent runners. +type AgentRunnerConfig struct { + Model string // Model to use (e.g., "haiku" for Claude) + Timeout time.Duration // Timeout per prompt +} + +// NewAgentRunner creates an agent runner based on the agent name. +// +//nolint:ireturn // factory pattern intentionally returns interface +func NewAgentRunner(name string, config AgentRunnerConfig) AgentRunner { + switch name { + case AgentNameClaudeCode: + return NewClaudeCodeRunner(config) + case AgentNameGeminiCLI: + return NewGeminiCLIRunner(config) + default: + // Return a runner that reports as unavailable + return &unavailableRunner{name: name} + } +} + +// unavailableRunner is returned for unknown agent names. +type unavailableRunner struct { + name string +} + +func (r *unavailableRunner) Name() string { return r.name } + +func (r *unavailableRunner) IsAvailable() (bool, error) { + return false, fmt.Errorf("unknown agent: %s", r.name) +} + +func (r *unavailableRunner) RunPrompt(_ context.Context, _ string, _ string) (*AgentResult, error) { + return nil, fmt.Errorf("agent %s is not available", r.name) +} + +func (r *unavailableRunner) RunPromptWithTools(_ context.Context, _ string, _ string, _ []string) (*AgentResult, error) { + return nil, fmt.Errorf("agent %s is not available", r.name) +} + +// ClaudeCodeRunner implements AgentRunner for Claude Code CLI. +type ClaudeCodeRunner struct { + Model string + Timeout time.Duration + AllowedTools []string +} + +// NewClaudeCodeRunner creates a new Claude Code runner with the given config. +func NewClaudeCodeRunner(config AgentRunnerConfig) *ClaudeCodeRunner { + model := config.Model + if model == "" { + model = os.Getenv("E2E_CLAUDE_MODEL") + if model == "" { + model = "haiku" + } + } + + timeout := config.Timeout + if timeout == 0 { + if envTimeout := os.Getenv("E2E_TIMEOUT"); envTimeout != "" { + if parsed, err := time.ParseDuration(envTimeout); err == nil { + timeout = parsed + } + } + if timeout == 0 { + timeout = 2 * time.Minute + } + } + + return &ClaudeCodeRunner{ + Model: model, + Timeout: timeout, + AllowedTools: []string{"Edit", "Read", "Write", "Bash", "Glob", "Grep"}, + } +} + +func (r *ClaudeCodeRunner) Name() string { + return AgentNameClaudeCode +} + +// IsAvailable checks if Claude CLI is installed and responds to --version. +// Note: This does NOT verify authentication status. Claude Code uses OAuth +// authentication (via `claude login`), not ANTHROPIC_API_KEY. If the CLI is +// installed but not logged in, tests will fail at RunPrompt time. +func (r *ClaudeCodeRunner) IsAvailable() (bool, error) { + // Check if claude CLI is in PATH + if _, err := exec.LookPath("claude"); err != nil { + return false, fmt.Errorf("claude CLI not found in PATH: %w", err) + } + + // Check if claude is working (--version doesn't require auth) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "claude", "--version") + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("claude CLI not working: %w", err) + } + + return true, nil +} + +func (r *ClaudeCodeRunner) RunPrompt(ctx context.Context, workDir string, prompt string) (*AgentResult, error) { + return r.RunPromptWithTools(ctx, workDir, prompt, r.AllowedTools) +} + +func (r *ClaudeCodeRunner) RunPromptWithTools(ctx context.Context, workDir string, prompt string, tools []string) (*AgentResult, error) { + // Build command: claude --model -p "" --allowedTools + args := []string{ + "--model", r.Model, + "-p", prompt, + } + + if len(tools) > 0 { + // Claude CLI expects each tool as a separate argument after --allowedTools + // e.g., --allowedTools Edit Read Bash (not --allowedTools "Edit,Read,Bash") + args = append(args, "--allowedTools") + args = append(args, tools...) + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(ctx, r.Timeout) + defer cancel() + + //nolint:gosec // args are constructed from trusted config, not user input + cmd := exec.CommandContext(ctx, "claude", args...) + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + err := cmd.Run() + duration := time.Since(start) + + result := &AgentResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Duration: duration, + } + + if err != nil { + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = -1 + } + //nolint:wrapcheck // error is from exec.Run, caller can check ExitCode in result + return result, err + } + + result.ExitCode = 0 + return result, nil +} + +// GeminiCLIRunner implements AgentRunner for Gemini CLI. +// This is a placeholder for future implementation. +type GeminiCLIRunner struct { + Timeout time.Duration +} + +// NewGeminiCLIRunner creates a new Gemini CLI runner with the given config. +func NewGeminiCLIRunner(config AgentRunnerConfig) *GeminiCLIRunner { + timeout := config.Timeout + if timeout == 0 { + timeout = 2 * time.Minute + } + + return &GeminiCLIRunner{ + Timeout: timeout, + } +} + +func (r *GeminiCLIRunner) Name() string { + return AgentNameGeminiCLI +} + +// IsAvailable checks if Gemini CLI is installed and authenticated. +func (r *GeminiCLIRunner) IsAvailable() (bool, error) { + // Check if gemini CLI is in PATH + if _, err := exec.LookPath("gemini"); err != nil { + return false, fmt.Errorf("gemini CLI not found in PATH: %w", err) + } + + // Check if gemini is working + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "gemini", "--version") + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("gemini CLI not working: %w", err) + } + + return true, nil +} + +func (r *GeminiCLIRunner) RunPrompt(ctx context.Context, workDir string, prompt string) (*AgentResult, error) { + return r.RunPromptWithTools(ctx, workDir, prompt, nil) +} + +func (r *GeminiCLIRunner) RunPromptWithTools(_ context.Context, _ string, _ string, _ []string) (*AgentResult, error) { + // Gemini CLI implementation would go here + // For now, return an error indicating it's not fully implemented + return nil, errors.New("gemini CLI runner not yet implemented") +} diff --git a/cmd/entire/cli/e2e_test/assertions.go b/cmd/entire/cli/e2e_test/assertions.go new file mode 100644 index 000000000..835778029 --- /dev/null +++ b/cmd/entire/cli/e2e_test/assertions.go @@ -0,0 +1,158 @@ +//go:build e2e + +package e2e + +import ( + "regexp" + "strings" + "testing" +) + +// AssertFileContains checks that a file contains the expected substring. +func AssertFileContains(t *testing.T, env *TestEnv, path, expected string) { + t.Helper() + + content := env.ReadFile(path) + if !strings.Contains(content, expected) { + t.Errorf("File %s does not contain expected string.\nExpected substring: %q\nActual content:\n%s", + path, expected, content) + } +} + +// AssertFileMatches checks that a file content matches a regex pattern. +func AssertFileMatches(t *testing.T, env *TestEnv, path, pattern string) { + t.Helper() + + content := env.ReadFile(path) + matched, err := regexp.MatchString(pattern, content) + if err != nil { + t.Fatalf("Invalid regex pattern %q: %v", pattern, err) + } + if !matched { + t.Errorf("File %s does not match pattern.\nPattern: %q\nActual content:\n%s", + path, pattern, content) + } +} + +// AssertHelloWorldProgram verifies a Go file is a valid hello world program. +// Uses flexible matching to handle agent variations. +func AssertHelloWorldProgram(t *testing.T, env *TestEnv, path string) { + t.Helper() + + content := env.ReadFile(path) + + // Check for package main + if !strings.Contains(content, "package main") { + t.Errorf("File %s missing 'package main'", path) + } + + // Check for main function (flexible whitespace) + mainFuncPattern := regexp.MustCompile(`func\s+main\s*\(\s*\)`) + if !mainFuncPattern.MatchString(content) { + t.Errorf("File %s missing main function", path) + } + + // Check for Hello, World! (case insensitive) + helloPattern := regexp.MustCompile(`(?i)hello.+world`) + if !helloPattern.MatchString(content) { + t.Errorf("File %s missing 'Hello, World!' output", path) + } +} + +// AssertCalculatorFunctions verifies calc.go has the expected functions. +func AssertCalculatorFunctions(t *testing.T, env *TestEnv, path string, functions ...string) { + t.Helper() + + content := env.ReadFile(path) + + for _, fn := range functions { + // Check for function definition (flexible whitespace) + pattern := regexp.MustCompile(`func\s+` + regexp.QuoteMeta(fn) + `\s*\(`) + if !pattern.MatchString(content) { + t.Errorf("File %s missing function %s", path, fn) + } + } +} + +// AssertRewindPointExists checks that at least one rewind point exists. +func AssertRewindPointExists(t *testing.T, env *TestEnv) { + t.Helper() + + points := env.GetRewindPoints() + if len(points) == 0 { + t.Error("Expected at least one rewind point, but none exist") + } +} + +// AssertRewindPointCount checks that the expected number of rewind points exist. +func AssertRewindPointCount(t *testing.T, env *TestEnv, expected int) { + t.Helper() + + points := env.GetRewindPoints() + if len(points) != expected { + t.Errorf("Expected %d rewind points, got %d", expected, len(points)) + for i, p := range points { + t.Logf(" Point %d: ID=%s, Message=%s", i, p.ID, p.Message) + } + } +} + +// AssertRewindPointCountAtLeast checks that at least the expected number of rewind points exist. +func AssertRewindPointCountAtLeast(t *testing.T, env *TestEnv, minimum int) { + t.Helper() + + points := env.GetRewindPoints() + if len(points) < minimum { + t.Errorf("Expected at least %d rewind points, got %d", minimum, len(points)) + for i, p := range points { + t.Logf(" Point %d: ID=%s, Message=%s", i, p.ID, p.Message) + } + } +} + +// AssertCheckpointExists checks that a checkpoint trailer exists in commit history. +func AssertCheckpointExists(t *testing.T, env *TestEnv) { + t.Helper() + + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + if err != nil || checkpointID == "" { + t.Errorf("Expected checkpoint trailer in commit history, but none found: %v", err) + } +} + +// AssertBranchExists checks that a branch exists. +func AssertBranchExists(t *testing.T, env *TestEnv, branchName string) { + t.Helper() + + if !env.BranchExists(branchName) { + t.Errorf("Expected branch %s to exist, but it doesn't", branchName) + } +} + +// AssertAgentSuccess checks that an agent result indicates success. +func AssertAgentSuccess(t *testing.T, result *AgentResult, err error) { + t.Helper() + + if err != nil { + stderr := "" + if result != nil { + stderr = result.Stderr + } + t.Errorf("Agent failed with error: %v\nStderr: %s", err, stderr) + } + if result != nil && result.ExitCode != 0 { + t.Errorf("Agent exited with code %d\nStdout: %s\nStderr: %s", + result.ExitCode, result.Stdout, result.Stderr) + } +} + +// AssertExpectedFilesExist checks that all expected files from a prompt template exist. +func AssertExpectedFilesExist(t *testing.T, env *TestEnv, prompt PromptTemplate) { + t.Helper() + + for _, file := range prompt.ExpectedFiles { + if !env.FileExists(file) { + t.Errorf("Expected file %s to exist after prompt %s, but it doesn't", file, prompt.Name) + } + } +} diff --git a/cmd/entire/cli/e2e_test/prompts.go b/cmd/entire/cli/e2e_test/prompts.go new file mode 100644 index 000000000..51c811654 --- /dev/null +++ b/cmd/entire/cli/e2e_test/prompts.go @@ -0,0 +1,97 @@ +//go:build e2e + +package e2e + +// PromptTemplate defines a deterministic prompt with expected outcomes. +type PromptTemplate struct { + Name string // Unique name for the prompt + Prompt string // The actual prompt text + ExpectedFiles []string // Files expected to be created/modified +} + +// Deterministic prompts designed for predictable outcomes with minimal token usage. + +// PromptCreateHelloGo creates a simple Go hello world program. +var PromptCreateHelloGo = PromptTemplate{ + Name: "CreateHelloGo", + Prompt: `Create a file called hello.go with a simple Go program that prints "Hello, World!". +Requirements: +- Use package main +- Use a main function +- Use fmt.Println to print exactly "Hello, World!" +- Do not add comments, tests, or extra functionality +- Do not create any other files`, + ExpectedFiles: []string{"hello.go"}, +} + +// PromptModifyHelloGo modifies the hello.go file to print a different message. +var PromptModifyHelloGo = PromptTemplate{ + Name: "ModifyHelloGo", + Prompt: `Modify hello.go to print "Hello, E2E Test!" instead of "Hello, World!". +Do not add any other functionality or files.`, + ExpectedFiles: []string{"hello.go"}, +} + +// PromptCreateCalculator creates a simple calculator with add/subtract functions. +var PromptCreateCalculator = PromptTemplate{ + Name: "CreateCalculator", + Prompt: `Create a file called calc.go with two exported functions: +- Add(a, b int) int - returns a + b +- Subtract(a, b int) int - returns a - b +Requirements: +- Use package main +- No comments or documentation +- No main function +- No tests +- No other files`, + ExpectedFiles: []string{"calc.go"}, +} + +// PromptCreateConfig creates a simple JSON config file. +var PromptCreateConfig = PromptTemplate{ + Name: "CreateConfig", + Prompt: `Create a file called config.json with this exact content: +{ + "name": "e2e-test", + "version": "1.0.0", + "enabled": true +} +Do not create any other files.`, + ExpectedFiles: []string{"config.json"}, +} + +// PromptAddMultiplyFunction adds a multiply function to calc.go. +var PromptAddMultiplyFunction = PromptTemplate{ + Name: "AddMultiplyFunction", + Prompt: `Add a Multiply function to calc.go that multiplies two integers. +- Signature: Multiply(a, b int) int +- No comments +- No other changes`, + ExpectedFiles: []string{"calc.go"}, +} + +// PromptCreateDocs creates a simple DOCS.md documentation file. +// Note: Uses DOCS.md instead of README.md to avoid conflicts with +// the README.md created by NewFeatureBranchEnv during test setup. +var PromptCreateDocs = PromptTemplate{ + Name: "CreateDocs", + Prompt: `Create a file called DOCS.md with exactly this content: +# Documentation + +This is the documentation file. + +## Usage + +Run the program with: go run . +`, + ExpectedFiles: []string{"DOCS.md"}, +} + +// PromptCommitChanges instructs the agent to commit changes. +// This is used to test agent commits during a turn. +var PromptCommitChanges = PromptTemplate{ + Name: "CommitChanges", + Prompt: `Stage and commit all changes with the message "Add feature via agent". +Use git add and git commit commands.`, + ExpectedFiles: []string{}, +} diff --git a/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go b/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go new file mode 100644 index 000000000..9442de7be --- /dev/null +++ b/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go @@ -0,0 +1,118 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestE2E_AgentCommitsDuringTurn tests what happens when the agent commits during its turn. +// This is a P1 test because it tests the deferred finalization behavior. +func TestE2E_AgentCommitsDuringTurn(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. First, agent creates a file + t.Log("Step 1: Agent creating file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("hello.go")) + + // 2. Agent commits the changes (using Bash tool) + t.Log("Step 2: Agent committing changes") + commitPrompt := `Stage and commit the hello.go file with commit message "Add hello world via agent". +Use these exact commands: +1. git add hello.go +2. git commit -m "Add hello world via agent" +Only run these two commands, nothing else.` + + result, err = env.RunAgentWithTools(commitPrompt, []string{"Bash"}) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + t.Logf("Agent commit output: %s", result.Stdout) + + // 3. Verify the commit was made + t.Log("Step 3: Verifying commit was made") + headMsg := env.GetCommitMessage(env.GetHeadHash()) + t.Logf("HEAD commit message: %s", headMsg) + + // The commit might or might not have the Entire-Checkpoint trailer depending + // on hook configuration. The key thing is the commit was made. + + // 4. Check rewind points + t.Log("Step 4: Checking rewind points") + points := env.GetRewindPoints() + t.Logf("Found %d rewind points after agent commit", len(points)) + + // 5. Agent makes another change after committing + t.Log("Step 5: Agent making another change") + result, err = env.RunAgent(PromptCreateCalculator.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("calc.go")) + + // 6. User commits the second change + t.Log("Step 6: User committing second change") + env.GitCommitWithShadowHooks("Add calculator", "calc.go") + + // 7. Final verification + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + if err != nil { + t.Logf("No checkpoint ID found: %v", err) + } else { + t.Logf("Final checkpoint ID: %s", checkpointID) + } +} + +// TestE2E_MultipleAgentSessions tests behavior across multiple agent sessions. +func TestE2E_MultipleAgentSessions(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // Session 1: Create hello.go + t.Log("Session 1: Creating hello.go") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + session1Points := env.GetRewindPoints() + t.Logf("After session 1: %d rewind points", len(session1Points)) + + // User commits + env.GitCommitWithShadowHooks("Session 1: Add hello world", "hello.go") + + // Session 2: Create calc.go + t.Log("Session 2: Creating calc.go") + result, err = env.RunAgent(PromptCreateCalculator.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + session2Points := env.GetRewindPoints() + t.Logf("After session 2: %d rewind points", len(session2Points)) + + // User commits + env.GitCommitWithShadowHooks("Session 2: Add calculator", "calc.go") + + // Session 3: Add multiply function + t.Log("Session 3: Adding multiply function") + result, err = env.RunAgent(PromptAddMultiplyFunction.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Verify multiply function was added + AssertCalculatorFunctions(t, env, "calc.go", "Add", "Subtract", "Multiply") + + // User commits + env.GitCommitWithShadowHooks("Session 3: Add multiply function", "calc.go") + + // Final check: we should have checkpoint IDs in commit history + t.Log("Final verification") + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint in commit history") + require.NotEmpty(t, checkpointID, "Should have checkpoint in final commit") +} diff --git a/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go b/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go new file mode 100644 index 000000000..4feecdde9 --- /dev/null +++ b/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go @@ -0,0 +1,89 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestE2E_BasicWorkflow tests the fundamental workflow: +// Agent creates a file -> User commits -> Checkpoint is created +func TestE2E_BasicWorkflow(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates a file + t.Log("Step 1: Running agent to create hello.go") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err, "Agent should succeed") + AssertAgentSuccess(t, result, err) + t.Logf("Agent completed in %v", result.Duration) + + // 2. Verify file was created with expected content + t.Log("Step 2: Verifying file was created") + require.True(t, env.FileExists("hello.go"), "hello.go should exist") + AssertHelloWorldProgram(t, env, "hello.go") + + // 3. Verify rewind points exist (session should have created checkpoints) + t.Log("Step 3: Checking for rewind points") + points := env.GetRewindPoints() + assert.GreaterOrEqual(t, len(points), 1, "Should have at least 1 rewind point") + if len(points) > 0 { + t.Logf("Found %d rewind point(s), first: %s", len(points), points[0].Message) + } + + // 4. User commits the changes with hooks + t.Log("Step 4: Committing changes with hooks") + env.GitCommitWithShadowHooks("Add hello world program", "hello.go") + + // 5. Verify checkpoint was created (trailer in commit) + t.Log("Step 5: Verifying checkpoint") + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint in commit history") + assert.NotEmpty(t, checkpointID, "Commit should have Entire-Checkpoint trailer") + t.Logf("Checkpoint ID: %s", checkpointID) + + // 6. Verify metadata branch exists + t.Log("Step 6: Checking metadata branch") + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "entire/checkpoints/v1 branch should exist") +} + +// TestE2E_MultipleChanges tests multiple agent changes before commit. +func TestE2E_MultipleChanges(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. First agent action: create hello.go + t.Log("Step 1: Creating first file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("hello.go")) + + // 2. Second agent action: create calc.go + t.Log("Step 2: Creating second file") + result, err = env.RunAgent(PromptCreateCalculator.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("calc.go")) + + // 3. Verify multiple rewind points exist + t.Log("Step 3: Checking rewind points") + points := env.GetRewindPoints() + assert.GreaterOrEqual(t, len(points), 2, "Should have at least 2 rewind points") + + // 4. Commit both files + t.Log("Step 4: Committing all changes") + env.GitCommitWithShadowHooks("Add hello world and calculator", "hello.go", "calc.go") + + // 5. Verify checkpoint + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint in commit history") + assert.NotEmpty(t, checkpointID) +} diff --git a/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go b/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go new file mode 100644 index 000000000..0cc78b8e2 --- /dev/null +++ b/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go @@ -0,0 +1,118 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestE2E_CheckpointMetadata verifies that checkpoint metadata is correctly stored. +func TestE2E_CheckpointMetadata(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates a file + t.Log("Step 1: Agent creating file") + result, err := env.RunAgent(PromptCreateConfig.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + AssertExpectedFilesExist(t, env, PromptCreateConfig) + + // 2. Verify session created rewind points + t.Log("Step 2: Checking session rewind points") + points := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points), 1, "Should have rewind points before commit") + + // Note: Before commit, points are on the shadow branch + // They should have metadata directories set + for i, p := range points { + t.Logf("Rewind point %d: ID=%s, MetadataDir=%s, Message=%s", + i, safeIDPrefix(p.ID), p.MetadataDir, p.Message) + } + + // 3. User commits + t.Log("Step 3: Committing changes") + env.GitCommitWithShadowHooks("Add config file", "config.json") + + // 4. Verify checkpoint trailer added + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint ID in commit history") + require.NotEmpty(t, checkpointID, "Should have checkpoint ID in commit") + t.Logf("Checkpoint ID: %s", checkpointID) + + // 5. Verify metadata branch has content + assert.True(t, env.BranchExists("entire/checkpoints/v1"), + "Metadata branch should exist after commit") + + // 6. Verify rewind points now reference condensed metadata + t.Log("Step 4: Checking post-commit rewind points") + postPoints := env.GetRewindPoints() + // After commit, logs-only points from entire/checkpoints/v1 should exist + for i, p := range postPoints { + t.Logf("Post-commit point %d: ID=%s, IsLogsOnly=%v, CondensationID=%s", + i, safeIDPrefix(p.ID), p.IsLogsOnly, p.CondensationID) + } +} + +// TestE2E_CheckpointIDFormat verifies checkpoint ID format is correct. +func TestE2E_CheckpointIDFormat(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent makes changes + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // 2. User commits + env.GitCommitWithShadowHooks("Add hello world", "hello.go") + + // 3. Verify checkpoint ID format + checkpointID, err := env.GetLatestCheckpointIDFromHistory() + require.NoError(t, err, "Should find checkpoint ID in commit history") + require.NotEmpty(t, checkpointID) + + // Checkpoint ID should be 12 hex characters + assert.Len(t, checkpointID, 12, "Checkpoint ID should be 12 characters") + + // Should only contain hex characters + for _, c := range checkpointID { + assert.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'), + "Checkpoint ID should be lowercase hex: got %c", c) + } +} + +// TestE2E_AutoCommitStrategy tests the auto-commit strategy creates clean commits. +func TestE2E_AutoCommitStrategy(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "auto-commit") + + // 1. Agent creates a file + t.Log("Step 1: Agent creating file with auto-commit strategy") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // 2. Verify file exists + require.True(t, env.FileExists("hello.go")) + + // 3. With auto-commit, commits are created automatically + // Check if commits were made with checkpoint trailers + commitMsg := env.GetCommitMessage(env.GetHeadHash()) + t.Logf("Latest commit message: %s", commitMsg) + + // 4. Verify metadata branch exists + if env.BranchExists("entire/checkpoints/v1") { + t.Log("Metadata branch exists (auto-commit creates it)") + } + + // 5. Check for rewind points + points := env.GetRewindPoints() + t.Logf("Found %d rewind points", len(points)) +} diff --git a/cmd/entire/cli/e2e_test/scenario_rewind_test.go b/cmd/entire/cli/e2e_test/scenario_rewind_test.go new file mode 100644 index 000000000..4330790ac --- /dev/null +++ b/cmd/entire/cli/e2e_test/scenario_rewind_test.go @@ -0,0 +1,157 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestE2E_RewindToCheckpoint tests rewinding to a previous checkpoint. +func TestE2E_RewindToCheckpoint(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates first file + t.Log("Step 1: Creating first file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + require.True(t, env.FileExists("hello.go")) + + // Get first checkpoint + points1 := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points1), 1) + firstPointID := points1[0].ID + t.Logf("First checkpoint: %s", safeIDPrefix(firstPointID)) + + // Save original content + originalContent := env.ReadFile("hello.go") + + // 2. Agent modifies the file + t.Log("Step 2: Modifying file") + result, err = env.RunAgent(PromptModifyHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Verify content changed + modifiedContent := env.ReadFile("hello.go") + assert.NotEqual(t, originalContent, modifiedContent, "Content should have changed") + assert.Contains(t, modifiedContent, "E2E Test", "Should contain new message") + + // Get second checkpoint + points2 := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points2), 2, "Should have at least 2 checkpoints") + t.Logf("Now have %d checkpoints", len(points2)) + + // 3. Rewind to first checkpoint + t.Log("Step 3: Rewinding to first checkpoint") + err = env.Rewind(firstPointID) + require.NoError(t, err) + + // 4. Verify content was restored + t.Log("Step 4: Verifying content restored") + restoredContent := env.ReadFile("hello.go") + assert.Equal(t, originalContent, restoredContent, "Content should be restored to original") + assert.NotContains(t, restoredContent, "E2E Test", "Should not contain modified message") +} + +// TestE2E_RewindAfterCommit tests rewinding to a checkpoint after user commits. +func TestE2E_RewindAfterCommit(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates file + t.Log("Step 1: Creating file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Get checkpoint before commit + pointsBefore := env.GetRewindPoints() + require.GreaterOrEqual(t, len(pointsBefore), 1) + preCommitPointID := pointsBefore[0].ID + + // 2. User commits + t.Log("Step 2: Committing") + env.GitCommitWithShadowHooks("Add hello world", "hello.go") + + // 3. Agent modifies file (new session) + t.Log("Step 3: Modifying file after commit") + result, err = env.RunAgent(PromptModifyHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + modifiedContent := env.ReadFile("hello.go") + require.Contains(t, modifiedContent, "E2E Test") + + // 4. Get rewind points - should include both pre and post commit points + t.Log("Step 4: Getting rewind points") + points := env.GetRewindPoints() + t.Logf("Found %d rewind points", len(points)) + for i, p := range points { + t.Logf(" Point %d: %s (logs_only=%v, condensation_id=%s)", + i, safeIDPrefix(p.ID), p.IsLogsOnly, p.CondensationID) + } + + // 5. Rewind to pre-commit checkpoint + // After commit, the pre-commit checkpoint becomes logs-only because the shadow branch + // is condensed. Rewinding to a logs-only point should fail with a clear error. + t.Log("Step 5: Attempting rewind to pre-commit (logs-only) checkpoint") + err = env.Rewind(preCommitPointID) + + // Verify the current file state regardless of rewind result + currentContent := env.ReadFile("hello.go") + + if err != nil { + // Rewind failed as expected for logs-only checkpoint + t.Logf("Rewind to logs-only point failed as expected: %v", err) + // File should still have the modifications since rewind failed + assert.Contains(t, currentContent, "E2E Test", + "File should still have modifications after logs-only rewind failure") + } else { + // If rewind succeeded, the file might have been restored + // This could happen if the rewind point wasn't actually logs-only + t.Log("Rewind succeeded - checkpoint may not have been logs-only") + } +} + +// TestE2E_RewindMultipleFiles tests rewinding changes across multiple files. +func TestE2E_RewindMultipleFiles(t *testing.T) { + t.Parallel() + + env := NewFeatureBranchEnv(t, "manual-commit") + + // 1. Agent creates multiple files + t.Log("Step 1: Creating first file") + result, err := env.RunAgent(PromptCreateHelloGo.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Get checkpoint after first file + points1 := env.GetRewindPoints() + require.GreaterOrEqual(t, len(points1), 1) + afterFirstFile := points1[0].ID + + t.Log("Step 2: Creating second file") + result, err = env.RunAgent(PromptCreateCalculator.Prompt) + require.NoError(t, err) + AssertAgentSuccess(t, result, err) + + // Verify both files exist + require.True(t, env.FileExists("hello.go")) + require.True(t, env.FileExists("calc.go")) + + // 3. Rewind to after first file (before second) + t.Log("Step 3: Rewinding to after first file") + err = env.Rewind(afterFirstFile) + require.NoError(t, err) + + // 4. Verify only first file exists + assert.True(t, env.FileExists("hello.go"), "hello.go should still exist") + assert.False(t, env.FileExists("calc.go"), "calc.go should be removed by rewind") +} diff --git a/cmd/entire/cli/e2e_test/setup_test.go b/cmd/entire/cli/e2e_test/setup_test.go new file mode 100644 index 000000000..442c7b1a8 --- /dev/null +++ b/cmd/entire/cli/e2e_test/setup_test.go @@ -0,0 +1,106 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +// testBinaryPath holds the path to the CLI binary built once in TestMain. +// All tests share this binary to avoid repeated builds. +var testBinaryPath string + +// defaultAgent holds the agent to test with, determined in TestMain. +var defaultAgent string + +// TestMain builds the CLI binary once and checks agent availability before running tests. +func TestMain(m *testing.M) { + // Determine which agent to test with + defaultAgent = os.Getenv("E2E_AGENT") + if defaultAgent == "" { + defaultAgent = AgentNameClaudeCode + } + + // Check if the agent is available + runner := NewAgentRunner(defaultAgent, AgentRunnerConfig{}) + available, err := runner.IsAvailable() + if !available { + fmt.Printf("Agent %s not available (%v), skipping E2E tests\n", defaultAgent, err) + // Exit 0 to not fail CI when agent isn't configured + os.Exit(0) + } + + // Build binary once to a temp directory + tmpDir, err := os.MkdirTemp("", "entire-e2e-test-*") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create temp dir for binary: %v\n", err) + os.Exit(1) + } + + testBinaryPath = filepath.Join(tmpDir, "entire") + + moduleRoot := findModuleRoot() + ctx := context.Background() + + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", testBinaryPath, ".") + buildCmd.Dir = filepath.Join(moduleRoot, "cmd", "entire") + + buildOutput, err := buildCmd.CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to build CLI binary: %v\nOutput: %s\n", err, buildOutput) + os.RemoveAll(tmpDir) + os.Exit(1) + } + + // Add binary to PATH so hooks can find it. + // This is safe because: + // 1. TestMain runs once before any tests (no parallel conflict with os.Setenv) + // 2. Each test package gets its own TestMain execution + // 3. PATH is restored in the same TestMain after m.Run() completes + // 4. The binary path is unique per test run (temp dir) + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+origPath) + + // Run tests + code := m.Run() + + // Cleanup + os.Setenv("PATH", origPath) + os.RemoveAll(tmpDir) + os.Exit(code) +} + +// getTestBinary returns the path to the shared test binary. +// It panics if TestMain hasn't run (testBinaryPath is empty). +func getTestBinary() string { + if testBinaryPath == "" { + panic("testBinaryPath not set - TestMain must run before tests") + } + return testBinaryPath +} + +// findModuleRoot finds the Go module root by walking up from the current file. +func findModuleRoot() string { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + panic("failed to get current file path via runtime.Caller") + } + dir := filepath.Dir(thisFile) + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + panic("could not find go.mod starting from " + thisFile) + } + dir = parent + } +} diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go new file mode 100644 index 000000000..85837c257 --- /dev/null +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -0,0 +1,529 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/format/config" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// TestEnv manages an isolated test environment for E2E tests with real agent calls. +type TestEnv struct { + T *testing.T + RepoDir string + Agent AgentRunner +} + +// NewTestEnv creates a new isolated E2E test environment. +func NewTestEnv(t *testing.T) *TestEnv { + t.Helper() + + // Resolve symlinks on macOS where /var -> /private/var + repoDir := t.TempDir() + if resolved, err := filepath.EvalSymlinks(repoDir); err == nil { + repoDir = resolved + } + + // Create agent runner + agent := NewAgentRunner(defaultAgent, AgentRunnerConfig{}) + + return &TestEnv{ + T: t, + RepoDir: repoDir, + Agent: agent, + } +} + +// NewFeatureBranchEnv creates an E2E test environment ready for testing. +// It initializes the repo, creates an initial commit on main, +// checks out a feature branch, and sets up agent hooks. +func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { + t.Helper() + + env := NewTestEnv(t) + env.InitRepo() + env.WriteFile("README.md", "# Test Repository\n\nThis is a test repository for E2E testing.\n") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/e2e-test") + + // Use `entire enable` to set up everything (hooks, settings, etc.) + // This sets up .entire/settings.json and .claude/settings.json with hooks + env.RunEntireEnable(strategyName) + + return env +} + +// RunEntireEnable runs `entire enable` to set up the project with hooks. +// Uses the configured defaultAgent (from E2E_AGENT env var or "claude-code"). +func (env *TestEnv) RunEntireEnable(strategyName string) { + env.T.Helper() + + args := []string{ + "enable", + "--agent", defaultAgent, + "--strategy", strategyName, + "--telemetry=false", + "--force", // Force reinstall hooks in case they exist + } + + //nolint:gosec,noctx // test code, args are static + cmd := exec.Command(getTestBinary(), args...) + cmd.Dir = env.RepoDir + + output, err := cmd.CombinedOutput() + if err != nil { + env.T.Fatalf("entire enable failed: %v\nOutput: %s", err, output) + } + env.T.Logf("entire enable output: %s", output) +} + +// InitRepo initializes a git repository in the test environment. +func (env *TestEnv) InitRepo() { + env.T.Helper() + + repo, err := git.PlainInit(env.RepoDir, false) + if err != nil { + env.T.Fatalf("failed to init git repo: %v", err) + } + + // Configure git user for commits + cfg, err := repo.Config() + if err != nil { + env.T.Fatalf("failed to get repo config: %v", err) + } + cfg.User.Name = "E2E Test User" + cfg.User.Email = "e2e-test@example.com" + + // Disable GPG signing for test commits + if cfg.Raw == nil { + cfg.Raw = config.New() + } + cfg.Raw.Section("commit").SetOption("gpgsign", "false") + + if err := repo.SetConfig(cfg); err != nil { + env.T.Fatalf("failed to set repo config: %v", err) + } +} + +// WriteFile creates a file with the given content in the test repo. +func (env *TestEnv) WriteFile(path, content string) { + env.T.Helper() + + fullPath := filepath.Join(env.RepoDir, path) + + // Create parent directories + dir := filepath.Dir(fullPath) + //nolint:gosec // test code, permissions are intentionally standard + if err := os.MkdirAll(dir, 0o755); err != nil { + env.T.Fatalf("failed to create directory %s: %v", dir, err) + } + + //nolint:gosec // test code, permissions are intentionally standard + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + env.T.Fatalf("failed to write file %s: %v", path, err) + } +} + +// ReadFile reads a file from the test repo. +func (env *TestEnv) ReadFile(path string) string { + env.T.Helper() + + fullPath := filepath.Join(env.RepoDir, path) + //nolint:gosec // test code, path is from test setup + data, err := os.ReadFile(fullPath) + if err != nil { + env.T.Fatalf("failed to read file %s: %v", path, err) + } + return string(data) +} + +// TryReadFile reads a file from the test repo, returning empty string if not found. +func (env *TestEnv) TryReadFile(path string) string { + env.T.Helper() + + fullPath := filepath.Join(env.RepoDir, path) + //nolint:gosec // test code, path is from test setup + data, err := os.ReadFile(fullPath) + if err != nil { + return "" + } + return string(data) +} + +// FileExists checks if a file exists in the test repo. +func (env *TestEnv) FileExists(path string) bool { + env.T.Helper() + + fullPath := filepath.Join(env.RepoDir, path) + _, err := os.Stat(fullPath) + return err == nil +} + +// GitAdd stages files for commit. +func (env *TestEnv) GitAdd(paths ...string) { + env.T.Helper() + + repo, err := git.PlainOpen(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to open git repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + env.T.Fatalf("failed to get worktree: %v", err) + } + + for _, path := range paths { + if _, err := worktree.Add(path); err != nil { + env.T.Fatalf("failed to add file %s: %v", path, err) + } + } +} + +// GitCommit creates a commit with all staged files. +func (env *TestEnv) GitCommit(message string) { + env.T.Helper() + + repo, err := git.PlainOpen(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to open git repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + env.T.Fatalf("failed to get worktree: %v", err) + } + + _, err = worktree.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "E2E Test User", + Email: "e2e-test@example.com", + When: time.Now(), + }, + }) + if err != nil { + env.T.Fatalf("failed to commit: %v", err) + } +} + +// GitCommitWithShadowHooks stages and commits files, running the prepare-commit-msg +// and post-commit hooks like a real workflow. +func (env *TestEnv) GitCommitWithShadowHooks(message string, files ...string) { + env.T.Helper() + + // Stage files using go-git + for _, file := range files { + env.GitAdd(file) + } + + // Create a temp file for the commit message + msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG") + //nolint:gosec // test code, permissions are intentionally standard + if err := os.WriteFile(msgFile, []byte(message), 0o644); err != nil { + env.T.Fatalf("failed to write commit message file: %v", err) + } + + // Run prepare-commit-msg hook + //nolint:gosec,noctx // test code, args are from trusted test setup, no context needed + prepCmd := exec.Command(getTestBinary(), "hooks", "git", "prepare-commit-msg", msgFile, "message") + prepCmd.Dir = env.RepoDir + prepCmd.Env = append(os.Environ(), + "ENTIRE_TEST_TTY=1", + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), + "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + ) + if output, err := prepCmd.CombinedOutput(); err != nil { + env.T.Logf("prepare-commit-msg output: %s", output) + } + + // Read the modified message + //nolint:gosec // test code, path is from test setup + modifiedMsg, err := os.ReadFile(msgFile) + if err != nil { + env.T.Fatalf("failed to read modified commit message: %v", err) + } + + // Create the commit using go-git with the modified message + repo, err := git.PlainOpen(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to open git repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + env.T.Fatalf("failed to get worktree: %v", err) + } + + _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{ + Author: &object.Signature{ + Name: "E2E Test User", + Email: "e2e-test@example.com", + When: time.Now(), + }, + }) + if err != nil { + env.T.Fatalf("failed to commit: %v", err) + } + + // Run post-commit hook + //nolint:gosec,noctx // test code, args are from trusted test setup, no context needed + postCmd := exec.Command(getTestBinary(), "hooks", "git", "post-commit") + postCmd.Dir = env.RepoDir + postCmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), + "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + ) + if output, err := postCmd.CombinedOutput(); err != nil { + env.T.Logf("post-commit output: %s", output) + } +} + +// GitCheckoutNewBranch creates and checks out a new branch. +func (env *TestEnv) GitCheckoutNewBranch(branchName string) { + env.T.Helper() + + //nolint:noctx // test code, no context needed for git checkout + cmd := exec.Command("git", "checkout", "-b", branchName) + cmd.Dir = env.RepoDir + if output, err := cmd.CombinedOutput(); err != nil { + env.T.Fatalf("failed to checkout new branch %s: %v\nOutput: %s", branchName, err, output) + } +} + +// GetHeadHash returns the current HEAD commit hash. +func (env *TestEnv) GetHeadHash() string { + env.T.Helper() + + repo, err := git.PlainOpen(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to open git repo: %v", err) + } + + head, err := repo.Head() + if err != nil { + env.T.Fatalf("failed to get HEAD: %v", err) + } + + return head.Hash().String() +} + +// RewindPoint mirrors the rewind --list JSON output. +type RewindPoint struct { + ID string `json:"id"` + Message string `json:"message"` + MetadataDir string `json:"metadata_dir"` + Date time.Time `json:"date"` + IsTaskCheckpoint bool `json:"is_task_checkpoint"` + ToolUseID string `json:"tool_use_id"` + IsLogsOnly bool `json:"is_logs_only"` + CondensationID string `json:"condensation_id"` +} + +// GetRewindPoints returns available rewind points using the CLI. +func (env *TestEnv) GetRewindPoints() []RewindPoint { + env.T.Helper() + + //nolint:gosec,noctx // test code, args are static, no context needed + cmd := exec.Command(getTestBinary(), "rewind", "--list") + cmd.Dir = env.RepoDir + + output, err := cmd.CombinedOutput() + if err != nil { + env.T.Fatalf("rewind --list failed: %v\nOutput: %s", err, output) + } + + // Parse JSON output + var jsonPoints []struct { + ID string `json:"id"` + Message string `json:"message"` + MetadataDir string `json:"metadata_dir"` + Date string `json:"date"` + IsTaskCheckpoint bool `json:"is_task_checkpoint"` + ToolUseID string `json:"tool_use_id"` + IsLogsOnly bool `json:"is_logs_only"` + CondensationID string `json:"condensation_id"` + } + + if err := json.Unmarshal(output, &jsonPoints); err != nil { + env.T.Fatalf("failed to parse rewind points: %v\nOutput: %s", err, output) + } + + points := make([]RewindPoint, len(jsonPoints)) + for i, jp := range jsonPoints { + //nolint:errcheck // date parsing failure is acceptable, defaults to zero time + date, _ := time.Parse(time.RFC3339, jp.Date) + points[i] = RewindPoint{ + ID: jp.ID, + Message: jp.Message, + MetadataDir: jp.MetadataDir, + Date: date, + IsTaskCheckpoint: jp.IsTaskCheckpoint, + ToolUseID: jp.ToolUseID, + IsLogsOnly: jp.IsLogsOnly, + CondensationID: jp.CondensationID, + } + } + + return points +} + +// Rewind performs a rewind to the specified commit ID using the CLI. +func (env *TestEnv) Rewind(commitID string) error { + env.T.Helper() + + //nolint:gosec,noctx // test code, commitID is from test setup, no context needed + cmd := exec.Command(getTestBinary(), "rewind", "--to", commitID) + cmd.Dir = env.RepoDir + + output, err := cmd.CombinedOutput() + if err != nil { + return errors.New("rewind failed: " + string(output)) + } + + env.T.Logf("Rewind output: %s", output) + return nil +} + +// BranchExists checks if a branch exists in the repository. +func (env *TestEnv) BranchExists(branchName string) bool { + env.T.Helper() + + repo, err := git.PlainOpen(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to open git repo: %v", err) + } + + refs, err := repo.References() + if err != nil { + env.T.Fatalf("failed to get references: %v", err) + } + + found := false + //nolint:errcheck,gosec // ForEach callback doesn't return errors we need to handle + refs.ForEach(func(ref *plumbing.Reference) error { + if ref.Name().Short() == branchName { + found = true + } + return nil + }) + + return found +} + +// GetCommitMessage returns the commit message for the given commit hash. +func (env *TestEnv) GetCommitMessage(hash string) string { + env.T.Helper() + + repo, err := git.PlainOpen(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to open git repo: %v", err) + } + + commitHash := plumbing.NewHash(hash) + commit, err := repo.CommitObject(commitHash) + if err != nil { + env.T.Fatalf("failed to get commit %s: %v", hash, err) + } + + return commit.Message +} + +// GetLatestCheckpointIDFromHistory walks backwards from HEAD and returns +// the checkpoint ID from the first commit with an Entire-Checkpoint trailer. +// Returns an error if no checkpoint trailer is found in any commit. +func (env *TestEnv) GetLatestCheckpointIDFromHistory() (string, error) { + env.T.Helper() + + repo, err := git.PlainOpen(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to open git repo: %v", err) + } + + head, err := repo.Head() + if err != nil { + env.T.Fatalf("failed to get HEAD: %v", err) + } + + commitIter, err := repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + env.T.Fatalf("failed to iterate commits: %v", err) + } + + var checkpointID string + //nolint:errcheck,gosec // ForEach callback returns error to stop iteration, not a real error + commitIter.ForEach(func(c *object.Commit) error { + // Look for Entire-Checkpoint trailer + for _, line := range strings.Split(c.Message, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Entire-Checkpoint:") { + checkpointID = strings.TrimSpace(strings.TrimPrefix(line, "Entire-Checkpoint:")) + return errors.New("stop iteration") + } + } + return nil + }) + + if checkpointID == "" { + return "", errors.New("no commit with Entire-Checkpoint trailer found in history") + } + + return checkpointID, nil +} + +// safeIDPrefix returns first 12 chars of ID or the full ID if shorter. +// Use this when logging checkpoint IDs to avoid index out of bounds panic. +func safeIDPrefix(id string) string { + if len(id) >= 12 { + return id[:12] + } + return id +} + +// RunCLI runs the entire CLI with the given arguments and returns stdout. +func (env *TestEnv) RunCLI(args ...string) string { + env.T.Helper() + output, err := env.RunCLIWithError(args...) + if err != nil { + env.T.Fatalf("CLI command failed: %v\nArgs: %v\nOutput: %s", err, args, output) + } + return output +} + +// RunCLIWithError runs the entire CLI and returns output and error. +func (env *TestEnv) RunCLIWithError(args ...string) (string, error) { + env.T.Helper() + + //nolint:gosec,noctx // test code, args are from test setup, no context needed + cmd := exec.Command(getTestBinary(), args...) + cmd.Dir = env.RepoDir + + output, err := cmd.CombinedOutput() + return string(output), err +} + +// RunAgent runs the agent with the given prompt and returns the result. +func (env *TestEnv) RunAgent(prompt string) (*AgentResult, error) { + env.T.Helper() + //nolint:wrapcheck // test helper, caller handles error + return env.Agent.RunPrompt(context.Background(), env.RepoDir, prompt) +} + +// RunAgentWithTools runs the agent with specific tools enabled. +func (env *TestEnv) RunAgentWithTools(prompt string, tools []string) (*AgentResult, error) { + env.T.Helper() + //nolint:wrapcheck // test helper, caller handles error + return env.Agent.RunPromptWithTools(context.Background(), env.RepoDir, prompt, tools) +} diff --git a/cmd/entire/cli/testutil/testutil.go b/cmd/entire/cli/testutil/testutil.go new file mode 100644 index 000000000..bdffb8f13 --- /dev/null +++ b/cmd/entire/cli/testutil/testutil.go @@ -0,0 +1,283 @@ +// Package testutil provides shared test utilities for both integration and e2e tests. +// This package has no build tags, making it usable by all test packages. +package testutil + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/format/config" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// RewindPoint mirrors the rewind --list JSON output. +type RewindPoint struct { + ID string `json:"id"` + Message string `json:"message"` + MetadataDir string `json:"metadata_dir"` + Date time.Time `json:"date"` + IsTaskCheckpoint bool `json:"is_task_checkpoint"` + ToolUseID string `json:"tool_use_id"` + IsLogsOnly bool `json:"is_logs_only"` + CondensationID string `json:"condensation_id"` +} + +// InitRepo initializes a git repository in the given directory with test user config. +func InitRepo(t *testing.T, repoDir string) { + t.Helper() + + repo, err := git.PlainInit(repoDir, false) + if err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + // Configure git user for commits + cfg, err := repo.Config() + if err != nil { + t.Fatalf("failed to get repo config: %v", err) + } + cfg.User.Name = "Test User" + cfg.User.Email = "test@example.com" + + // Disable GPG signing for test commits + if cfg.Raw == nil { + cfg.Raw = config.New() + } + cfg.Raw.Section("commit").SetOption("gpgsign", "false") + + if err := repo.SetConfig(cfg); err != nil { + t.Fatalf("failed to set repo config: %v", err) + } +} + +// WriteFile creates a file with the given content in the repo directory. +// It creates parent directories as needed. +func WriteFile(t *testing.T, repoDir, path, content string) { + t.Helper() + + fullPath := filepath.Join(repoDir, path) + + // Create parent directories + dir := filepath.Dir(fullPath) + //nolint:gosec // test code, permissions are intentionally standard + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("failed to create directory %s: %v", dir, err) + } + + //nolint:gosec // test code, permissions are intentionally standard + if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write file %s: %v", path, err) + } +} + +// ReadFile reads a file from the repo directory. +func ReadFile(t *testing.T, repoDir, path string) string { + t.Helper() + + fullPath := filepath.Join(repoDir, path) + //nolint:gosec // test code, path is from test setup + data, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("failed to read file %s: %v", path, err) + } + return string(data) +} + +// TryReadFile reads a file from the repo directory, returning empty string if not found. +func TryReadFile(t *testing.T, repoDir, path string) string { + t.Helper() + + fullPath := filepath.Join(repoDir, path) + //nolint:gosec // test code, path is from test setup + data, err := os.ReadFile(fullPath) + if err != nil { + return "" + } + return string(data) +} + +// FileExists checks if a file exists in the repo directory. +func FileExists(repoDir, path string) bool { + fullPath := filepath.Join(repoDir, path) + _, err := os.Stat(fullPath) + return err == nil +} + +// GitAdd stages files for commit. +func GitAdd(t *testing.T, repoDir string, paths ...string) { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + for _, path := range paths { + if _, err := worktree.Add(path); err != nil { + t.Fatalf("failed to add file %s: %v", path, err) + } + } +} + +// GitCommit creates a commit with all staged files. +func GitCommit(t *testing.T, repoDir, message string) { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + _, err = worktree.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } +} + +// GitCheckoutNewBranch creates and checks out a new branch. +// Uses git CLI to work around go-git v5 bug with checkout deleting untracked files. +func GitCheckoutNewBranch(t *testing.T, repoDir, branchName string) { + t.Helper() + + //nolint:noctx // test code, no context needed for git checkout + cmd := exec.Command("git", "checkout", "-b", branchName) + cmd.Dir = repoDir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to checkout new branch %s: %v\nOutput: %s", branchName, err, output) + } +} + +// GetHeadHash returns the current HEAD commit hash. +func GetHeadHash(t *testing.T, repoDir string) string { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + head, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + + return head.Hash().String() +} + +// BranchExists checks if a branch exists in the repository. +func BranchExists(t *testing.T, repoDir, branchName string) bool { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + refs, err := repo.References() + if err != nil { + t.Fatalf("failed to get references: %v", err) + } + + found := false + //nolint:errcheck,gosec // ForEach callback doesn't return errors we need to handle + refs.ForEach(func(ref *plumbing.Reference) error { + if ref.Name().Short() == branchName { + found = true + } + return nil + }) + + return found +} + +// GetCommitMessage returns the commit message for the given commit hash. +func GetCommitMessage(t *testing.T, repoDir, hash string) string { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + commitHash := plumbing.NewHash(hash) + commit, err := repo.CommitObject(commitHash) + if err != nil { + t.Fatalf("failed to get commit %s: %v", hash, err) + } + + return commit.Message +} + +// GetLatestCheckpointIDFromHistory walks backwards from HEAD and returns +// the checkpoint ID from the first commit with an Entire-Checkpoint trailer. +// Returns an error if no checkpoint trailer is found in any commit. +func GetLatestCheckpointIDFromHistory(t *testing.T, repoDir string) (string, error) { + t.Helper() + + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatalf("failed to open git repo: %v", err) + } + + head, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + + commitIter, err := repo.Log(&git.LogOptions{From: head.Hash()}) + if err != nil { + t.Fatalf("failed to iterate commits: %v", err) + } + + var checkpointID string + //nolint:errcheck,gosec // ForEach callback returns error to stop iteration + commitIter.ForEach(func(c *object.Commit) error { + // Look for Entire-Checkpoint trailer + for line := range strings.SplitSeq(c.Message, "\n") { + line = strings.TrimSpace(line) + if value, found := strings.CutPrefix(line, "Entire-Checkpoint:"); found { + checkpointID = strings.TrimSpace(value) + return errors.New("stop iteration") + } + } + return nil + }) + + if checkpointID == "" { + return "", errors.New("no commit with Entire-Checkpoint trailer found in history") + } + + return checkpointID, nil +} + +// SafeIDPrefix returns first 12 chars of ID or the full ID if shorter. +// Use this when logging checkpoint IDs to avoid index out of bounds panic. +func SafeIDPrefix(id string) string { + if len(id) >= 12 { + return id[:12] + } + return id +} diff --git a/mise.toml b/mise.toml index 8a713e827..9d9a7e7d0 100644 --- a/mise.toml +++ b/mise.toml @@ -94,3 +94,16 @@ fi echo "Checking staged files for duplication..." git diff --cached --name-only -z --diff-filter=ACM | grep -z '\\.go$' | xargs -0 golangci-lint run --enable-only dupl --new=false --max-issues-per-linter=0 --max-same-issues=0 """ + +[tasks."test:e2e"] +description = "Run E2E tests with real agent calls (requires claude CLI)" +# -count=1 disables test caching since E2E tests call real external agents +run = "go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." + +[tasks."test:e2e:claude"] +description = "Run E2E tests with Claude Code (haiku model)" +run = "E2E_AGENT=claude-code go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." + +[tasks."test:e2e:gemini"] +description = "Run E2E tests with Gemini CLI (runner not yet implemented - will skip)" +run = "E2E_AGENT=gemini-cli go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..."