From 07b5f59c2cfa36c338388eecc83dd93c8588bec3 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sat, 21 Mar 2026 19:22:56 +0000 Subject: [PATCH] feat: move to custom screen impl --- cmd/root.go | 2 +- cmd/root_test.go | 30 ++- go.mod | 3 + go.sum | 6 + internal/mire/post_process.go | 4 + internal/mire/record_session.go | 33 ++- internal/mire/record_test.go | 149 ++++--------- internal/mire/test.go | 21 +- internal/mire/test_test.go | 18 +- internal/mire/testhelpers_test.go | 68 ++++++ internal/screen/screen.go | 336 ++++++++++++++++++++++++++++++ internal/screen/screen_test.go | 66 ++++++ internal/testutil/testutil.go | 171 ++++++++++++++- 13 files changed, 762 insertions(+), 145 deletions(-) create mode 100644 internal/screen/screen.go create mode 100644 internal/screen/screen_test.go diff --git a/cmd/root.go b/cmd/root.go index d090f91..59fbe26 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,7 +40,7 @@ func newRootCommand() *cobra.Command { } func ensureDependencies() error { - for _, name := range []string{"script", "bwrap", "bash"} { + for _, name := range []string{"bwrap", "bash"} { if _, err := exec.LookPath(name); err != nil { return fmt.Errorf("required command %q not found in PATH", name) } diff --git a/cmd/root_test.go b/cmd/root_test.go index dbd5a9d..adf7d5b 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -10,7 +10,7 @@ import ( ) func TestRunShowsHelpWhenNoArgs(t *testing.T) { - testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") stdout, stderr := testutil.CaptureOutput(t, func() { if got := Run(nil); got != 0 { @@ -31,7 +31,7 @@ func TestRunShowsHelpWhenNoArgs(t *testing.T) { func TestRunInit(t *testing.T) { root := t.TempDir() - testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") testutil.WithWorkingDir(t, root, func() struct{} { stdout, stderr := testutil.CaptureOutput(t, func() { @@ -62,7 +62,7 @@ func TestRunInit(t *testing.T) { } func TestRunRecord(t *testing.T) { - testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") root := t.TempDir() wantDir := filepath.Join(root, "e2e") @@ -80,16 +80,15 @@ func TestRunRecord(t *testing.T) { t.Fatalf("stderr = %q, want empty", initStderr) } - testutil.WithStdin(t, "y\n", func() {}) - stdout, stderr := testutil.CaptureOutput(t, func() { + stdout, stderr := testutil.CapturePromptedOutput(t, "exit\n", "Save recording?", "y\n", func() { if got := Run([]string{"record", "suite/spec"}); got != 0 { t.Fatalf("Run() code = %d, want %d", got, 0) } }) createdPath := filepath.Join(wantDir, "suite", "spec") - if stdout != prefixed(createdPath+"\n") { - t.Fatalf("stdout = %q, want %q", stdout, prefixed(createdPath+"\n")) + if !strings.Contains(stdout, createdPath) { + t.Fatalf("stdout = %q, want created path", stdout) } if !strings.Contains(stderr, "Save recording?") { t.Fatalf("stderr = %q, want save prompt", stderr) @@ -118,7 +117,7 @@ func TestRunInitFailsWhenDependenciesMissing(t *testing.T) { if stdout != "" { t.Fatalf("stdout = %q, want empty", stdout) } - if !strings.Contains(stderr, `required command "script" not found in PATH`) { + if !strings.Contains(stderr, `required command "bwrap" not found in PATH`) { t.Fatalf("stderr = %q, want missing dependency error", stderr) } if _, err := os.Stat(filepath.Join(root, "mire.toml")); !os.IsNotExist(err) { @@ -132,7 +131,7 @@ func TestRunInitFailsWhenDependenciesMissing(t *testing.T) { } func TestRunRecordMissingPath(t *testing.T) { - testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") stdout, stderr := testutil.CaptureOutput(t, func() { if got := Run([]string{"record"}); got != 1 { @@ -152,12 +151,11 @@ func TestRunRecordMissingPath(t *testing.T) { } func TestRunTest(t *testing.T) { - testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") - t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") root := t.TempDir() - testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "a"), "echo one\n", "echo one\n") - testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "nested", "b"), "echo two\n", "echo two\n") + testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "a"), "echo one\n", "echo one\r\n") + testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "nested", "b"), "echo two\n", "echo two\r\n") testutil.WithWorkingDir(t, root, func() struct{} { initStdout, initStderr := testutil.CaptureOutput(t, func() { @@ -198,7 +196,7 @@ func TestRunTest(t *testing.T) { } func TestRunTestExtraArgs(t *testing.T) { - testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") stdout, stderr := testutil.CaptureOutput(t, func() { if got := Run([]string{"test", "a", "b"}); got != 1 { @@ -218,7 +216,7 @@ func TestRunTestExtraArgs(t *testing.T) { } func TestRunInitExtraArgs(t *testing.T) { - testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") stdout, stderr := testutil.CaptureOutput(t, func() { if got := Run([]string{"init", "extra"}); got != 1 { @@ -238,7 +236,7 @@ func TestRunInitExtraArgs(t *testing.T) { } func TestRunUnknownCommand(t *testing.T) { - testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") stdout, stderr := testutil.CaptureOutput(t, func() { if got := Run([]string{"wat"}); got != 1 { diff --git a/go.mod b/go.mod index 87608fa..9abed93 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,13 @@ go 1.25.5 require ( github.com/BurntSushi/toml v1.5.0 + github.com/creack/pty v1.1.24 github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.30.0 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index b076de0..45609c8 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -9,4 +11,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/mire/post_process.go b/internal/mire/post_process.go index 4d0c90e..684dc9d 100644 --- a/internal/mire/post_process.go +++ b/internal/mire/post_process.go @@ -35,6 +35,10 @@ func loadRecordedOutput(path string) ([]byte, error) { return nil, err } + if !hasScriptWrapper(data) { + return data, nil + } + return stripScriptWrapper(data) } diff --git a/internal/mire/record_session.go b/internal/mire/record_session.go index 166d166..c9cb5c7 100644 --- a/internal/mire/record_session.go +++ b/internal/mire/record_session.go @@ -9,6 +9,7 @@ import ( "strings" "mire/internal/output" + "mire/internal/screen" ) type recordIO struct { @@ -120,13 +121,35 @@ func newRecordSandboxForPathEnv(pathEnv string) (recordSandbox, func(), error) { } func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbox, rio recordIO, sandboxConfig map[string]string, setupScripts []string) error { - cmd := exec.Command("script", "-q", "-E", "always", "-I", rawIn, "-O", rawOut, "-c", shellPath) + rawInFile, err := os.Create(rawIn) + if err != nil { + return err + } + defer rawInFile.Close() + + rawOutFile, err := os.Create(rawOut) + if err != nil { + return err + } + defer rawOutFile.Close() + + cmd := exec.Command(shellPath) cmd.Dir = dir cmd.Env = recordSessionEnv(sandbox, sandboxConfig, setupScripts) - cmd.Stdin = rio.in - cmd.Stdout = rio.out - cmd.Stderr = rio.err - return cmd.Run() + + var tty *os.File + if file, ok := rio.in.(*os.File); ok { + tty = file + } + + return screen.Record(screen.RecordRequest{ + Cmd: cmd, + Input: rio.in, + Output: rio.out, + TTY: tty, + InputLog: rawInFile, + OutputLog: rawOutFile, + }) } func confirmRecordOverwrite(target string, rio recordIO) (bool, error) { diff --git a/internal/mire/record_test.go b/internal/mire/record_test.go index 516641c..4d81990 100644 --- a/internal/mire/record_test.go +++ b/internal/mire/record_test.go @@ -1,13 +1,11 @@ package mire import ( - "bytes" "errors" "os" "path/filepath" "strings" "testing" - "time" "mire/internal/testutil" ) @@ -18,15 +16,18 @@ func TestRecordCreatesRelativePath(t *testing.T) { testutil.MustMkdirAll(t, testDir) testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e")) mustWriteRecordShell(t, testDir) - testutil.AddFakeRecordDependencies(t, "script") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") - got := testutil.WithWorkingDir(t, root, func() string { - testutil.WithStdin(t, "y\n", func() {}) - path, err := Record(filepath.Join("a", "b", "c") + string(os.PathSeparator)) - if err != nil { - t.Fatalf("Record() error = %v", err) - } - return path + var got string + testutil.WithWorkingDir(t, root, func() struct{} { + testutil.CapturePromptedOutput(t, "exit\n", "Save recording?", "y\n", func() { + path, err := Record(filepath.Join("a", "b", "c") + string(os.PathSeparator)) + if err != nil { + t.Fatalf("Record() error = %v", err) + } + got = path + }) + return struct{}{} }) want := filepath.Join(testDir, "a", "b", "c") @@ -40,11 +41,11 @@ func TestRecordCreatesRelativePath(t *testing.T) { } } - if got := testutil.ReadFile(t, filepath.Join(want, "in")); got != "fake recorded input\n" { - t.Fatalf("saved in = %q, want %q", got, "fake recorded input\n") + if got := testutil.ReadFile(t, filepath.Join(want, "in")); got != "exit\n" { + t.Fatalf("saved in = %q, want %q", got, "exit\n") } - if got := testutil.ReadFile(t, filepath.Join(want, "out")); got != "fake recorded output\n" { - t.Fatalf("saved out = %q, want %q", got, "fake recorded output\n") + if got := testutil.ReadFile(t, filepath.Join(want, "out")); got != "exit\r\nexit\r\n" { + t.Fatalf("saved out = %q, want %q", got, "exit\\r\\nexit\\r\\n") } } @@ -53,12 +54,12 @@ func TestRecordReturnsDiscardedErrorWhenSaveDeclined(t *testing.T) { testDir := filepath.Join(root, "e2e") testutil.MustMkdirAll(t, testDir) mustWriteRecordShell(t, testDir) - testutil.AddFakeRecordDependencies(t, "script") + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") err := testutil.WithWorkingDir(t, root, func() error { target := filepath.Join(testDir, "a", "b", "c") testutil.MustMkdirAll(t, target) - return withRecordStreams(t, "n\n", func(rio recordIO) error { + return withRecordStreams(t, "exit\nn\n", func(rio recordIO) error { return recordScenario(target, recordShellPath(testDir), rio, defaultSandboxConfig(), nil) }) }) @@ -81,7 +82,6 @@ func TestRecordReturnsDiscardedErrorWhenOverwriteDeclined(t *testing.T) { target := filepath.Join(testDir, "a", "b", "c") testutil.MustMkdirAll(t, target) mustWriteRecordShell(t, testDir) - testutil.AddFakeRecordDependencies(t, "script") testutil.WriteFile(t, filepath.Join(target, "in"), "existing in\n") testutil.WriteFile(t, filepath.Join(target, "out"), "existing out\n") @@ -204,15 +204,12 @@ func TestRecordSessionEnvWithExtraIncludesAdditionalEntries(t *testing.T) { } } -func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) { - testDir := filepath.Join(t.TempDir(), "e2e") - mustWriteRecordShell(t, testDir) - testutil.AddFakeRecordDependencies(t, "script") - - argsPath := filepath.Join(t.TempDir(), "script.args") - commandBodyPath := filepath.Join(t.TempDir(), "script.command") - t.Setenv("FAKE_SCRIPT_ARGS_FILE", argsPath) - t.Setenv("FAKE_SCRIPT_COMMAND_BODY_FILE", commandBodyPath) +func TestRunRecordSessionCapturesInputAndOutput(t *testing.T) { + shellPath := filepath.Join(t.TempDir(), "shell.sh") + testutil.WriteFile(t, shellPath, "#!/bin/sh\nprintf 'ready\\n'\nread line\nprintf 'seen:%s\\n' \"$line\"\n") + if err := os.Chmod(shellPath, 0o755); err != nil { + t.Fatalf("Chmod(%q) error = %v", shellPath, err) + } sandbox, cleanup, err := newRecordSandboxForPathEnv(os.Getenv("PATH")) if err != nil { @@ -220,57 +217,29 @@ func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) { } defer cleanup() - shellPath := recordShellPath(testDir) - err = withRecordStreams(t, "", func(rio recordIO) error { - return runRecordSession(t.TempDir(), filepath.Join(t.TempDir(), "raw.in"), filepath.Join(t.TempDir(), "raw.out"), shellPath, sandbox, rio, defaultSandboxConfig(), []string{"/repo/e2e/setup.sh"}) + rawIn := filepath.Join(t.TempDir(), "raw.in") + rawOut := filepath.Join(t.TempDir(), "raw.out") + err = withRecordStreams(t, "hello\n", func(rio recordIO) error { + return runRecordSession(t.TempDir(), rawIn, rawOut, shellPath, sandbox, rio, defaultSandboxConfig(), nil) }) if err != nil { t.Fatalf("runRecordSession() error = %v", err) } - args := strings.Split(strings.TrimSpace(testutil.ReadFile(t, argsPath)), "\n") - if len(args) != 9 { - t.Fatalf("script args = %q, want 9 args", args) - } - if got := args[:4]; strings.Join(got, "\n") != strings.Join([]string{"-q", "-E", "always", "-I"}, "\n") { - t.Fatalf("script args prefix = %q, want %q", got, []string{"-q", "-E", "always", "-I"}) - } - if args[5] != "-O" { - t.Fatalf("script args[5] = %q, want %q", args[5], "-O") - } - if args[7] != "-c" { - t.Fatalf("script args[7] = %q, want %q", args[7], "-c") - } - if args[8] != shellPath { - t.Fatalf("script args[8] = %q, want %q", args[8], shellPath) + if got := testutil.ReadFile(t, rawIn); got != "hello\n" { + t.Fatalf("raw in = %q, want %q", got, "hello\n") } - body := testutil.ReadFile(t, commandBodyPath) - for _, want := range []string{ - "host_home=${MIRE_HOST_HOME:?}", - "visible_home=${MIRE_VISIBLE_HOME:?}", - "bootstrap_rc=\"$host_home/.mire-shell-rc\"", - "visible_bootstrap_rc=\"$visible_home/.mire-shell-rc\"", - "for path in /tmp/mire-setup-scripts/*.sh; do", - "source \"$path\"", - `if [ -n "${MIRE_SETUP_SCRIPTS:-}" ]; then`, - `set -- "$@" --ro-bind "$host_path" "$visible_path"`, - `if [ "${MIRE_COMPARE_MARKER:-0}" = "1" ]; then`, - "printf '__MIRE_E2E_BEGIN__\\n'", - "--ro-bind / /", - "--tmpfs /home", - "--setenv HOME \"$visible_home\"", - "--setenv TMPDIR '/tmp'", - "exec bwrap \"$@\" bash --noprofile --rcfile \"$visible_bootstrap_rc\" -i", - } { - if !strings.Contains(body, want) { - t.Fatalf("wrapper = %q, want substring %q", body, want) + rawOutput := testutil.ReadFile(t, rawOut) + for _, want := range []string{"ready", "seen:hello"} { + if !strings.Contains(rawOutput, want) { + t.Fatalf("raw out = %q, want substring %q", rawOutput, want) } } } func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { - testutil.RequireCommands(t, "script", "bwrap", "bash") + testutil.RequireCommands(t, "bwrap", "bash") root := t.TempDir() testDir := filepath.Join(root, "e2e") @@ -283,49 +252,20 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { } visibleHome := sandboxConfig["visible_home"] - reader, writer, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe() error = %v", err) - } - t.Cleanup(func() { - if err := reader.Close(); err != nil { - t.Fatalf("close pipe reader: %v", err) - } - }) - - writeDone := make(chan error, 1) - go func() { - defer close(writeDone) - defer writer.Close() - - if _, err := writer.Write([]byte("pwd\necho \"$HOME\"\necho \"$MIRE_KEY_WORD\"\nif [ -e \"$HOME/repo\" ]; then echo FOUND; else echo MISSING; fi\npwd\nexit\n")); err != nil { - writeDone <- err - return - } - - time.Sleep(300 * time.Millisecond) - - if _, err := writer.Write([]byte("y\n")); err != nil { - writeDone <- err - return - } - - writeDone <- nil - }() - - err = testutil.WithWorkingDir(t, root, func() error { - return recordScenario(target, recordShellPath(testDir), recordIO{ - in: reader, - out: ioDiscard{}, - err: &bytes.Buffer{}, - }, sandboxConfig, nil) + err := testutil.WithWorkingDir(t, root, func() error { + return withPromptedRecordStreams( + t, + "pwd\necho \"$HOME\"\necho \"$MIRE_KEY_WORD\"\nif [ -e \"$HOME/repo\" ]; then echo FOUND; else echo MISSING; fi\npwd\nexit\n", + "y\n", + func(rio recordIO) error { + rio.out = ioDiscard{} + return recordScenario(target, recordShellPath(testDir), rio, sandboxConfig, nil) + }, + ) }) if err != nil { t.Fatalf("recordScenario() error = %v", err) } - if err := <-writeDone; err != nil { - t.Fatalf("write session input: %v", err) - } recordedIn := testutil.ReadFile(t, filepath.Join(target, "in")) if strings.Contains(recordedIn, "Script started on ") { @@ -359,7 +299,6 @@ func TestRecordFailsWhenRecorderShellMissing(t *testing.T) { testDir := filepath.Join(root, "e2e") testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e")) testutil.MustMkdirAll(t, testDir) - testutil.AddFakeRecordDependencies(t, "script") target := filepath.Join(testDir, "suite", "spec") err := testutil.WithWorkingDir(t, root, func() error { diff --git a/internal/mire/test.go b/internal/mire/test.go index b55bfc0..f42f300 100644 --- a/internal/mire/test.go +++ b/internal/mire/test.go @@ -13,6 +13,7 @@ import ( "time" "mire/internal/output" + "mire/internal/screen" ) type testIO struct { @@ -242,18 +243,28 @@ func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxCo } defer cleanupSandbox() - cmd := exec.Command("script", "-q", "-E", "always", "-O", rawOut, "-c", shellPath) + rawOutFile, err := os.Create(rawOut) + if err != nil { + return fmt.Errorf("failed to prepare replay output: %v", err) + } + + cmd := exec.Command(shellPath) cmd.Dir = scenario.dir cmd.Env = recordSessionEnvWithExtra(sandbox, sandboxConfig, scenario.setupScripts, map[string]string{ compareMarkerEnvName: compareMarkerEnabledValue, }) - cmd.Stdin = bytes.NewReader(input) - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - if err := cmd.Run(); err != nil { + if err := screen.Replay(screen.ReplayRequest{ + Cmd: cmd, + Input: input, + OutputLog: rawOutFile, + }); err != nil { + rawOutFile.Close() return fmt.Errorf("replay failed: %v", err) } + if err := rawOutFile.Close(); err != nil { + return fmt.Errorf("failed to close replay output: %v", err) + } got, err := loadRecordedOutput(rawOut) if err != nil { diff --git a/internal/mire/test_test.go b/internal/mire/test_test.go index ed98d7b..cfb5197 100644 --- a/internal/mire/test_test.go +++ b/internal/mire/test_test.go @@ -2,6 +2,7 @@ package mire import ( "bytes" + "os" "path/filepath" "strings" "testing" @@ -90,17 +91,13 @@ func TestDiscoverTestScenariosRejectsMissingInFixture(t *testing.T) { } func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) { - testutil.AddFakeRecordDependencies(t, "script") - t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1") + testutil.AddFakeRecordDependencies(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 replay\nexit\n", "echo replay\nexit\n") - - capturedInput := filepath.Join(t.TempDir(), "stdin.capture") - t.Setenv("FAKE_SCRIPT_CAPTURE_STDIN_FILE", capturedInput) + testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\r\nexit\r\n") var stdout bytes.Buffer var stderr bytes.Buffer @@ -118,9 +115,6 @@ func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) t.Fatalf("replayScenario() error = %v", err) } - if got := testutil.ReadFile(t, capturedInput); got != "echo replay\nexit\n" { - t.Fatalf("captured stdin = %q, want recorded input", got) - } if stdout.String() != "" { t.Fatalf("stdout = %q, want empty", stdout.String()) } @@ -130,12 +124,12 @@ func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) } func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) { - testutil.AddFakeRecordDependencies(t, "script") - t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1") - testDir := filepath.Join(t.TempDir(), "e2e") shellPath := filepath.Join(testDir, "shell.sh") testutil.WriteFile(t, shellPath, "#!/bin/sh\n") + if err := os.Chmod(shellPath, 0o755); err != nil { + t.Fatalf("Chmod(%q) error = %v", shellPath, err) + } scenarioDir := filepath.Join(testDir, "suite", "spec") testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\nexit\n") diff --git a/internal/mire/testhelpers_test.go b/internal/mire/testhelpers_test.go index be4899a..427e0cf 100644 --- a/internal/mire/testhelpers_test.go +++ b/internal/mire/testhelpers_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "mire/internal/testutil" @@ -50,6 +51,56 @@ func withRecordStreams[T any](t *testing.T, input string, fn func(recordIO) T) T }) } +func withPromptedRecordStreams[T any](t *testing.T, sessionInput, promptInput string, fn func(recordIO) T) T { + t.Helper() + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error = %v", err) + } + t.Cleanup(func() { + _ = reader.Close() + _ = writer.Close() + }) + + errWriter := &promptSignalBuffer{ + marker: "Save recording?", + ready: make(chan struct{}), + } + + writeDone := make(chan error, 1) + go func() { + defer close(writeDone) + defer writer.Close() + + if _, err := writer.Write([]byte(sessionInput)); err != nil { + writeDone <- err + return + } + + <-errWriter.ready + + if _, err := writer.Write([]byte(promptInput)); err != nil { + writeDone <- err + return + } + + writeDone <- nil + }() + + result := fn(recordIO{ + in: reader, + out: ioDiscard{}, + err: errWriter, + }) + + if err := <-writeDone; err != nil { + t.Fatalf("write record input: %v", err) + } + + return result +} + type ioDiscard struct{} func (ioDiscard) Write(p []byte) (int, error) { @@ -90,3 +141,20 @@ func containsEnvKey(env []string, key string) bool { return false } + +type promptSignalBuffer struct { + bytes.Buffer + marker string + ready chan struct{} + once sync.Once +} + +func (b *promptSignalBuffer) Write(p []byte) (int, error) { + n, err := b.Buffer.Write(p) + if strings.Contains(b.Buffer.String(), b.marker) { + b.once.Do(func() { + close(b.ready) + }) + } + return n, err +} diff --git a/internal/screen/screen.go b/internal/screen/screen.go new file mode 100644 index 0000000..d2c9d6e --- /dev/null +++ b/internal/screen/screen.go @@ -0,0 +1,336 @@ +package screen + +import ( + "bytes" + "errors" + "io" + "os" + "os/exec" + "os/signal" + "syscall" + + "github.com/creack/pty" + "golang.org/x/sys/unix" + "golang.org/x/term" +) + +const ( + defaultRows = 24 + defaultCols = 80 + terminalEOF = byte(0x04) +) + +type RecordRequest struct { + Cmd *exec.Cmd + Input io.Reader + Output io.Writer + TTY *os.File + InputLog io.Writer + OutputLog io.Writer +} + +type ReplayRequest struct { + Cmd *exec.Cmd + Input []byte + OutputLog io.Writer +} + +func Record(req RecordRequest) error { + if req.Cmd == nil { + return errors.New("record session command is required") + } + + ptmx, err := pty.StartWithSize(req.Cmd, sessionSize(req.TTY)) + if err != nil { + return err + } + defer ptmx.Close() + + restoreTTY, err := makeRaw(req.TTY) + if err != nil { + return err + } + defer restoreTTY() + + stopResize := watchResize(req.TTY, ptmx) + defer stopResize() + + input, closeInput, err := duplicateInput(req.Input) + if err != nil { + return err + } + defer closeInput() + + outputDone := copyAsync(combineWriters(req.Output, req.OutputLog), ptmx) + inputDone, stopInput, err := copyInputAsync(combineWriters(ptmx, newInputLogWriter(req.InputLog)), input) + if err != nil { + return err + } + defer stopInput() + + waitErr := req.Cmd.Wait() + stopInput() + ptmx.Close() + + outputErr := <-outputDone + inputErr := <-inputDone + + return firstErr(waitErr, outputErr, inputErr) +} + +func Replay(req ReplayRequest) error { + if req.Cmd == nil { + return errors.New("replay session command is required") + } + + ptmx, err := pty.StartWithSize(req.Cmd, sessionSize(nil)) + if err != nil { + return err + } + defer ptmx.Close() + + outputDone := copyAsync(combineWriters(req.OutputLog), ptmx) + inputDone := copyAsync(ptmx, bytes.NewReader(replayInput(req.Input))) + + waitErr := req.Cmd.Wait() + ptmx.Close() + + outputErr := <-outputDone + inputErr := <-inputDone + + return firstErr(waitErr, outputErr, inputErr) +} + +func combineWriters(writers ...io.Writer) io.Writer { + active := make([]io.Writer, 0, len(writers)) + for _, writer := range writers { + if writer != nil { + active = append(active, writer) + } + } + + switch len(active) { + case 0: + return io.Discard + case 1: + return active[0] + default: + return io.MultiWriter(active...) + } +} + +func copyAsync(dst io.Writer, src io.Reader) <-chan error { + done := make(chan error, 1) + go func() { + _, 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 + } + + data := make([]byte, 0, len(input)+1) + data = append(data, input...) + data = append(data, terminalEOF) + return data +} + +func copyInputAsync(dst io.Writer, src io.Reader) (<-chan error, func(), error) { + file, ok := src.(*os.File) + if !ok { + done := copyAsync(dst, src) + return done, func() {}, nil + } + + stopReader, stopWriter, err := os.Pipe() + if err != nil { + return nil, nil, err + } + + done := make(chan error, 1) + go func() { + defer stopReader.Close() + + buf := make([]byte, 4096) + fds := []unix.PollFd{ + {Fd: int32(file.Fd()), Events: unix.POLLIN | unix.POLLHUP}, + {Fd: int32(stopReader.Fd()), Events: unix.POLLIN | unix.POLLHUP}, + } + + for { + _, err := unix.Poll(fds, -1) + if err != nil { + if err == unix.EINTR { + continue + } + done <- err + return + } + if fds[1].Revents != 0 { + done <- nil + return + } + if fds[0].Revents&(unix.POLLIN|unix.POLLHUP) == 0 { + continue + } + + n, readErr := file.Read(buf) + if n > 0 { + if _, writeErr := dst.Write(buf[:n]); writeErr != nil { + done <- normalizeCopyError(writeErr) + return + } + } + if readErr != nil { + done <- normalizeCopyError(readErr) + return + } + } + }() + + stop := func() { + _ = stopWriter.Close() + _ = file.Close() + } + + return done, stop, nil +} + +func normalizeCopyError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, io.EOF): + return nil + case errors.Is(err, os.ErrClosed): + return nil + case errors.Is(err, syscall.EIO): + return nil + default: + return err + } +} + +func firstErr(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} + +func sessionSize(tty *os.File) *pty.Winsize { + if tty != nil && term.IsTerminal(int(tty.Fd())) { + if cols, rows, err := term.GetSize(int(tty.Fd())); err == nil { + return &pty.Winsize{ + Rows: uint16(rows), + Cols: uint16(cols), + } + } + } + + return &pty.Winsize{ + Rows: defaultRows, + Cols: defaultCols, + } +} + +func makeRaw(tty *os.File) (func(), error) { + if tty == nil || !term.IsTerminal(int(tty.Fd())) { + return func() {}, nil + } + + state, err := term.MakeRaw(int(tty.Fd())) + if err != nil { + return nil, err + } + + return func() { + _ = term.Restore(int(tty.Fd()), state) + }, nil +} + +func watchResize(tty *os.File, ptmx *os.File) func() { + if tty == nil || !term.IsTerminal(int(tty.Fd())) { + return func() {} + } + + applySize := func() { + _ = pty.Setsize(ptmx, sessionSize(tty)) + } + applySize() + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGWINCH) + + done := make(chan struct{}) + go func() { + for { + select { + case <-signals: + applySize() + case <-done: + return + } + } + }() + + return func() { + signal.Stop(signals) + close(done) + } +} + +func duplicateInput(input io.Reader) (io.Reader, func(), error) { + if input == nil { + return bytes.NewReader(nil), func() {}, nil + } + + file, ok := input.(*os.File) + if !ok { + return input, func() {}, nil + } + + fd, err := syscall.Dup(int(file.Fd())) + if err != nil { + return nil, nil, err + } + + dup := os.NewFile(uintptr(fd), file.Name()) + return dup, func() { + _ = dup.Close() + }, nil +} + +type inputLogWriter struct { + dst io.Writer +} + +func newInputLogWriter(dst io.Writer) io.Writer { + if dst == nil { + return io.Discard + } + return inputLogWriter{dst: dst} +} + +func (w inputLogWriter) Write(p []byte) (int, error) { + normalized := make([]byte, len(p)) + for i, b := range p { + if b == '\r' { + normalized[i] = '\n' + continue + } + normalized[i] = b + } + + if _, err := w.dst.Write(normalized); err != nil { + return 0, err + } + + return len(p), nil +} diff --git a/internal/screen/screen_test.go b/internal/screen/screen_test.go new file mode 100644 index 0000000..81f2714 --- /dev/null +++ b/internal/screen/screen_test.go @@ -0,0 +1,66 @@ +package screen + +import ( + "bytes" + "os" + "os/exec" + "testing" +) + +func TestRecordCopiesInputAndOutput(t *testing.T) { + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error = %v", err) + } + t.Cleanup(func() { + _ = reader.Close() + _ = writer.Close() + }) + + go func() { + _, _ = writer.Write([]byte("hello\n")) + _ = writer.Close() + }() + + cmd := exec.Command("sh", "-c", `printf 'ready\n'; read line; printf 'line:%s\n' "$line"`) + + var live bytes.Buffer + var inputLog bytes.Buffer + var outputLog bytes.Buffer + if err := Record(RecordRequest{ + Cmd: cmd, + Input: reader, + Output: &live, + InputLog: &inputLog, + OutputLog: &outputLog, + }); err != nil { + t.Fatalf("Record() error = %v", err) + } + + if got := inputLog.String(); got != "hello\n" { + t.Fatalf("input log = %q, want %q", got, "hello\n") + } + for _, got := range []string{live.String(), outputLog.String()} { + if !bytes.Contains([]byte(got), []byte("ready")) || !bytes.Contains([]byte(got), []byte("line:hello")) { + t.Fatalf("output = %q, want ready + line:hello", got) + } + } +} + +func TestReplayCapturesOutput(t *testing.T) { + cmd := exec.Command("sh", "-c", `read line; printf '__MIRE_E2E_BEGIN__\nline:%s\n' "$line"`) + + var outputLog bytes.Buffer + if err := Replay(ReplayRequest{ + Cmd: cmd, + Input: []byte("hello\n"), + OutputLog: &outputLog, + }); err != nil { + t.Fatalf("Replay() error = %v", err) + } + + got := outputLog.String() + if !bytes.Contains([]byte(got), []byte("__MIRE_E2E_BEGIN__")) || !bytes.Contains([]byte(got), []byte("line:hello")) { + t.Fatalf("output log = %q, want marker + line:hello", got) + } +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index daa4e21..1c42001 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" "path/filepath" + "strings" + "sync" "testing" ) @@ -53,6 +55,113 @@ func CaptureOutput(t *testing.T, fn func()) (string, string) { return stdoutBuf.String(), stderrBuf.String() } +func CapturePromptedOutput(t *testing.T, sessionInput, promptMarker, promptInput string, fn func()) (string, string) { + t.Helper() + t.Setenv("NO_COLOR", "1") + + stdinReader, stdinWriter, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() stdin error = %v", err) + } + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() stdout error = %v", err) + } + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() stderr error = %v", err) + } + + oldStdin := os.Stdin + oldStdout := os.Stdout + oldStderr := os.Stderr + os.Stdin = stdinReader + os.Stdout = stdoutWriter + os.Stderr = stderrWriter + t.Cleanup(func() { + os.Stdin = oldStdin + os.Stdout = oldStdout + os.Stderr = oldStderr + }) + + var stdinOnce sync.Once + closeStdin := func() { + stdinOnce.Do(func() { + _ = stdinWriter.Close() + }) + } + t.Cleanup(closeStdin) + + var stdoutBuf bytes.Buffer + stdoutDone := make(chan error, 1) + go func() { + _, err := io.Copy(&stdoutBuf, stdoutReader) + stdoutDone <- err + }() + + var stderrBuf bytes.Buffer + var promptOnce sync.Once + stderrDone := make(chan error, 1) + go func() { + buf := make([]byte, 1024) + for { + n, readErr := stderrReader.Read(buf) + if n > 0 { + if _, err := stderrBuf.Write(buf[:n]); err != nil { + stderrDone <- err + return + } + if promptMarker != "" && strings.Contains(stderrBuf.String(), promptMarker) { + promptOnce.Do(func() { + if _, err := stdinWriter.Write([]byte(promptInput)); err != nil { + stderrDone <- err + return + } + closeStdin() + }) + } + } + if readErr == nil { + continue + } + if readErr == io.EOF { + stderrDone <- nil + return + } + stderrDone <- readErr + return + } + }() + + go func() { + if _, err := stdinWriter.Write([]byte(sessionInput)); err != nil { + return + } + if promptMarker == "" { + closeStdin() + } + }() + + fn() + + closeStdin() + if err := stdoutWriter.Close(); err != nil { + t.Fatalf("stdout close error = %v", err) + } + if err := stderrWriter.Close(); err != nil { + t.Fatalf("stderr close error = %v", err) + } + + if err := <-stdoutDone; err != nil { + t.Fatalf("stdout copy error = %v", err) + } + if err := <-stderrDone; err != nil { + t.Fatalf("stderr copy error = %v", err) + } + + return stdoutBuf.String(), stderrBuf.String() +} + func WriteFile(t *testing.T, path, content string) { t.Helper() @@ -233,6 +342,61 @@ if [ -n "${FAKE_SCRIPT_EXIT_CODE:-}" ]; then exit "$FAKE_SCRIPT_EXIT_CODE" fi exit 0 +` + } + if name == "bwrap" { + body = `#!/bin/sh +while [ "$#" -gt 0 ]; do + case "$1" in + --ro-bind|--bind|--setenv) + shift 3 + ;; + --tmpfs|--chdir) + shift 2 + ;; + --dev|--proc) + shift 2 + ;; + --unshare-pid|--die-with-parent) + shift + ;; + *) + break + ;; + esac +done + +if [ "$#" -eq 0 ]; then + exit 0 +fi + +exec "$@" +` + } + if name == "bash" { + body = `#!/bin/sh +while [ "$#" -gt 0 ]; do + case "$1" in + --noprofile|--norc|-i) + shift + ;; + --rcfile) + shift 2 + ;; + *) + shift + ;; + esac +done + +/bin/stty -echo 2>/dev/null || true +while IFS= read -r line || [ -n "$line" ]; do + printf '%s\n' "$line" + if [ "$line" = "exit" ]; then + break + fi +done +exit 0 ` } if err := os.WriteFile(path, []byte(body), 0o755); err != nil { @@ -240,7 +404,12 @@ exit 0 } } - t.Setenv("PATH", binDir) + oldPath := os.Getenv("PATH") + if oldPath == "" { + t.Setenv("PATH", binDir) + return + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+oldPath) } func RequireCommands(t *testing.T, names ...string) {