diff --git a/internal/mire/record_shell.go b/internal/mire/record_shell.go index e322421..785ba4c 100644 --- a/internal/mire/record_shell.go +++ b/internal/mire/record_shell.go @@ -15,7 +15,7 @@ const recordShellName = "shell.sh" const ( compareMarkerEnvName = "MIRE_COMPARE_MARKER" compareMarkerEnabledValue = "1" - compareOutputMarker = "__MIRE_E2E_BEGIN__" + compareOutputMarker = "__MIRE_PROMPT_READY__" ) //go:embed record_shell.sh diff --git a/internal/mire/record_shell.sh b/internal/mire/record_shell.sh index dda19db..99b321c 100644 --- a/internal/mire/record_shell.sh +++ b/internal/mire/record_shell.sh @@ -19,11 +19,21 @@ for path in /tmp/mire-setup-scripts/*.sh; do source "$path" cd "${HOME:?}" done -EOF if [ "${MIRE_COMPARE_MARKER:-0}" = "1" ]; then - printf '__MIRE_E2E_BEGIN__\n' + __mire_prompt_ready_original=${PROMPT_COMMAND-} + __mire_prompt_ready() { + printf '__MIRE_PROMPT_READY__\n' + if [ -n "${__mire_prompt_ready_original:-}" ]; then + PROMPT_COMMAND=$__mire_prompt_ready_original + eval "$PROMPT_COMMAND" + else + unset PROMPT_COMMAND + fi + } + PROMPT_COMMAND=__mire_prompt_ready fi +EOF set -- \ --ro-bind / / \ diff --git a/internal/mire/record_test.go b/internal/mire/record_test.go index 4d81990..c68a49c 100644 --- a/internal/mire/record_test.go +++ b/internal/mire/record_test.go @@ -132,7 +132,8 @@ func TestBuildRecordShellScriptUsesExpectedCommands(t *testing.T) { `set -- "$@" --ro-bind "$host_path" "$visible_path"`, "${MIRE_SETUP_SCRIPTS-}", `if [ "${MIRE_COMPARE_MARKER:-0}" = "1" ]; then`, - "printf '__MIRE_E2E_BEGIN__\\n'", + "printf '__MIRE_PROMPT_READY__\\n'", + "PROMPT_COMMAND=__mire_prompt_ready", "--bind \"$host_home\" \"$visible_home\"", "--bind \"$host_tmp\" '/tmp'", "--setenv HOME \"$visible_home\"", @@ -147,6 +148,9 @@ func TestBuildRecordShellScriptUsesExpectedCommands(t *testing.T) { t.Fatalf("wrapper = %q, want substring %q", body, want) } } + if strings.Contains(body, "printf '__MIRE_E2E_BEGIN__\\n'") { + t.Fatalf("wrapper = %q, want old compare marker removed", body) + } } func TestRecordSessionEnvIncludesConfiguredSandboxEnv(t *testing.T) { diff --git a/internal/mire/test.go b/internal/mire/test.go index 229a85e..35ec72e 100644 --- a/internal/mire/test.go +++ b/internal/mire/test.go @@ -47,8 +47,8 @@ type testMismatchError struct { } const ( - replayPromptReadyMarker = "\x1b[?2004h$ " - replayPromptReadyTimeout = 30 * time.Second + replayPromptReadyMarker = compareOutputMarker + replayPromptReadyTimeout = 2 * time.Second ) func (e *testMismatchError) Error() string { @@ -260,24 +260,16 @@ func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxCo compareMarkerEnvName: compareMarkerEnabledValue, }) - outputLog := io.Writer(rawOutFile) - var promptWriter *replayPromptWriter - var promptReady <-chan struct{} - if replayNeedsInteractivePrompt(input) { - ready := make(chan struct{}) - promptWriter = newReplayPromptWriter(rawOutFile, replayPromptReadyMarker, ready) - outputLog = promptWriter - promptReady = ready - - promptTimeout := time.AfterFunc(replayPromptReadyTimeout, promptWriter.release) - defer promptTimeout.Stop() - } + ready := make(chan struct{}) + promptWriter := newReplayPromptWriter(rawOutFile, replayPromptReadyMarker, ready) + promptTimeout := time.AfterFunc(replayPromptReadyTimeout, promptWriter.release) + defer promptTimeout.Stop() if err := screen.Replay(screen.ReplayRequest{ - Cmd: cmd, - Input: input, - InputReady: promptReady, - OutputLog: outputLog, + Cmd: cmd, + Input: input, + InputReady: ready, + OutputLog: promptWriter, }); err != nil { rawOutFile.Close() return fmt.Errorf("replay failed: %v", err) @@ -285,8 +277,8 @@ func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxCo if err := rawOutFile.Close(); err != nil { return fmt.Errorf("failed to close replay output: %v", err) } - if promptWriter != nil && !promptWriter.seen { - return fmt.Errorf("replay shell never reached the initial interactive prompt; inspect %q or rerun `mire init`", shellPath) + if !promptWriter.seen { + return fmt.Errorf("replay shell never emitted %q; rerun `mire init` or refresh %q", compareOutputMarker, shellPath) } got, err := loadRecordedOutput(rawOut) @@ -328,7 +320,7 @@ func trimReplayOutputToMarker(data []byte, shellPath string) ([]byte, error) { idx := bytes.Index(data, []byte(compareOutputMarker)) if idx == -1 { return nil, fmt.Errorf( - "missing compare marker %q in replay output; rerun `mire init` or refresh %q", + "missing replay start marker %q in replay output; rerun `mire init` or refresh %q", compareOutputMarker, shellPath, ) @@ -345,20 +337,6 @@ func trimReplayOutputToMarker(data []byte, shellPath string) ([]byte, error) { } } -func replayNeedsInteractivePrompt(input []byte) bool { - for _, b := range input { - switch b { - case '\r', '\n', eofByte: - continue - } - if b < 0x20 || b == 0x7f || b == 0x1b { - return true - } - } - - return false -} - type replayPromptWriter struct { dst io.Writer marker []byte diff --git a/internal/mire/test_test.go b/internal/mire/test_test.go index cf4c190..1cbb3f2 100644 --- a/internal/mire/test_test.go +++ b/internal/mire/test_test.go @@ -123,6 +123,35 @@ func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) } } +func TestReplayScenarioWaitsForPromptReadyMarkerBeforeSendingInput(t *testing.T) { + testutil.RequireCommands(t, "bwrap", "bash") + + testDir := filepath.Join(t.TempDir(), "e2e") + shellPath := filepath.Join(testDir, "shell.sh") + mustWriteRecordShell(t, testDir) + scenarioDir := filepath.Join(testDir, "suite", "spec") + testutil.WriteScenarioFixtures( + t, + scenarioDir, + "echo ab\x7fc\nexit\n", + "\x1b[?2004h$ echo ab\b \bc\r\n\x1b[?2004l\rac\r\n\x1b[?2004h$ exit\r\n\x1b[?2004l\rexit\r\n", + ) + + err := replayScenario(testScenario{ + dir: scenarioDir, + relPath: filepath.Join("suite", "spec"), + inPath: filepath.Join(scenarioDir, "in"), + outPath: filepath.Join(scenarioDir, "out"), + setupScripts: nil, + }, shellPath, testIO{ + out: &bytes.Buffer{}, + err: &bytes.Buffer{}, + }, defaultSandboxConfig()) + if err != nil { + t.Fatalf("replayScenario() error = %v", err) + } +} + func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) { testDir := filepath.Join(t.TempDir(), "e2e") shellPath := filepath.Join(testDir, "shell.sh") @@ -146,29 +175,8 @@ func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) { if err == nil { t.Fatal("replayScenario() error = nil, want error") } - if !strings.Contains(err.Error(), "missing compare marker") || !strings.Contains(err.Error(), "rerun `mire init`") { - t.Fatalf("replayScenario() error = %q, want compare marker refresh hint", err.Error()) - } -} - -func TestReplayNeedsInteractivePrompt(t *testing.T) { - tests := []struct { - name string - input string - want bool - }{ - {name: "plain text", input: "echo hello\nexit\n", want: false}, - {name: "backspace", input: "echo ab\x7fc\n", want: true}, - {name: "escape sequence", input: "echo a\x1b[D\n", want: true}, - {name: "eof suffix only", input: "echo hello\r" + string([]byte{eofByte}), want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := replayNeedsInteractivePrompt([]byte(tt.input)); got != tt.want { - t.Fatalf("replayNeedsInteractivePrompt(%q) = %v, want %v", tt.input, got, tt.want) - } - }) + if !strings.Contains(err.Error(), "__MIRE_PROMPT_READY__") || !strings.Contains(err.Error(), "rerun `mire init`") { + t.Fatalf("replayScenario() error = %q, want prompt-ready marker refresh hint", err.Error()) } } diff --git a/internal/screen/screen.go b/internal/screen/screen.go index bccdfcd..083b14e 100644 --- a/internal/screen/screen.go +++ b/internal/screen/screen.go @@ -8,6 +8,7 @@ import ( "os/exec" "os/signal" "syscall" + "time" "github.com/creack/pty" "golang.org/x/sys/unix" @@ -15,9 +16,10 @@ import ( ) const ( - defaultRows = 24 - defaultCols = 80 - terminalEOF = byte(0x04) + defaultRows = 24 + defaultCols = 80 + terminalEOF = byte(0x04) + replayReadySettleDelay = 10 * time.Millisecond ) type RecordRequest struct { @@ -30,10 +32,10 @@ type RecordRequest struct { } type ReplayRequest struct { - Cmd *exec.Cmd - Input []byte + Cmd *exec.Cmd + Input []byte InputReady <-chan struct{} - OutputLog io.Writer + OutputLog io.Writer } func Record(req RecordRequest) error { @@ -91,9 +93,11 @@ func Replay(req ReplayRequest) error { defer ptmx.Close() outputDone := copyAsync(combineWriters(req.OutputLog), ptmx) - inputDone := copyAsyncWhenReady(ptmx, bytes.NewReader(replayInput(req.Input)), req.InputReady) + processDone := make(chan struct{}) + inputDone := copyAsyncWhenReady(ptmx, bytes.NewReader(replayInput(req.Input)), req.InputReady, processDone) waitErr := req.Cmd.Wait() + close(processDone) ptmx.Close() outputErr := <-outputDone @@ -129,11 +133,17 @@ func copyAsync(dst io.Writer, src io.Reader) <-chan error { return done } -func copyAsyncWhenReady(dst io.Writer, src io.Reader, ready <-chan struct{}) <-chan error { +func copyAsyncWhenReady(dst io.Writer, src io.Reader, ready <-chan struct{}, stop <-chan struct{}) <-chan error { done := make(chan error, 1) go func() { if ready != nil { - <-ready + select { + case <-ready: + time.Sleep(replayReadySettleDelay) + case <-stop: + done <- nil + return + } } _, err := io.Copy(dst, src) done <- normalizeCopyError(err) diff --git a/internal/screen/screen_test.go b/internal/screen/screen_test.go index 59b8d08..91a7d8f 100644 --- a/internal/screen/screen_test.go +++ b/internal/screen/screen_test.go @@ -78,10 +78,10 @@ func TestReplayWaitsForInputReadySignal(t *testing.T) { }() if err := Replay(ReplayRequest{ - Cmd: cmd, - Input: []byte("hello\n"), + Cmd: cmd, + Input: []byte("hello\n"), InputReady: ready, - OutputLog: &outputLog, + OutputLog: &outputLog, }); err != nil { t.Fatalf("Replay() error = %v", err) } diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 1c42001..007da68 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -388,8 +388,12 @@ while [ "$#" -gt 0 ]; do ;; esac done - -/bin/stty -echo 2>/dev/null || true +if [ "${MIRE_COMPARE_MARKER:-0}" = "1" ]; then + /bin/stty -echo 2>/dev/null || true + printf '%s\n' '__MIRE_PROMPT_READY__' +else + /bin/stty -echo 2>/dev/null || true +fi while IFS= read -r line || [ -n "$line" ]; do printf '%s\n' "$line" if [ "$line" = "exit" ]; then