Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions cmd/entire/cli/integration_test/interactive.go
Original file line number Diff line number Diff line change
@@ -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)
}
213 changes: 203 additions & 10 deletions cmd/entire/cli/integration_test/resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down