diff --git a/cmd/root.go b/cmd/root.go index 85a7e6e..78d0d70 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"} { + for _, name := range []string{"script", "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 ae59493..f421dd2 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -12,7 +12,7 @@ import ( ) func TestRunShowsHelpWhenNoArgs(t *testing.T) { - addFakeRecordDependencies(t, "script") + addFakeRecordDependencies(t, "script", "bwrap", "bash") stdout, stderr := captureOutput(t, func() { if got := Run(nil); got != 0 { @@ -32,7 +32,7 @@ func TestRunShowsHelpWhenNoArgs(t *testing.T) { } func TestRunInit(t *testing.T) { - addFakeRecordDependencies(t, "script") + addFakeRecordDependencies(t, "script", "bwrap", "bash") stdout, stderr := captureOutput(t, func() { if got := Run([]string{"init"}); got != 0 { @@ -49,7 +49,7 @@ func TestRunInit(t *testing.T) { } func TestRunRecord(t *testing.T) { - addFakeRecordDependencies(t, "script") + addFakeRecordDependencies(t, "script", "bwrap", "bash") root := t.TempDir() wantDir := filepath.Join(root, "e2e") @@ -82,7 +82,7 @@ func TestRunRecord(t *testing.T) { } func TestRunRecordWithExplicitTestDirPath(t *testing.T) { - addFakeRecordDependencies(t, "script") + addFakeRecordDependencies(t, "script", "bwrap", "bash") root := t.TempDir() wantDir := filepath.Join(root, "e2e") @@ -168,8 +168,49 @@ func TestRunFailsWhenDependenciesMissing(t *testing.T) { }) } +func TestRunRecordFailsWhenDependencyMissing(t *testing.T) { + root := t.TempDir() + wantDir := filepath.Join(root, "e2e") + if err := os.MkdirAll(wantDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + for _, tc := range []struct { + name string + deps []string + missing string + }{ + {name: "script", deps: []string{"bwrap", "bash"}, missing: "script"}, + {name: "bwrap", deps: []string{"script", "bash"}, missing: "bwrap"}, + {name: "bash", deps: []string{"script", "bwrap"}, missing: "bash"}, + } { + t.Run(tc.name, func(t *testing.T) { + addFakeRecordDependencies(t, tc.deps...) + + withWorkingDir(t, root, func() { + stdout, stderr := captureOutput(t, func() { + if got := Run([]string{"record", "suite/spec"}); got != 1 { + t.Fatalf("Run() code = %d, want %d", got, 1) + } + }) + + if stdout != "" { + t.Fatalf("stdout = %q, want empty", stdout) + } + want := `required command "` + tc.missing + `" not found in PATH` + if !strings.Contains(stderr, want) { + t.Fatalf("stderr = %q, want missing dependency error %q", stderr, want) + } + if _, err := os.Stat(filepath.Join(wantDir, "suite", "spec", "in")); !os.IsNotExist(err) { + t.Fatalf("Stat() error = %v, want not exists", err) + } + }) + }) + } +} + func TestRunRecordMissingPath(t *testing.T) { - addFakeRecordDependencies(t, "script") + addFakeRecordDependencies(t, "script", "bwrap", "bash") stdout, stderr := captureOutput(t, func() { if got := Run([]string{"record"}); got != 1 { @@ -189,7 +230,7 @@ func TestRunRecordMissingPath(t *testing.T) { } func TestRunInitExtraArgs(t *testing.T) { - addFakeRecordDependencies(t, "script") + addFakeRecordDependencies(t, "script", "bwrap", "bash") stdout, stderr := captureOutput(t, func() { if got := Run([]string{"init", "extra"}); got != 1 { @@ -209,7 +250,7 @@ func TestRunInitExtraArgs(t *testing.T) { } func TestRunUnknownCommand(t *testing.T) { - addFakeRecordDependencies(t, "script") + addFakeRecordDependencies(t, "script", "bwrap", "bash") stdout, stderr := captureOutput(t, func() { if got := Run([]string{"wat"}); got != 1 { @@ -299,14 +340,14 @@ func addFakeRecordDependencies(t *testing.T, names ...string) { path := filepath.Join(binDir, name) body := "#!/bin/sh\nexit 0\n" if name == "script" { - body = "#!/bin/sh\nin=''\nout=''\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n -I)\n in=\"$2\"\n shift 2\n ;;\n -O)\n out=\"$2\"\n shift 2\n ;;\n *)\n shift\n ;;\n esac\ndone\ncat <<'EOF' > \"$in\"\nfake recorded input\nEOF\ncat <<'EOF' > \"$out\"\nScript started on 2026-03-18 11:13:38+00:00 [TERM=\"xterm-256color\"]\nfake recorded output\nScript done on 2026-03-18 11:13:44+00:00 [COMMAND_EXIT_CODE=\"0\"]\nEOF\nexit 0\n" + body = "#!/bin/sh\nif [ -n \"${FAKE_SCRIPT_ARGS_FILE:-}\" ]; then\n : > \"$FAKE_SCRIPT_ARGS_FILE\"\n for arg in \"$@\"; do\n printf '%s\\n' \"$arg\" >> \"$FAKE_SCRIPT_ARGS_FILE\"\n done\nfi\nin=''\nout=''\ncmd=''\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n -I)\n in=\"$2\"\n shift 2\n ;;\n -O)\n out=\"$2\"\n shift 2\n ;;\n -c)\n cmd=\"$2\"\n shift 2\n ;;\n *)\n shift\n ;;\n esac\ndone\nif [ -n \"${FAKE_SCRIPT_COMMAND_BODY_FILE:-}\" ] && [ -n \"$cmd\" ]; then\n : > \"$FAKE_SCRIPT_COMMAND_BODY_FILE\"\n while IFS= read -r line || [ -n \"$line\" ]; do\n printf '%s\\n' \"$line\" >> \"$FAKE_SCRIPT_COMMAND_BODY_FILE\"\n done < \"$cmd\"\nfi\nprintf '%s' 'fake recorded input\n' > \"$in\"\nprintf '%s' 'Script started on 2026-03-18 11:13:38+00:00 [TERM=\"xterm-256color\"]\nfake recorded output\nScript done on 2026-03-18 11:13:44+00:00 [COMMAND_EXIT_CODE=\"0\"]\n' > \"$out\"\nexit 0\n" } if err := os.WriteFile(path, []byte(body), 0o755); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) } } - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("PATH", binDir) } func withStdin(t *testing.T, input string, fn func()) { diff --git a/internal/miro/post_process.go b/internal/miro/post_process.go index 81abf1d..cf88182 100644 --- a/internal/miro/post_process.go +++ b/internal/miro/post_process.go @@ -11,6 +11,19 @@ var ( scriptDonePrefix = []byte("Script done on ") ) +func loadRecordedInput(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if hasScriptWrapper(data) { + return stripScriptWrapper(data) + } + + return data, nil +} + func loadRecordedOutput(path string) ([]byte, error) { data, err := os.ReadFile(path) if err != nil { @@ -20,6 +33,15 @@ func loadRecordedOutput(path string) ([]byte, error) { return stripScriptWrapper(data) } +func hasScriptWrapper(data []byte) bool { + if bytes.HasPrefix(data, scriptStartPrefix) { + return true + } + + _, state := findScriptFooter(data) + return state != footerMissing +} + func stripScriptWrapper(data []byte) ([]byte, error) { hasHeader := bytes.HasPrefix(data, scriptStartPrefix) diff --git a/internal/miro/record_session.go b/internal/miro/record_session.go index 0c0d9a1..32f12e6 100644 --- a/internal/miro/record_session.go +++ b/internal/miro/record_session.go @@ -2,6 +2,7 @@ package miro import ( "bufio" + "fmt" "io" "os" "os/exec" @@ -11,7 +12,12 @@ import ( "miro/internal/output" ) -var () +const ( + recordVisibleHome = "/home/test" + recordVisibleRepo = "/home/test/repo" + recordVisibleTmp = "/tmp" + recordGitDate = "2024-01-01T00:00:00Z" +) type recordIO struct { in io.Reader @@ -26,6 +32,12 @@ func recordScenario(target string, rio recordIO) error { } defer cleanup() + sandbox, cleanupSandbox, err := newRecordSandbox() + if err != nil { + return err + } + defer cleanupSandbox() + overwrite, err := confirmRecordOverwrite(target, rio) if err != nil { return err @@ -36,7 +48,7 @@ func recordScenario(target string, rio recordIO) error { output.Fprintln(rio.err, "Run commands in the recorder shell, then type exit to finish.") - if err := runRecordSession(target, rawIn, rawOut, rio); err != nil { + if err := runRecordSession(target, rawIn, rawOut, sandbox, rio); err != nil { return err } @@ -76,8 +88,108 @@ func newRecordFiles() (string, string, func(), error) { return filepath.Join(dir, "in"), filepath.Join(dir, "out"), cleanup, nil } -func runRecordSession(dir, rawIn, rawOut string, rio recordIO) error { - cmd := exec.Command("script", "-q", "-I", rawIn, "-O", rawOut) +type recordSandbox struct { + hostHome string + hostTmp string + projectRoot string + wrapperPath string + pathEnv string +} + +func newRecordSandbox() (recordSandbox, func(), error) { + cwd, err := os.Getwd() + if err != nil { + return recordSandbox{}, nil, err + } + + return newRecordSandboxForProjectRoot(projectRoot(cwd), os.Getenv("PATH")) +} + +func newRecordSandboxForProjectRoot(root, pathEnv string) (recordSandbox, func(), error) { + dir, err := os.MkdirTemp("", "miro-record-sandbox-") + if err != nil { + return recordSandbox{}, nil, err + } + + cleanup := func() { + _ = os.RemoveAll(dir) + } + + sandbox := recordSandbox{ + hostHome: filepath.Join(dir, "home"), + hostTmp: filepath.Join(dir, "tmp"), + projectRoot: root, + wrapperPath: filepath.Join(dir, "sandbox.sh"), + pathEnv: pathEnv, + } + + for _, path := range []string{ + sandbox.hostHome, + filepath.Join(sandbox.hostHome, "repo"), + sandbox.hostTmp, + } { + if err := os.MkdirAll(path, 0o755); err != nil { + cleanup() + return recordSandbox{}, nil, err + } + } + + body := buildRecordWrapperScript(sandbox) + if err := os.WriteFile(sandbox.wrapperPath, []byte(body), 0o755); err != nil { + cleanup() + return recordSandbox{}, nil, err + } + + return sandbox, cleanup, nil +} + +func buildRecordWrapperScript(sandbox recordSandbox) string { + var body strings.Builder + + body.WriteString("#!/bin/sh\n") + body.WriteString("set -eu\n\n") + body.WriteString("if command -v git >/dev/null 2>&1; then\n") + body.WriteString(fmt.Sprintf(" HOME=%s GIT_CONFIG_NOSYSTEM=1 git config --global user.name 'Miro Test' >/dev/null 2>&1 || :\n", shQuote(sandbox.hostHome))) + body.WriteString(fmt.Sprintf(" HOME=%s GIT_CONFIG_NOSYSTEM=1 git config --global user.email 'miro-test@example.com' >/dev/null 2>&1 || :\n", shQuote(sandbox.hostHome))) + body.WriteString(fmt.Sprintf(" HOME=%s GIT_CONFIG_NOSYSTEM=1 git config --global init.defaultBranch main >/dev/null 2>&1 || :\n", shQuote(sandbox.hostHome))) + body.WriteString(fmt.Sprintf(" HOME=%s GIT_CONFIG_NOSYSTEM=1 git config --global advice.defaultBranchName false >/dev/null 2>&1 || :\n", shQuote(sandbox.hostHome))) + body.WriteString("fi\n\n") + body.WriteString("exec bwrap \\\n") + body.WriteString(" --ro-bind / / \\\n") + body.WriteString(" --tmpfs /home \\\n") + body.WriteString(fmt.Sprintf(" --bind %s %s \\\n", shQuote(sandbox.hostHome), shQuote(recordVisibleHome))) + body.WriteString(fmt.Sprintf(" --ro-bind %s %s \\\n", shQuote(sandbox.projectRoot), shQuote(recordVisibleRepo))) + body.WriteString(fmt.Sprintf(" --bind %s %s \\\n", shQuote(sandbox.hostTmp), shQuote(recordVisibleTmp))) + body.WriteString(" --dev /dev \\\n") + body.WriteString(" --proc /proc \\\n") + body.WriteString(" --unshare-pid \\\n") + body.WriteString(" --die-with-parent \\\n") + body.WriteString(fmt.Sprintf(" --setenv GIT_AUTHOR_DATE %s \\\n", shQuote(recordGitDate))) + body.WriteString(fmt.Sprintf(" --setenv GIT_COMMITTER_DATE %s \\\n", shQuote(recordGitDate))) + body.WriteString(" --setenv GIT_CONFIG_NOSYSTEM '1' \\\n") + body.WriteString(" --setenv GIT_PAGER 'cat' \\\n") + body.WriteString(" --setenv HISTFILE '/dev/null' \\\n") + body.WriteString(fmt.Sprintf(" --setenv HOME %s \\\n", shQuote(recordVisibleHome))) + body.WriteString(" --setenv LANG 'C' \\\n") + body.WriteString(" --setenv LC_ALL 'C' \\\n") + body.WriteString(" --setenv PAGER 'cat' \\\n") + body.WriteString(fmt.Sprintf(" --setenv PATH %s \\\n", shQuote(sandbox.pathEnv))) + body.WriteString(" --setenv PS1 '$ ' \\\n") + body.WriteString(" --setenv TERM 'xterm-256color' \\\n") + body.WriteString(fmt.Sprintf(" --setenv TMPDIR %s \\\n", shQuote(recordVisibleTmp))) + body.WriteString(" --setenv TZ 'UTC' \\\n") + body.WriteString(fmt.Sprintf(" --chdir %s \\\n", shQuote(recordVisibleHome))) + body.WriteString(" bash --noprofile --norc -i\n") + + return body.String() +} + +func shQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func runRecordSession(dir, rawIn, rawOut string, sandbox recordSandbox, rio recordIO) error { + cmd := exec.Command("script", "-q", "-E", "always", "-I", rawIn, "-O", rawOut, "-c", sandbox.wrapperPath) cmd.Dir = dir cmd.Stdin = rio.in cmd.Stdout = rio.out @@ -137,7 +249,7 @@ func recordFixturesExist(target string) (bool, error) { } func loadRecordedFixtures(rawIn, rawOut string) ([]byte, []byte, error) { - recordedIn, err := os.ReadFile(rawIn) + recordedIn, err := loadRecordedInput(rawIn) if err != nil { return nil, nil, err } diff --git a/internal/miro/record_test.go b/internal/miro/record_test.go index aab175c..12ba3ad 100644 --- a/internal/miro/record_test.go +++ b/internal/miro/record_test.go @@ -4,8 +4,11 @@ import ( "bytes" "errors" "os" + "os/exec" "path/filepath" + "strings" "testing" + "time" ) func TestRecordCreatesRelativePath(t *testing.T) { @@ -153,6 +156,162 @@ func TestRecordReturnsDiscardedErrorWhenOverwriteDeclined(t *testing.T) { } } +func TestNewRecordSandboxBuildsDeterministicWrapper(t *testing.T) { + projectRoot := filepath.Join(t.TempDir(), "project root") + sandbox, cleanup, err := newRecordSandboxForProjectRoot(projectRoot, "/custom/bin:/usr/bin") + if err != nil { + t.Fatalf("newRecordSandboxForProjectRoot() error = %v", err) + } + defer cleanup() + + body := readFile(t, sandbox.wrapperPath) + + for _, want := range []string{ + "HOME=" + shQuote(sandbox.hostHome) + " GIT_CONFIG_NOSYSTEM=1 git config --global user.name 'Miro Test'", + "--bind " + shQuote(sandbox.hostHome) + " " + shQuote(recordVisibleHome), + "--ro-bind " + shQuote(projectRoot) + " " + shQuote(recordVisibleRepo), + "--bind " + shQuote(sandbox.hostTmp) + " " + shQuote(recordVisibleTmp), + "--setenv HOME " + shQuote(recordVisibleHome), + "--setenv PATH " + shQuote("/custom/bin:/usr/bin"), + "--setenv PS1 '$ '", + "--setenv TERM 'xterm-256color'", + "--setenv TZ 'UTC'", + "--chdir " + shQuote(recordVisibleHome), + "bash --noprofile --norc -i", + } { + if !strings.Contains(body, want) { + t.Fatalf("wrapper = %q, want substring %q", body, want) + } + } +} + +func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) { + root := t.TempDir() + 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) + + sandbox, cleanup, err := newRecordSandboxForProjectRoot(root, os.Getenv("PATH")) + if err != nil { + t.Fatalf("newRecordSandboxForProjectRoot() error = %v", err) + } + defer cleanup() + + err = withRecordStreams(t, "", func(rio recordIO) error { + return runRecordSession(root, filepath.Join(t.TempDir(), "raw.in"), filepath.Join(t.TempDir(), "raw.out"), sandbox, rio) + }) + if err != nil { + t.Fatalf("runRecordSession() error = %v", err) + } + + args := strings.Split(strings.TrimSpace(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] != sandbox.wrapperPath { + t.Fatalf("script args[8] = %q, want %q", args[8], sandbox.wrapperPath) + } + + body := readFile(t, commandBodyPath) + for _, want := range []string{ + "--ro-bind / /", + "--tmpfs /home", + "--ro-bind " + shQuote(root) + " " + shQuote(recordVisibleRepo), + "--setenv HOME " + shQuote(recordVisibleHome), + "--setenv TMPDIR " + shQuote(recordVisibleTmp), + "bash --noprofile --norc -i", + } { + if !strings.Contains(body, want) { + t.Fatalf("wrapper = %q, want substring %q", body, want) + } + } +} + +func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { + requireCommands(t, "script", "bwrap", "bash") + + root := t.TempDir() + testDir := filepath.Join(root, "e2e") + target := filepath.Join(testDir, "suite", "spec") + mustMkdirAll(t, target) + + 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\"\ncd repo\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 = withWorkingDir(t, root, func() error { + return recordScenario(target, recordIO{ + in: reader, + out: ioDiscard{}, + err: &bytes.Buffer{}, + }) + }) + if err != nil { + t.Fatalf("recordScenario() error = %v", err) + } + if err := <-writeDone; err != nil { + t.Fatalf("write session input: %v", err) + } + + recordedIn := readFile(t, filepath.Join(target, "in")) + if strings.Contains(recordedIn, "Script started on ") { + t.Fatalf("saved in = %q, want stripped script wrapper", recordedIn) + } + for _, want := range []string{"pwd\n", "echo \"$HOME\"\n", "cd repo\n", "exit\n"} { + if !strings.Contains(recordedIn, want) { + t.Fatalf("saved in = %q, want substring %q", recordedIn, want) + } + } + + recordedOut := readFile(t, filepath.Join(target, "out")) + if strings.Contains(recordedOut, "Script started on ") { + t.Fatalf("saved out = %q, want stripped script wrapper", recordedOut) + } + for _, want := range []string{recordVisibleHome, recordVisibleRepo} { + if !strings.Contains(recordedOut, want) { + t.Fatalf("saved out = %q, want substring %q", recordedOut, want) + } + } +} + func withRecordStreams[T any](t *testing.T, input string, fn func(recordIO) T) T { t.Helper() @@ -229,12 +388,22 @@ func addFakeRecordDependencies(t *testing.T, names ...string) { path := filepath.Join(binDir, name) body := "#!/bin/sh\nexit 0\n" if name == "script" { - body = "#!/bin/sh\nin=''\nout=''\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n -I)\n in=\"$2\"\n shift 2\n ;;\n -O)\n out=\"$2\"\n shift 2\n ;;\n *)\n shift\n ;;\n esac\ndone\nif [ -n \"${FAKE_SCRIPT_LOG_IN+x}\" ]; then\n printf '%s' \"$FAKE_SCRIPT_LOG_IN\" > \"$in\"\nelse\n cat <<'EOF' > \"$in\"\nfake recorded input\nEOF\nfi\nif [ -n \"${FAKE_SCRIPT_LOG_OUT+x}\" ]; then\n printf '%s' \"$FAKE_SCRIPT_LOG_OUT\" > \"$out\"\nelse\n cat <<'EOF' > \"$out\"\nScript started on 2026-03-18 11:13:38+00:00 [TERM=\"xterm-256color\"]\nfake recorded output\nScript done on 2026-03-18 11:13:44+00:00 [COMMAND_EXIT_CODE=\"0\"]\nEOF\nfi\nexit 0\n" + body = "#!/bin/sh\nif [ -n \"${FAKE_SCRIPT_ARGS_FILE:-}\" ]; then\n : > \"$FAKE_SCRIPT_ARGS_FILE\"\n for arg in \"$@\"; do\n printf '%s\\n' \"$arg\" >> \"$FAKE_SCRIPT_ARGS_FILE\"\n done\nfi\nin=''\nout=''\ncmd=''\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n -I)\n in=\"$2\"\n shift 2\n ;;\n -O)\n out=\"$2\"\n shift 2\n ;;\n -c)\n cmd=\"$2\"\n shift 2\n ;;\n *)\n shift\n ;;\n esac\ndone\nif [ -n \"${FAKE_SCRIPT_COMMAND_BODY_FILE:-}\" ] && [ -n \"$cmd\" ]; then\n : > \"$FAKE_SCRIPT_COMMAND_BODY_FILE\"\n while IFS= read -r line || [ -n \"$line\" ]; do\n printf '%s\\n' \"$line\" >> \"$FAKE_SCRIPT_COMMAND_BODY_FILE\"\n done < \"$cmd\"\nfi\nif [ -n \"${FAKE_SCRIPT_LOG_IN+x}\" ]; then\n printf '%s' \"$FAKE_SCRIPT_LOG_IN\" > \"$in\"\nelse\n printf '%s' 'fake recorded input\n' > \"$in\"\nfi\nif [ -n \"${FAKE_SCRIPT_LOG_OUT+x}\" ]; then\n printf '%s' \"$FAKE_SCRIPT_LOG_OUT\" > \"$out\"\nelse\n printf '%s' 'Script started on 2026-03-18 11:13:38+00:00 [TERM=\"xterm-256color\"]\nfake recorded output\nScript done on 2026-03-18 11:13:44+00:00 [COMMAND_EXIT_CODE=\"0\"]\n' > \"$out\"\nfi\nexit 0\n" } if err := os.WriteFile(path, []byte(body), 0o755); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) } } - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("PATH", binDir) +} + +func requireCommands(t *testing.T, names ...string) { + t.Helper() + + for _, name := range names { + if _, err := exec.LookPath(name); err != nil { + t.Skipf("missing command %q: %v", name, err) + } + } }