feat: move to custom screen impl

This commit is contained in:
2026-03-21 19:22:56 +00:00
parent dfe47846af
commit 07b5f59c2c
13 changed files with 762 additions and 145 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ func newRootCommand() *cobra.Command {
} }
func ensureDependencies() error { func ensureDependencies() error {
for _, name := range []string{"script", "bwrap", "bash"} { for _, name := range []string{"bwrap", "bash"} {
if _, err := exec.LookPath(name); err != nil { if _, err := exec.LookPath(name); err != nil {
return fmt.Errorf("required command %q not found in PATH", name) return fmt.Errorf("required command %q not found in PATH", name)
} }
+14 -16
View File
@@ -10,7 +10,7 @@ import (
) )
func TestRunShowsHelpWhenNoArgs(t *testing.T) { func TestRunShowsHelpWhenNoArgs(t *testing.T) {
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
stdout, stderr := testutil.CaptureOutput(t, func() { stdout, stderr := testutil.CaptureOutput(t, func() {
if got := Run(nil); got != 0 { if got := Run(nil); got != 0 {
@@ -31,7 +31,7 @@ func TestRunShowsHelpWhenNoArgs(t *testing.T) {
func TestRunInit(t *testing.T) { func TestRunInit(t *testing.T) {
root := t.TempDir() root := t.TempDir()
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
testutil.WithWorkingDir(t, root, func() struct{} { testutil.WithWorkingDir(t, root, func() struct{} {
stdout, stderr := testutil.CaptureOutput(t, func() { stdout, stderr := testutil.CaptureOutput(t, func() {
@@ -62,7 +62,7 @@ func TestRunInit(t *testing.T) {
} }
func TestRunRecord(t *testing.T) { func TestRunRecord(t *testing.T) {
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
root := t.TempDir() root := t.TempDir()
wantDir := filepath.Join(root, "e2e") wantDir := filepath.Join(root, "e2e")
@@ -80,16 +80,15 @@ func TestRunRecord(t *testing.T) {
t.Fatalf("stderr = %q, want empty", initStderr) t.Fatalf("stderr = %q, want empty", initStderr)
} }
testutil.WithStdin(t, "y\n", func() {}) stdout, stderr := testutil.CapturePromptedOutput(t, "exit\n", "Save recording?", "y\n", func() {
stdout, stderr := testutil.CaptureOutput(t, func() {
if got := Run([]string{"record", "suite/spec"}); got != 0 { if got := Run([]string{"record", "suite/spec"}); got != 0 {
t.Fatalf("Run() code = %d, want %d", got, 0) t.Fatalf("Run() code = %d, want %d", got, 0)
} }
}) })
createdPath := filepath.Join(wantDir, "suite", "spec") createdPath := filepath.Join(wantDir, "suite", "spec")
if stdout != prefixed(createdPath+"\n") { if !strings.Contains(stdout, createdPath) {
t.Fatalf("stdout = %q, want %q", stdout, prefixed(createdPath+"\n")) t.Fatalf("stdout = %q, want created path", stdout)
} }
if !strings.Contains(stderr, "Save recording?") { if !strings.Contains(stderr, "Save recording?") {
t.Fatalf("stderr = %q, want save prompt", stderr) t.Fatalf("stderr = %q, want save prompt", stderr)
@@ -118,7 +117,7 @@ func TestRunInitFailsWhenDependenciesMissing(t *testing.T) {
if stdout != "" { if stdout != "" {
t.Fatalf("stdout = %q, want empty", 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) t.Fatalf("stderr = %q, want missing dependency error", stderr)
} }
if _, err := os.Stat(filepath.Join(root, "mire.toml")); !os.IsNotExist(err) { 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) { func TestRunRecordMissingPath(t *testing.T) {
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
stdout, stderr := testutil.CaptureOutput(t, func() { stdout, stderr := testutil.CaptureOutput(t, func() {
if got := Run([]string{"record"}); got != 1 { if got := Run([]string{"record"}); got != 1 {
@@ -152,12 +151,11 @@ func TestRunRecordMissingPath(t *testing.T) {
} }
func TestRunTest(t *testing.T) { func TestRunTest(t *testing.T) {
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash") testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1")
root := t.TempDir() root := t.TempDir()
testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "a"), "echo one\n", "echo one\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\n") testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "nested", "b"), "echo two\n", "echo two\r\n")
testutil.WithWorkingDir(t, root, func() struct{} { testutil.WithWorkingDir(t, root, func() struct{} {
initStdout, initStderr := testutil.CaptureOutput(t, func() { initStdout, initStderr := testutil.CaptureOutput(t, func() {
@@ -198,7 +196,7 @@ func TestRunTest(t *testing.T) {
} }
func TestRunTestExtraArgs(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() { stdout, stderr := testutil.CaptureOutput(t, func() {
if got := Run([]string{"test", "a", "b"}); got != 1 { if got := Run([]string{"test", "a", "b"}); got != 1 {
@@ -218,7 +216,7 @@ func TestRunTestExtraArgs(t *testing.T) {
} }
func TestRunInitExtraArgs(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() { stdout, stderr := testutil.CaptureOutput(t, func() {
if got := Run([]string{"init", "extra"}); got != 1 { if got := Run([]string{"init", "extra"}); got != 1 {
@@ -238,7 +236,7 @@ func TestRunInitExtraArgs(t *testing.T) {
} }
func TestRunUnknownCommand(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() { stdout, stderr := testutil.CaptureOutput(t, func() {
if got := Run([]string{"wat"}); got != 1 { if got := Run([]string{"wat"}); got != 1 {
+3
View File
@@ -4,10 +4,13 @@ go 1.25.5
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
github.com/creack/pty v1.1.24
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/term v0.30.0
) )
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.31.0 // indirect
) )
+6
View File
@@ -1,6 +1,8 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 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/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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+4
View File
@@ -35,6 +35,10 @@ func loadRecordedOutput(path string) ([]byte, error) {
return nil, err return nil, err
} }
if !hasScriptWrapper(data) {
return data, nil
}
return stripScriptWrapper(data) return stripScriptWrapper(data)
} }
+28 -5
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"mire/internal/output" "mire/internal/output"
"mire/internal/screen"
) )
type recordIO struct { 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 { 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.Dir = dir
cmd.Env = recordSessionEnv(sandbox, sandboxConfig, setupScripts) cmd.Env = recordSessionEnv(sandbox, sandboxConfig, setupScripts)
cmd.Stdin = rio.in
cmd.Stdout = rio.out var tty *os.File
cmd.Stderr = rio.err if file, ok := rio.in.(*os.File); ok {
return cmd.Run() 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) { func confirmRecordOverwrite(target string, rio recordIO) (bool, error) {
+40 -101
View File
@@ -1,13 +1,11 @@
package mire package mire
import ( import (
"bytes"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"mire/internal/testutil" "mire/internal/testutil"
) )
@@ -18,15 +16,18 @@ func TestRecordCreatesRelativePath(t *testing.T) {
testutil.MustMkdirAll(t, testDir) testutil.MustMkdirAll(t, testDir)
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e")) testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
mustWriteRecordShell(t, testDir) mustWriteRecordShell(t, testDir)
testutil.AddFakeRecordDependencies(t, "script") testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
got := testutil.WithWorkingDir(t, root, func() string { var got string
testutil.WithStdin(t, "y\n", func() {}) 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)) path, err := Record(filepath.Join("a", "b", "c") + string(os.PathSeparator))
if err != nil { if err != nil {
t.Fatalf("Record() error = %v", err) t.Fatalf("Record() error = %v", err)
} }
return path got = path
})
return struct{}{}
}) })
want := filepath.Join(testDir, "a", "b", "c") 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" { if got := testutil.ReadFile(t, filepath.Join(want, "in")); got != "exit\n" {
t.Fatalf("saved in = %q, want %q", got, "fake recorded input\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" { if got := testutil.ReadFile(t, filepath.Join(want, "out")); got != "exit\r\nexit\r\n" {
t.Fatalf("saved out = %q, want %q", got, "fake recorded output\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") testDir := filepath.Join(root, "e2e")
testutil.MustMkdirAll(t, testDir) testutil.MustMkdirAll(t, testDir)
mustWriteRecordShell(t, testDir) mustWriteRecordShell(t, testDir)
testutil.AddFakeRecordDependencies(t, "script") testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
err := testutil.WithWorkingDir(t, root, func() error { err := testutil.WithWorkingDir(t, root, func() error {
target := filepath.Join(testDir, "a", "b", "c") target := filepath.Join(testDir, "a", "b", "c")
testutil.MustMkdirAll(t, target) 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) return recordScenario(target, recordShellPath(testDir), rio, defaultSandboxConfig(), nil)
}) })
}) })
@@ -81,7 +82,6 @@ func TestRecordReturnsDiscardedErrorWhenOverwriteDeclined(t *testing.T) {
target := filepath.Join(testDir, "a", "b", "c") target := filepath.Join(testDir, "a", "b", "c")
testutil.MustMkdirAll(t, target) testutil.MustMkdirAll(t, target)
mustWriteRecordShell(t, testDir) mustWriteRecordShell(t, testDir)
testutil.AddFakeRecordDependencies(t, "script")
testutil.WriteFile(t, filepath.Join(target, "in"), "existing in\n") testutil.WriteFile(t, filepath.Join(target, "in"), "existing in\n")
testutil.WriteFile(t, filepath.Join(target, "out"), "existing out\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) { func TestRunRecordSessionCapturesInputAndOutput(t *testing.T) {
testDir := filepath.Join(t.TempDir(), "e2e") shellPath := filepath.Join(t.TempDir(), "shell.sh")
mustWriteRecordShell(t, testDir) testutil.WriteFile(t, shellPath, "#!/bin/sh\nprintf 'ready\\n'\nread line\nprintf 'seen:%s\\n' \"$line\"\n")
testutil.AddFakeRecordDependencies(t, "script") if err := os.Chmod(shellPath, 0o755); err != nil {
t.Fatalf("Chmod(%q) error = %v", shellPath, err)
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)
sandbox, cleanup, err := newRecordSandboxForPathEnv(os.Getenv("PATH")) sandbox, cleanup, err := newRecordSandboxForPathEnv(os.Getenv("PATH"))
if err != nil { if err != nil {
@@ -220,57 +217,29 @@ func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) {
} }
defer cleanup() defer cleanup()
shellPath := recordShellPath(testDir) rawIn := filepath.Join(t.TempDir(), "raw.in")
err = withRecordStreams(t, "", func(rio recordIO) error { rawOut := filepath.Join(t.TempDir(), "raw.out")
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"}) err = withRecordStreams(t, "hello\n", func(rio recordIO) error {
return runRecordSession(t.TempDir(), rawIn, rawOut, shellPath, sandbox, rio, defaultSandboxConfig(), nil)
}) })
if err != nil { if err != nil {
t.Fatalf("runRecordSession() error = %v", err) t.Fatalf("runRecordSession() error = %v", err)
} }
args := strings.Split(strings.TrimSpace(testutil.ReadFile(t, argsPath)), "\n") if got := testutil.ReadFile(t, rawIn); got != "hello\n" {
if len(args) != 9 { t.Fatalf("raw in = %q, want %q", got, "hello\n")
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)
} }
body := testutil.ReadFile(t, commandBodyPath) rawOutput := testutil.ReadFile(t, rawOut)
for _, want := range []string{ for _, want := range []string{"ready", "seen:hello"} {
"host_home=${MIRE_HOST_HOME:?}", if !strings.Contains(rawOutput, want) {
"visible_home=${MIRE_VISIBLE_HOME:?}", t.Fatalf("raw out = %q, want substring %q", rawOutput, want)
"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)
} }
} }
} }
func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) {
testutil.RequireCommands(t, "script", "bwrap", "bash") testutil.RequireCommands(t, "bwrap", "bash")
root := t.TempDir() root := t.TempDir()
testDir := filepath.Join(root, "e2e") testDir := filepath.Join(root, "e2e")
@@ -283,49 +252,20 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) {
} }
visibleHome := sandboxConfig["visible_home"] visibleHome := sandboxConfig["visible_home"]
reader, writer, err := os.Pipe() err := testutil.WithWorkingDir(t, root, func() error {
if err != nil { return withPromptedRecordStreams(
t.Fatalf("os.Pipe() error = %v", err) t,
} "pwd\necho \"$HOME\"\necho \"$MIRE_KEY_WORD\"\nif [ -e \"$HOME/repo\" ]; then echo FOUND; else echo MISSING; fi\npwd\nexit\n",
t.Cleanup(func() { "y\n",
if err := reader.Close(); err != nil { func(rio recordIO) error {
t.Fatalf("close pipe reader: %v", err) rio.out = ioDiscard{}
} return recordScenario(target, recordShellPath(testDir), rio, sandboxConfig, nil)
}) },
)
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)
}) })
if err != nil { if err != nil {
t.Fatalf("recordScenario() error = %v", err) 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")) recordedIn := testutil.ReadFile(t, filepath.Join(target, "in"))
if strings.Contains(recordedIn, "Script started on ") { if strings.Contains(recordedIn, "Script started on ") {
@@ -359,7 +299,6 @@ func TestRecordFailsWhenRecorderShellMissing(t *testing.T) {
testDir := filepath.Join(root, "e2e") testDir := filepath.Join(root, "e2e")
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e")) testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
testutil.MustMkdirAll(t, testDir) testutil.MustMkdirAll(t, testDir)
testutil.AddFakeRecordDependencies(t, "script")
target := filepath.Join(testDir, "suite", "spec") target := filepath.Join(testDir, "suite", "spec")
err := testutil.WithWorkingDir(t, root, func() error { err := testutil.WithWorkingDir(t, root, func() error {
+16 -5
View File
@@ -13,6 +13,7 @@ import (
"time" "time"
"mire/internal/output" "mire/internal/output"
"mire/internal/screen"
) )
type testIO struct { type testIO struct {
@@ -242,18 +243,28 @@ func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxCo
} }
defer cleanupSandbox() 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.Dir = scenario.dir
cmd.Env = recordSessionEnvWithExtra(sandbox, sandboxConfig, scenario.setupScripts, map[string]string{ cmd.Env = recordSessionEnvWithExtra(sandbox, sandboxConfig, scenario.setupScripts, map[string]string{
compareMarkerEnvName: compareMarkerEnabledValue, 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) 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) got, err := loadRecordedOutput(rawOut)
if err != nil { if err != nil {
+6 -12
View File
@@ -2,6 +2,7 @@ package mire
import ( import (
"bytes" "bytes"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -90,17 +91,13 @@ func TestDiscoverTestScenariosRejectsMissingInFixture(t *testing.T) {
} }
func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) { func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) {
testutil.AddFakeRecordDependencies(t, "script") testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1")
testDir := filepath.Join(t.TempDir(), "e2e") testDir := filepath.Join(t.TempDir(), "e2e")
shellPath := filepath.Join(testDir, "shell.sh") shellPath := filepath.Join(testDir, "shell.sh")
mustWriteRecordShell(t, testDir) mustWriteRecordShell(t, testDir)
scenarioDir := filepath.Join(testDir, "suite", "spec") scenarioDir := filepath.Join(testDir, "suite", "spec")
testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\nexit\n") testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\r\nexit\r\n")
capturedInput := filepath.Join(t.TempDir(), "stdin.capture")
t.Setenv("FAKE_SCRIPT_CAPTURE_STDIN_FILE", capturedInput)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -118,9 +115,6 @@ func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T)
t.Fatalf("replayScenario() error = %v", err) 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() != "" { if stdout.String() != "" {
t.Fatalf("stdout = %q, want empty", stdout.String()) t.Fatalf("stdout = %q, want empty", stdout.String())
} }
@@ -130,12 +124,12 @@ func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T)
} }
func TestReplayScenarioFailsWhenCompareMarkerMissing(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") testDir := filepath.Join(t.TempDir(), "e2e")
shellPath := filepath.Join(testDir, "shell.sh") shellPath := filepath.Join(testDir, "shell.sh")
testutil.WriteFile(t, shellPath, "#!/bin/sh\n") 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") scenarioDir := filepath.Join(testDir, "suite", "spec")
testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\nexit\n") testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\nexit\n")
+68
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"mire/internal/testutil" "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{} type ioDiscard struct{}
func (ioDiscard) Write(p []byte) (int, error) { func (ioDiscard) Write(p []byte) (int, error) {
@@ -90,3 +141,20 @@ func containsEnvKey(env []string, key string) bool {
return false 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
}
+336
View File
@@ -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
}
+66
View File
@@ -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)
}
}
+169
View File
@@ -6,6 +6,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"sync"
"testing" "testing"
) )
@@ -53,6 +55,113 @@ func CaptureOutput(t *testing.T, fn func()) (string, string) {
return stdoutBuf.String(), stderrBuf.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) { func WriteFile(t *testing.T, path, content string) {
t.Helper() t.Helper()
@@ -233,6 +342,61 @@ if [ -n "${FAKE_SCRIPT_EXIT_CODE:-}" ]; then
exit "$FAKE_SCRIPT_EXIT_CODE" exit "$FAKE_SCRIPT_EXIT_CODE"
fi fi
exit 0 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 { if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
@@ -240,7 +404,12 @@ exit 0
} }
} }
oldPath := os.Getenv("PATH")
if oldPath == "" {
t.Setenv("PATH", binDir) t.Setenv("PATH", binDir)
return
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+oldPath)
} }
func RequireCommands(t *testing.T, names ...string) { func RequireCommands(t *testing.T, names ...string) {