From fe42c40d1ea5714349fdf5a57a5dd2de0a2c9068 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sat, 21 Mar 2026 20:54:24 +0000 Subject: [PATCH] fix: wait on bash prompt ready marker before sending in keystrokes --- internal/mire/test.go | 96 +++++++++++++++++++++++++++++++++- internal/mire/test_test.go | 21 ++++++++ internal/screen/screen.go | 15 +++++- internal/screen/screen_test.go | 29 ++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) diff --git a/internal/mire/test.go b/internal/mire/test.go index f42f300..229a85e 100644 --- a/internal/mire/test.go +++ b/internal/mire/test.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "sort" + "sync" "time" "mire/internal/output" @@ -45,6 +46,11 @@ type testMismatchError struct { actual []byte } +const ( + replayPromptReadyMarker = "\x1b[?2004h$ " + replayPromptReadyTimeout = 30 * time.Second +) + func (e *testMismatchError) Error() string { return "output differed" } @@ -254,10 +260,24 @@ 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() + } + if err := screen.Replay(screen.ReplayRequest{ Cmd: cmd, Input: input, - OutputLog: rawOutFile, + InputReady: promptReady, + OutputLog: outputLog, }); err != nil { rawOutFile.Close() return fmt.Errorf("replay failed: %v", err) @@ -265,6 +285,9 @@ 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) + } got, err := loadRecordedOutput(rawOut) if err != nil { @@ -321,3 +344,74 @@ func trimReplayOutputToMarker(data []byte, shellPath string) ([]byte, error) { return data, nil } } + +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 + tail []byte + ready chan struct{} + once sync.Once + seen bool +} + +func newReplayPromptWriter(dst io.Writer, marker string, ready chan struct{}) *replayPromptWriter { + return &replayPromptWriter{ + dst: dst, + marker: []byte(marker), + ready: ready, + } +} + +func (w *replayPromptWriter) Write(p []byte) (int, error) { + n, err := w.dst.Write(p) + if n > 0 { + w.observe(p[:n]) + } + return n, err +} + +func (w *replayPromptWriter) observe(p []byte) { + if w.seen || len(w.marker) == 0 { + return + } + + data := make([]byte, 0, len(w.tail)+len(p)) + data = append(data, w.tail...) + data = append(data, p...) + + if bytes.Contains(data, w.marker) { + w.seen = true + w.release() + } + + keep := len(w.marker) - 1 + if keep <= 0 { + return + } + if len(data) <= keep { + w.tail = append(w.tail[:0], data...) + return + } + + w.tail = append(w.tail[:0], data[len(data)-keep:]...) +} + +func (w *replayPromptWriter) release() { + w.once.Do(func() { + close(w.ready) + }) +} diff --git a/internal/mire/test_test.go b/internal/mire/test_test.go index cfb5197..cf4c190 100644 --- a/internal/mire/test_test.go +++ b/internal/mire/test_test.go @@ -151,6 +151,27 @@ func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) { } } +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) + } + }) + } +} + func TestDiscoverTestScenariosUsesDisplayRootForRelativePaths(t *testing.T) { testDir := filepath.Join(t.TempDir(), "e2e") scopedDir := filepath.Join(testDir, "nested") diff --git a/internal/screen/screen.go b/internal/screen/screen.go index d2c9d6e..bccdfcd 100644 --- a/internal/screen/screen.go +++ b/internal/screen/screen.go @@ -32,6 +32,7 @@ type RecordRequest struct { type ReplayRequest struct { Cmd *exec.Cmd Input []byte + InputReady <-chan struct{} OutputLog io.Writer } @@ -90,7 +91,7 @@ func Replay(req ReplayRequest) error { defer ptmx.Close() outputDone := copyAsync(combineWriters(req.OutputLog), ptmx) - inputDone := copyAsync(ptmx, bytes.NewReader(replayInput(req.Input))) + inputDone := copyAsyncWhenReady(ptmx, bytes.NewReader(replayInput(req.Input)), req.InputReady) waitErr := req.Cmd.Wait() ptmx.Close() @@ -128,6 +129,18 @@ 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 { + done := make(chan error, 1) + go func() { + if ready != nil { + <-ready + } + _, err := io.Copy(dst, src) + done <- normalizeCopyError(err) + }() + return done +} + func replayInput(input []byte) []byte { if len(input) > 0 && input[len(input)-1] == terminalEOF { return input diff --git a/internal/screen/screen_test.go b/internal/screen/screen_test.go index 81f2714..59b8d08 100644 --- a/internal/screen/screen_test.go +++ b/internal/screen/screen_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "testing" + "time" ) func TestRecordCopiesInputAndOutput(t *testing.T) { @@ -64,3 +65,31 @@ func TestReplayCapturesOutput(t *testing.T) { t.Fatalf("output log = %q, want marker + line:hello", got) } } + +func TestReplayWaitsForInputReadySignal(t *testing.T) { + cmd := exec.Command("sh", "-c", `IFS= read -r line; printf 'line:%s\n' "$line"`) + ready := make(chan struct{}) + + var outputLog bytes.Buffer + start := time.Now() + go func() { + time.Sleep(100 * time.Millisecond) + close(ready) + }() + + if err := Replay(ReplayRequest{ + Cmd: cmd, + Input: []byte("hello\n"), + InputReady: ready, + OutputLog: &outputLog, + }); err != nil { + t.Fatalf("Replay() error = %v", err) + } + + if elapsed := time.Since(start); elapsed < 100*time.Millisecond { + t.Fatalf("Replay() elapsed = %v, want >= %v", elapsed, 100*time.Millisecond) + } + if !bytes.Contains(outputLog.Bytes(), []byte("line:hello")) { + t.Fatalf("output log = %q, want line:hello", outputLog.String()) + } +}