fix: wait on bash prompt ready marker before sending in keystrokes
This commit is contained in:
+95
-1
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mire/internal/output"
|
"mire/internal/output"
|
||||||
@@ -45,6 +46,11 @@ type testMismatchError struct {
|
|||||||
actual []byte
|
actual []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
replayPromptReadyMarker = "\x1b[?2004h$ "
|
||||||
|
replayPromptReadyTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
func (e *testMismatchError) Error() string {
|
func (e *testMismatchError) Error() string {
|
||||||
return "output differed"
|
return "output differed"
|
||||||
}
|
}
|
||||||
@@ -254,10 +260,24 @@ func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxCo
|
|||||||
compareMarkerEnvName: compareMarkerEnabledValue,
|
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{
|
if err := screen.Replay(screen.ReplayRequest{
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
Input: input,
|
Input: input,
|
||||||
OutputLog: rawOutFile,
|
InputReady: promptReady,
|
||||||
|
OutputLog: outputLog,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
rawOutFile.Close()
|
rawOutFile.Close()
|
||||||
return fmt.Errorf("replay failed: %v", err)
|
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 {
|
if err := rawOutFile.Close(); err != nil {
|
||||||
return fmt.Errorf("failed to close replay output: %v", err)
|
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)
|
got, err := loadRecordedOutput(rawOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -321,3 +344,74 @@ func trimReplayOutputToMarker(data []byte, shellPath string) ([]byte, error) {
|
|||||||
return data, nil
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestDiscoverTestScenariosUsesDisplayRootForRelativePaths(t *testing.T) {
|
||||||
testDir := filepath.Join(t.TempDir(), "e2e")
|
testDir := filepath.Join(t.TempDir(), "e2e")
|
||||||
scopedDir := filepath.Join(testDir, "nested")
|
scopedDir := filepath.Join(testDir, "nested")
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type RecordRequest struct {
|
|||||||
type ReplayRequest struct {
|
type ReplayRequest struct {
|
||||||
Cmd *exec.Cmd
|
Cmd *exec.Cmd
|
||||||
Input []byte
|
Input []byte
|
||||||
|
InputReady <-chan struct{}
|
||||||
OutputLog io.Writer
|
OutputLog io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ func Replay(req ReplayRequest) error {
|
|||||||
defer ptmx.Close()
|
defer ptmx.Close()
|
||||||
|
|
||||||
outputDone := copyAsync(combineWriters(req.OutputLog), ptmx)
|
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()
|
waitErr := req.Cmd.Wait()
|
||||||
ptmx.Close()
|
ptmx.Close()
|
||||||
@@ -128,6 +129,18 @@ func copyAsync(dst io.Writer, src io.Reader) <-chan error {
|
|||||||
return done
|
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 {
|
func replayInput(input []byte) []byte {
|
||||||
if len(input) > 0 && input[len(input)-1] == terminalEOF {
|
if len(input) > 0 && input[len(input)-1] == terminalEOF {
|
||||||
return input
|
return input
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecordCopiesInputAndOutput(t *testing.T) {
|
func TestRecordCopiesInputAndOutput(t *testing.T) {
|
||||||
@@ -64,3 +65,31 @@ func TestReplayCapturesOutput(t *testing.T) {
|
|||||||
t.Fatalf("output log = %q, want marker + line:hello", got)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user