diff --git a/cmd/entire/cli/integration_test/interactive.go b/cmd/entire/cli/integration_test/interactive.go new file mode 100644 index 000000000..5b4b95d47 --- /dev/null +++ b/cmd/entire/cli/integration_test/interactive.go @@ -0,0 +1,110 @@ +//go:build integration + +package integration + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/creack/pty" +) + +// RunCommandInteractive executes a CLI command with a pty, allowing interactive +// prompt responses. The respond function receives the pty for reading output +// and writing input, and should return the output it read. +func (env *TestEnv) RunCommandInteractive(args []string, respond func(ptyFile *os.File) string) (string, error) { + env.T.Helper() + + cmd := exec.Command(getTestBinary(), args...) + cmd.Dir = env.RepoDir + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, + "TERM=xterm", + "ACCESSIBLE=1", // Required: makes huh read from stdin instead of /dev/tty + ) + + // Start command with a pty + ptmx, err := pty.Start(cmd) + if err != nil { + return "", fmt.Errorf("failed to start pty: %w", err) + } + defer ptmx.Close() + + // Let the respond function interact with the pty and collect output + var respondOutput string + respondDone := make(chan struct{}) + go func() { + defer close(respondDone) + respondOutput = respond(ptmx) + }() + + // Wait for respond function with timeout + select { + case <-respondDone: + // respond completed + case <-time.After(10 * time.Second): + env.T.Log("Warning: respond function timed out") + } + + // Collect any remaining output after respond is done + var remaining bytes.Buffer + remainingDone := make(chan struct{}) + go func() { + defer close(remainingDone) + _, _ = io.Copy(&remaining, ptmx) + }() + + // Wait for process to complete with timeout + cmdDone := make(chan error, 1) + go func() { + cmdDone <- cmd.Wait() + }() + + var cmdErr error + select { + case cmdErr = <-cmdDone: + // process completed + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + cmdErr = fmt.Errorf("process timed out") + } + + // Give remaining output goroutine time to finish after process exits + select { + case <-remainingDone: + case <-time.After(1 * time.Second): + } + + return respondOutput + remaining.String(), cmdErr +} + +// WaitForPromptAndRespond reads from the pty until it sees the expected prompt text, +// then writes the response. Returns the output read so far. +func WaitForPromptAndRespond(ptyFile *os.File, promptSubstring, response string, timeout time.Duration) (string, error) { + var output bytes.Buffer + buf := make([]byte, 1024) + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + // Set read deadline to avoid blocking forever + _ = ptyFile.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + n, err := ptyFile.Read(buf) + if n > 0 { + output.Write(buf[:n]) + if strings.Contains(output.String(), promptSubstring) { + // Found the prompt, send response + _, _ = ptyFile.WriteString(response) + return output.String(), nil + } + } + if err != nil && !os.IsTimeout(err) { + return output.String(), err + } + } + return output.String(), fmt.Errorf("timeout waiting for prompt containing %q", promptSubstring) +} diff --git a/cmd/entire/cli/integration_test/resume_test.go b/cmd/entire/cli/integration_test/resume_test.go index 410919659..e1dfc8a80 100644 --- a/cmd/entire/cli/integration_test/resume_test.go +++ b/cmd/entire/cli/integration_test/resume_test.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" "testing" "time" @@ -472,6 +473,8 @@ func TestResume_AfterMergingMain(t *testing.T) { } // RunResume executes the resume command and returns the combined output. +// The subprocess is detached from the controlling terminal (via Setsid) to prevent +// interactive prompts from hanging tests. This simulates non-interactive environments like CI. func (env *TestEnv) RunResume(branchName string) (string, error) { env.T.Helper() @@ -481,6 +484,8 @@ func (env *TestEnv) RunResume(branchName string) (string, error) { cmd.Env = append(os.Environ(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, ) + // Detach from controlling terminal so huh can't open /dev/tty for interactive prompts + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} output, err := cmd.CombinedOutput() return string(output), err @@ -501,6 +506,14 @@ func (env *TestEnv) RunResumeForce(branchName string) (string, error) { return string(output), err } +// RunResumeInteractive executes the resume command with a pty, allowing +// interactive prompt responses. The respond function receives the pty for +// reading output and writing input. See RunCommandInteractive for details. +func (env *TestEnv) RunResumeInteractive(branchName string, respond func(ptyFile *os.File) string) (string, error) { + env.T.Helper() + return env.RunCommandInteractive([]string{"resume", branchName}, respond) +} + // GitMerge merges a branch into the current branch. func (env *TestEnv) GitMerge(branchName string) { env.T.Helper() @@ -559,9 +572,10 @@ func (env *TestEnv) GitCheckoutBranch(branchName string) { } } -// TestResume_LocalLogNewerTimestamp tests that when local log has newer timestamps -// than the checkpoint, the command requires --force to proceed (without interactive prompt). -func TestResume_LocalLogNewerTimestamp(t *testing.T) { +// TestResume_LocalLogNewerTimestamp_RequiresForce tests that when local log has newer +// timestamps than the checkpoint, the command fails in non-interactive mode (no TTY) +// and does NOT overwrite the local log. This ensures safe behavior in CI environments. +func TestResume_LocalLogNewerTimestamp_RequiresForce(t *testing.T) { t.Parallel() env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) @@ -599,16 +613,66 @@ func TestResume_LocalLogNewerTimestamp(t *testing.T) { // Switch to main env.GitCheckoutBranch(masterBranch) - // Resume WITHOUT --force - should fail because it needs interactive confirmation - // (which isn't available in non-interactive test mode) + // Resume WITHOUT --force in non-interactive mode (no TTY due to Setsid) + // Should fail because it can't prompt for confirmation output, err := env.RunResume(featureBranch) - // The command might succeed (if huh falls back to default) or might fail - // Either way, with --force it should definitely succeed - t.Logf("Resume without --force output: %s, err: %v", output, err) + if err == nil { + t.Errorf("expected error when resuming without --force in non-interactive mode, got success.\nOutput: %s", output) + } - // Resume WITH --force should succeed and overwrite the local log + // Verify local log was NOT overwritten (safe behavior) + data, err := os.ReadFile(existingLog) + if err != nil { + t.Fatalf("failed to read log: %v", err) + } + if !strings.Contains(string(data), "newer local work") { + t.Errorf("local log should NOT have been overwritten without --force, but content changed to: %s", string(data)) + } +} + +// TestResume_LocalLogNewerTimestamp_ForceOverwrites tests that when local log has newer +// timestamps than the checkpoint, the --force flag bypasses the confirmation prompt +// and overwrites the local log. +func TestResume_LocalLogNewerTimestamp_ForceOverwrites(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + + // Create a session with a specific timestamp + session := env.NewSession() + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + content := "def hello; end" + env.WriteFile("hello.rb", content) + + session.CreateTranscript( + "Create hello method", + []FileChange{{Path: "hello.rb", Content: content}}, + ) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + featureBranch := env.GetCurrentBranch() + + // Create a local log with a NEWER timestamp than the checkpoint + if err := os.MkdirAll(env.ClaudeProjectDir, 0o755); err != nil { + t.Fatalf("failed to create Claude project dir: %v", err) + } + existingLog := filepath.Join(env.ClaudeProjectDir, session.ID+".jsonl") + // Use a timestamp far in the future to ensure it's newer + futureTimestamp := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339) + newerContent := fmt.Sprintf(`{"type":"human","timestamp":"%s","message":{"content":"newer local work"}}`, futureTimestamp) + if err := os.WriteFile(existingLog, []byte(newerContent), 0o644); err != nil { + t.Fatalf("failed to write existing log: %v", err) + } + + // Switch to main env.GitCheckoutBranch(masterBranch) - output, err = env.RunResumeForce(featureBranch) + + // Resume WITH --force should succeed and overwrite the local log + output, err := env.RunResumeForce(featureBranch) if err != nil { t.Fatalf("resume --force failed: %v\nOutput: %s", err, output) } @@ -626,6 +690,135 @@ func TestResume_LocalLogNewerTimestamp(t *testing.T) { } } +// TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite tests that when the user +// confirms the overwrite prompt interactively, the local log is overwritten. +func TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + + // Create a session with a specific timestamp + session := env.NewSession() + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + content := "def hello; end" + env.WriteFile("hello.rb", content) + + session.CreateTranscript( + "Create hello method", + []FileChange{{Path: "hello.rb", Content: content}}, + ) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + featureBranch := env.GetCurrentBranch() + + // Create a local log with a NEWER timestamp than the checkpoint + if err := os.MkdirAll(env.ClaudeProjectDir, 0o755); err != nil { + t.Fatalf("failed to create Claude project dir: %v", err) + } + existingLog := filepath.Join(env.ClaudeProjectDir, session.ID+".jsonl") + futureTimestamp := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339) + newerContent := fmt.Sprintf(`{"type":"human","timestamp":"%s","message":{"content":"newer local work"}}`, futureTimestamp) + if err := os.WriteFile(existingLog, []byte(newerContent), 0o644); err != nil { + t.Fatalf("failed to write existing log: %v", err) + } + + // Switch to main + env.GitCheckoutBranch(masterBranch) + + // Resume interactively and confirm the overwrite + output, err := env.RunResumeInteractive(featureBranch, func(ptyFile *os.File) string { + out, promptErr := WaitForPromptAndRespond(ptyFile, "[y/N]", "y\n", 10*time.Second) + if promptErr != nil { + t.Logf("Warning: %v", promptErr) + } + return out + }) + if err != nil { + t.Fatalf("resume with user confirmation failed: %v\nOutput: %s", err, output) + } + + // Verify local log was overwritten with checkpoint content + data, err := os.ReadFile(existingLog) + if err != nil { + t.Fatalf("failed to read log: %v", err) + } + if strings.Contains(string(data), "newer local work") { + t.Errorf("local log should have been overwritten after user confirmed, but still has newer content: %s", string(data)) + } + if !strings.Contains(string(data), "Create hello method") { + t.Errorf("restored log should contain checkpoint transcript, got: %s", string(data)) + } +} + +// TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite tests that when the user +// declines the overwrite prompt interactively, the local log is preserved. +func TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + + // Create a session with a specific timestamp + session := env.NewSession() + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + content := "def hello; end" + env.WriteFile("hello.rb", content) + + session.CreateTranscript( + "Create hello method", + []FileChange{{Path: "hello.rb", Content: content}}, + ) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + featureBranch := env.GetCurrentBranch() + + // Create a local log with a NEWER timestamp than the checkpoint + if err := os.MkdirAll(env.ClaudeProjectDir, 0o755); err != nil { + t.Fatalf("failed to create Claude project dir: %v", err) + } + existingLog := filepath.Join(env.ClaudeProjectDir, session.ID+".jsonl") + futureTimestamp := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339) + newerContent := fmt.Sprintf(`{"type":"human","timestamp":"%s","message":{"content":"newer local work"}}`, futureTimestamp) + if err := os.WriteFile(existingLog, []byte(newerContent), 0o644); err != nil { + t.Fatalf("failed to write existing log: %v", err) + } + + // Switch to main + env.GitCheckoutBranch(masterBranch) + + // Resume interactively and decline the overwrite + output, err := env.RunResumeInteractive(featureBranch, func(ptyFile *os.File) string { + out, promptErr := WaitForPromptAndRespond(ptyFile, "[y/N]", "n\n", 10*time.Second) + if promptErr != nil { + t.Logf("Warning: %v", promptErr) + } + return out + }) + // Command should succeed (graceful exit) but not overwrite + t.Logf("Resume with user decline output: %s, err: %v", output, err) + + // Verify local log was NOT overwritten + data, err := os.ReadFile(existingLog) + if err != nil { + t.Fatalf("failed to read log: %v", err) + } + if !strings.Contains(string(data), "newer local work") { + t.Errorf("local log should NOT have been overwritten after user declined, but content changed to: %s", string(data)) + } + + // Output should indicate the resume was cancelled + if !strings.Contains(output, "cancelled") && !strings.Contains(output, "preserved") { + t.Logf("Note: Expected 'cancelled' or 'preserved' in output, got: %s", output) + } +} + // TestResume_CheckpointNewerTimestamp tests that when checkpoint has newer timestamps // than local log, resume proceeds without requiring --force. func TestResume_CheckpointNewerTimestamp(t *testing.T) { diff --git a/go.mod b/go.mod index 0bd3f3342..3eec64b1d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/charmbracelet/huh v0.8.0 + github.com/creack/pty v1.1.24 github.com/denisbrodbeck/machineid v1.0.1 github.com/go-git/go-git/v5 v5.16.4 github.com/posthog/posthog-go v1.9.0