From e5b767e35f287c9d8b638e68d82a9b6e6d4473a7 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Thu, 19 Mar 2026 20:15:11 +0000 Subject: [PATCH] feat: miro test command --- cmd/root.go | 2 +- cmd/root_test.go | 296 ++++++++++++++++++++++++++++- cmd/test.go | 17 ++ internal/miro/init.go | 2 +- internal/miro/post_process.go | 17 +- internal/miro/post_process_test.go | 35 ++++ internal/miro/record_shell.go | 27 ++- internal/miro/record_shell.sh | 4 + internal/miro/record_test.go | 124 +++++++++++- internal/miro/test.go | 243 +++++++++++++++++++++++ internal/miro/test_test.go | 185 ++++++++++++++++++ internal/miro/testdir_test.go | 6 +- 12 files changed, 942 insertions(+), 16 deletions(-) create mode 100644 cmd/test.go create mode 100644 internal/miro/post_process_test.go create mode 100644 internal/miro/test.go create mode 100644 internal/miro/test_test.go diff --git a/cmd/root.go b/cmd/root.go index 78d0d70..bef702d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,7 +34,7 @@ func newRootCommand() *cobra.Command { }, } - rootCmd.AddCommand(newInitCommand(), newRecordCommand()) + rootCmd.AddCommand(newInitCommand(), newRecordCommand(), newTestCommand()) return rootCmd } diff --git a/cmd/root_test.go b/cmd/root_test.go index 141ac72..62055c4 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -23,7 +23,7 @@ func TestRunShowsHelpWhenNoArgs(t *testing.T) { if !strings.Contains(stdout, "A lean CLI E2E testing framework.") { t.Fatalf("stdout = %q, want root help", stdout) } - if !strings.Contains(stdout, "init") || !strings.Contains(stdout, "record") { + if !strings.Contains(stdout, "init") || !strings.Contains(stdout, "record") || !strings.Contains(stdout, "test") { t.Fatalf("stdout = %q, want listed subcommands", stdout) } if stderr != "" { @@ -263,6 +263,194 @@ func TestRunRecordMissingConfigFails(t *testing.T) { }) } +func TestRunTest(t *testing.T) { + addFakeRecordDependencies(t, "script", "bwrap", "bash") + t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1") + + root := t.TempDir() + writeScenarioFixtures(t, filepath.Join(root, "e2e", "a"), "echo one\n", "echo one\n") + writeScenarioFixtures(t, filepath.Join(root, "e2e", "nested", "b"), "echo two\n", "echo two\n") + + withWorkingDir(t, root, func() { + initStdout, initStderr := captureOutput(t, func() { + if got := Run([]string{"init"}); got != 0 { + t.Fatalf("Run() code = %d, want %d", got, 0) + } + }) + if initStdout != prefixed("Done initialising...\n") { + t.Fatalf("stdout = %q, want %q", initStdout, prefixed("Done initialising...\n")) + } + if initStderr != "" { + t.Fatalf("stderr = %q, want empty", initStderr) + } + + stdout, stderr := captureOutput(t, func() { + if got := Run([]string{"test"}); got != 0 { + t.Fatalf("Run() code = %d, want %d", got, 0) + } + }) + + for _, want := range []string{ + "RUN a", + "PASS a", + "RUN nested/b", + "PASS nested/b", + "Summary: total=2 passed=2 failed=0", + "echo one\n", + "echo two\n", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("stdout = %q, want substring %q", stdout, want) + } + } + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + }) +} + +func TestRunTestReturnsOneWhenScenarioMismatches(t *testing.T) { + addFakeRecordDependencies(t, "script", "bwrap", "bash") + t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1") + + root := t.TempDir() + writeScenarioFixtures(t, filepath.Join(root, "e2e", "a"), "echo one\n", "echo one\n") + writeScenarioFixtures(t, filepath.Join(root, "e2e", "b"), "echo two\n", "different output\n") + + withWorkingDir(t, root, func() { + initStdout, initStderr := captureOutput(t, func() { + if got := Run([]string{"init"}); got != 0 { + t.Fatalf("Run() code = %d, want %d", got, 0) + } + }) + if initStdout != prefixed("Done initialising...\n") { + t.Fatalf("stdout = %q, want %q", initStdout, prefixed("Done initialising...\n")) + } + if initStderr != "" { + t.Fatalf("stderr = %q, want empty", initStderr) + } + + stdout, stderr := captureOutput(t, func() { + if got := Run([]string{"test"}); got != 1 { + t.Fatalf("Run() code = %d, want %d", got, 1) + } + }) + + for _, want := range []string{ + "RUN a", + "PASS a", + "RUN b", + "FAIL b: output differed", + "Summary: total=2 passed=1 failed=1", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("stdout = %q, want substring %q", stdout, want) + } + } + if !strings.Contains(stderr, "1 scenario(s) failed") { + t.Fatalf("stderr = %q, want suite failure", stderr) + } + }) +} + +func TestRunTestMissingConfigFails(t *testing.T) { + addFakeRecordDependencies(t, "script", "bwrap", "bash") + + root := t.TempDir() + + withWorkingDir(t, root, func() { + stdout, stderr := captureOutput(t, func() { + if got := Run([]string{"test"}); got != 1 { + t.Fatalf("Run() code = %d, want %d", got, 1) + } + }) + + if stdout != "" { + t.Fatalf("stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, "failed to resolve test directory") || !strings.Contains(stderr, "miro.toml") { + t.Fatalf("stderr = %q, want missing config error", stderr) + } + }) +} + +func TestRunTestFailsWhenRecorderShellMissing(t *testing.T) { + addFakeRecordDependencies(t, "script", "bwrap", "bash") + + root := t.TempDir() + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) + + withWorkingDir(t, root, func() { + stdout, stderr := captureOutput(t, func() { + if got := Run([]string{"test"}); got != 1 { + t.Fatalf("Run() code = %d, want %d", got, 1) + } + }) + + if stdout != "" { + t.Fatalf("stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, "rerun `miro init`") { + t.Fatalf("stderr = %q, want rerun init hint", stderr) + } + }) +} + +func TestRunTestFailsWhenFixtureMalformed(t *testing.T) { + addFakeRecordDependencies(t, "script", "bwrap", "bash") + + root := t.TempDir() + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) + writeFile(t, filepath.Join(root, "e2e", "shell.sh"), "#!/bin/sh\n") + writeFile(t, filepath.Join(root, "e2e", "broken", "in"), "echo broken\n") + + withWorkingDir(t, root, func() { + stdout, stderr := captureOutput(t, func() { + if got := Run([]string{"test"}); got != 1 { + t.Fatalf("Run() code = %d, want %d", got, 1) + } + }) + + if stdout != "" { + t.Fatalf("stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, `malformed scenario "broken": missing out fixture`) { + t.Fatalf("stderr = %q, want malformed fixture error", stderr) + } + }) +} + +func TestRunTestFailsWhenCompareMarkerMissing(t *testing.T) { + addFakeRecordDependencies(t, "script", "bwrap", "bash") + t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1") + + root := t.TempDir() + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) + writeFile(t, filepath.Join(root, "e2e", "shell.sh"), "#!/bin/sh\n") + writeScenarioFixtures(t, filepath.Join(root, "e2e", "some"), "echo one\n", "echo one\n") + + withWorkingDir(t, root, func() { + stdout, stderr := captureOutput(t, func() { + if got := Run([]string{"test"}); got != 1 { + t.Fatalf("Run() code = %d, want %d", got, 1) + } + }) + + for _, want := range []string{ + "RUN some", + "FAIL some: missing compare marker", + "Summary: total=1 passed=0 failed=1", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("stdout = %q, want substring %q", stdout, want) + } + } + if !strings.Contains(stderr, "1 scenario(s) failed") { + t.Fatalf("stderr = %q, want suite failure", stderr) + } + }) +} + func TestRunRecordMissingPath(t *testing.T) { addFakeRecordDependencies(t, "script", "bwrap", "bash") @@ -370,11 +558,21 @@ func prefixed(msg string) string { func writeFile(t *testing.T, path, content string) { t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err) + } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) } } +func writeScenarioFixtures(t *testing.T, dir, in, out string) { + t.Helper() + + writeFile(t, filepath.Join(dir, "in"), in) + writeFile(t, filepath.Join(dir, "out"), out) +} + func mustReadFile(t *testing.T, path string) string { t.Helper() @@ -412,7 +610,101 @@ func addFakeRecordDependencies(t *testing.T, names ...string) { path := filepath.Join(binDir, name) body := "#!/bin/sh\nexit 0\n" if name == "script" { - 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" + body = `#!/bin/sh +if [ -n "${FAKE_SCRIPT_ARGS_FILE:-}" ]; then + : > "$FAKE_SCRIPT_ARGS_FILE" + for arg in "$@"; do + printf '%s\n' "$arg" >> "$FAKE_SCRIPT_ARGS_FILE" + done +fi +in='' +out='' +cmd='' +while [ "$#" -gt 0 ]; do + case "$1" in + -I) + in="$2" + shift 2 + ;; + -O) + out="$2" + shift 2 + ;; + -c) + cmd="$2" + shift 2 + ;; + *) + shift + ;; + esac +done +if [ -n "${FAKE_SCRIPT_COMMAND_BODY_FILE:-}" ] && [ -n "$cmd" ]; then + : > "$FAKE_SCRIPT_COMMAND_BODY_FILE" + while IFS= read -r line || [ -n "$line" ]; do + printf '%s\n' "$line" >> "$FAKE_SCRIPT_COMMAND_BODY_FILE" + done < "$cmd" +fi +command_has_compare_marker=0 +if [ -n "$cmd" ] && /bin/grep -q '__MIRO_E2E_BEGIN__' "$cmd" 2>/dev/null; then + command_has_compare_marker=1 +fi +stdin_file='' +if [ "${FAKE_SCRIPT_ECHO_STDIN:-}" = "1" ] || [ -n "${FAKE_SCRIPT_CAPTURE_STDIN_FILE:-}" ]; then + stdin_file="${TMPDIR:-/tmp}/miro-fake-script-stdin-$$" + /bin/cat > "$stdin_file" +fi +if [ -n "${FAKE_SCRIPT_CAPTURE_STDIN_FILE:-}" ] && [ -n "$stdin_file" ]; then + /bin/cp "$stdin_file" "$FAKE_SCRIPT_CAPTURE_STDIN_FILE" +fi +if [ -n "$in" ] && [ -n "${FAKE_SCRIPT_LOG_IN+x}" ]; then + printf '%s' "$FAKE_SCRIPT_LOG_IN" > "$in" +elif [ -n "$in" ] && [ -n "$stdin_file" ]; then + /bin/cp "$stdin_file" "$in" +elif [ -n "$in" ]; then + printf '%s' 'fake recorded input +' > "$in" +fi +if [ -n "${FAKE_SCRIPT_STREAM_OUT+x}" ]; then + printf '%s' "$FAKE_SCRIPT_STREAM_OUT" +elif [ "${FAKE_SCRIPT_ECHO_STDIN:-}" = "1" ] && [ -n "$stdin_file" ]; then + /bin/cat "$stdin_file" + if [ "${MIRO_COMPARE_MARKER:-0}" = "1" ] && [ "$command_has_compare_marker" = "1" ]; then + printf '%s\n' '__MIRO_E2E_BEGIN__' + /bin/cat "$stdin_file" + fi +fi +if [ -n "${FAKE_SCRIPT_STREAM_ERR+x}" ]; then + printf '%s' "$FAKE_SCRIPT_STREAM_ERR" >&2 +fi +if [ -n "$out" ] && [ -n "${FAKE_SCRIPT_LOG_OUT+x}" ]; then + printf '%s' "$FAKE_SCRIPT_LOG_OUT" > "$out" +elif [ -n "$out" ] && [ "${FAKE_SCRIPT_ECHO_STDIN:-}" = "1" ] && [ -n "$stdin_file" ]; then + { + printf '%s' 'Script started on 2026-03-18 11:13:38+00:00 [TERM="xterm-256color"] +' + /bin/cat "$stdin_file" + if [ "${MIRO_COMPARE_MARKER:-0}" = "1" ] && [ "$command_has_compare_marker" = "1" ]; then + printf '%s\n' '__MIRO_E2E_BEGIN__' + /bin/cat "$stdin_file" + fi + printf '%s' 'Script done on 2026-03-18 11:13:44+00:00 [COMMAND_EXIT_CODE="0"] +' + } > "$out" +elif [ -n "$out" ]; then + printf '%s' 'Script started on 2026-03-18 11:13:38+00:00 [TERM="xterm-256color"] +fake recorded output +Script done on 2026-03-18 11:13:44+00:00 [COMMAND_EXIT_CODE="0"] +' > "$out" +fi +if [ -n "$stdin_file" ]; then + /bin/rm -f "$stdin_file" +fi +if [ -n "${FAKE_SCRIPT_EXIT_CODE:-}" ]; then + exit "$FAKE_SCRIPT_EXIT_CODE" +fi +exit 0 +` } if err := os.WriteFile(path, []byte(body), 0o755); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) diff --git a/cmd/test.go b/cmd/test.go new file mode 100644 index 0000000..77e7055 --- /dev/null +++ b/cmd/test.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "miro/internal/miro" + + "github.com/spf13/cobra" +) + +func newTestCommand() *cobra.Command { + return &cobra.Command{ + Use: "test", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return miro.RunTests() + }, + } +} diff --git a/internal/miro/init.go b/internal/miro/init.go index a8a8bc8..9836ea7 100644 --- a/internal/miro/init.go +++ b/internal/miro/init.go @@ -11,7 +11,7 @@ import ( const defaultTestDir = "e2e" -// Init creates the default config when missing and leaves valid config untouched. +// Init creates the default config when missing and refreshes the recorder shell. func Init() error { root, err := currentProjectRoot() if err != nil { diff --git a/internal/miro/post_process.go b/internal/miro/post_process.go index cf88182..72d96a2 100644 --- a/internal/miro/post_process.go +++ b/internal/miro/post_process.go @@ -11,6 +11,8 @@ var ( scriptDonePrefix = []byte("Script done on ") ) +const eofByte = byte(0x04) + func loadRecordedInput(path string) ([]byte, error) { data, err := os.ReadFile(path) if err != nil { @@ -18,10 +20,13 @@ func loadRecordedInput(path string) ([]byte, error) { } if hasScriptWrapper(data) { - return stripScriptWrapper(data) + data, err = stripScriptWrapper(data) + if err != nil { + return nil, err + } } - return data, nil + return trimTrailingNewlineAfterEOF(data), nil } func loadRecordedOutput(path string) ([]byte, error) { @@ -102,3 +107,11 @@ func findScriptFooter(data []byte) (int, footerMatchState) { return candidate, footerFound } + +func trimTrailingNewlineAfterEOF(data []byte) []byte { + if len(data) >= 2 && data[len(data)-2] == eofByte && data[len(data)-1] == '\n' { + return data[:len(data)-1] + } + + return data +} diff --git a/internal/miro/post_process_test.go b/internal/miro/post_process_test.go new file mode 100644 index 0000000..d345403 --- /dev/null +++ b/internal/miro/post_process_test.go @@ -0,0 +1,35 @@ +package miro + +import ( + "path/filepath" + "testing" +) + +func TestLoadRecordedInputTrimsTrailingNewlineAfterEOF(t *testing.T) { + path := filepath.Join(t.TempDir(), "in") + writeFile(t, path, "echo hi\r"+string([]byte{eofByte})+"\n") + + got, err := loadRecordedInput(path) + if err != nil { + t.Fatalf("loadRecordedInput() error = %v", err) + } + + want := "echo hi\r" + string([]byte{eofByte}) + if string(got) != want { + t.Fatalf("loadRecordedInput() = %q, want %q", string(got), want) + } +} + +func TestLoadRecordedInputLeavesNormalTrailingNewline(t *testing.T) { + path := filepath.Join(t.TempDir(), "in") + writeFile(t, path, "echo hi\n") + + got, err := loadRecordedInput(path) + if err != nil { + t.Fatalf("loadRecordedInput() error = %v", err) + } + + if string(got) != "echo hi\n" { + t.Fatalf("loadRecordedInput() = %q, want %q", string(got), "echo hi\n") + } +} diff --git a/internal/miro/record_shell.go b/internal/miro/record_shell.go index 1565664..0017572 100644 --- a/internal/miro/record_shell.go +++ b/internal/miro/record_shell.go @@ -12,6 +12,12 @@ import ( const recordShellName = "shell.sh" +const ( + compareMarkerEnvName = "MIRO_COMPARE_MARKER" + compareMarkerEnabledValue = "1" + compareOutputMarker = "__MIRO_E2E_BEGIN__" +) + //go:embed record_shell.sh var recordShellFS embed.FS @@ -21,8 +27,10 @@ func recordShellPath(testDir string) string { func ensureRecordShell(testDir string) error { path := recordShellPath(testDir) - if _, err := os.Stat(path); err == nil { - return nil + if info, err := os.Stat(path); err == nil { + if info.IsDir() { + return fmt.Errorf("recorder shell %q is a directory; remove it and rerun `miro init`", path) + } } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to check recorder shell %q: %v", path, err) } @@ -72,15 +80,22 @@ func buildRecordShellScript() string { } func recordSessionEnv(sandbox recordSandbox, sandboxConfig map[string]string) []string { + return recordSessionEnvWithExtra(sandbox, sandboxConfig, nil) +} + +func recordSessionEnvWithExtra(sandbox recordSandbox, sandboxConfig, extraEnv map[string]string) []string { env := append([]string{}, os.Environ()...) env = append(env, "MIRO_HOST_HOME="+sandbox.hostHome, "MIRO_HOST_TMP="+sandbox.hostTmp, "MIRO_PATH_ENV="+sandbox.pathEnv, ) - for _, key := range sortedSandboxKeys(sandboxConfig) { + for _, key := range sortedKeys(sandboxConfig) { env = append(env, sandboxEnvName(key)+"="+sandboxConfig[key]) } + for _, key := range sortedKeys(extraEnv) { + env = append(env, key+"="+extraEnv[key]) + } return env } @@ -89,9 +104,9 @@ func sandboxEnvName(key string) string { return "MIRO_" + strings.ToUpper(key) } -func sortedSandboxKeys(sandboxConfig map[string]string) []string { - keys := make([]string, 0, len(sandboxConfig)) - for key := range sandboxConfig { +func sortedKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { keys = append(keys, key) } sort.Strings(keys) diff --git a/internal/miro/record_shell.sh b/internal/miro/record_shell.sh index 132f92f..ee8b994 100644 --- a/internal/miro/record_shell.sh +++ b/internal/miro/record_shell.sh @@ -7,6 +7,10 @@ host_tmp=${MIRO_HOST_TMP:?} path_env=${MIRO_PATH_ENV:?} visible_home=${MIRO_VISIBLE_HOME:?} +if [ "${MIRO_COMPARE_MARKER:-0}" = "1" ]; then + printf '__MIRO_E2E_BEGIN__\n' +fi + set -- \ --ro-bind / / \ --tmpfs /home \ diff --git a/internal/miro/record_test.go b/internal/miro/record_test.go index 55ea56c..e8882c1 100644 --- a/internal/miro/record_test.go +++ b/internal/miro/record_test.go @@ -171,6 +171,8 @@ func TestBuildRecordShellScriptUsesExpectedCommands(t *testing.T) { "host_tmp=${MIRO_HOST_TMP:?}", "path_env=${MIRO_PATH_ENV:?}", "visible_home=${MIRO_VISIBLE_HOME:?}", + `if [ "${MIRO_COMPARE_MARKER:-0}" = "1" ]; then`, + "printf '__MIRO_E2E_BEGIN__\\n'", "--bind \"$host_home\" \"$visible_home\"", "--bind \"$host_tmp\" '/tmp'", "--setenv HOME \"$visible_home\"", @@ -210,6 +212,30 @@ func TestRecordSessionEnvIncludesConfiguredSandboxEnv(t *testing.T) { } } +func TestRecordSessionEnvWithExtraIncludesAdditionalEntries(t *testing.T) { + env := recordSessionEnvWithExtra(recordSandbox{ + hostHome: "/tmp/host-home", + hostTmp: "/tmp/host-tmp", + pathEnv: "/tmp/bin", + }, map[string]string{ + "visible_home": "/sandbox/home", + }, map[string]string{ + compareMarkerEnvName: compareMarkerEnabledValue, + }) + + for _, want := range []string{ + "MIRO_HOST_HOME=/tmp/host-home", + "MIRO_HOST_TMP=/tmp/host-tmp", + "MIRO_PATH_ENV=/tmp/bin", + "MIRO_VISIBLE_HOME=/sandbox/home", + "MIRO_COMPARE_MARKER=1", + } { + if !containsEnvEntry(env, want) { + t.Fatalf("env = %#v, want entry %q", env, want) + } + } +} + func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) { testDir := filepath.Join(t.TempDir(), "e2e") mustWriteRecordShell(t, testDir) @@ -255,6 +281,8 @@ func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) { for _, want := range []string{ "host_home=${MIRO_HOST_HOME:?}", "visible_home=${MIRO_VISIBLE_HOME:?}", + `if [ "${MIRO_COMPARE_MARKER:-0}" = "1" ]; then`, + "printf '__MIRO_E2E_BEGIN__\\n'", "--ro-bind / /", "--tmpfs /home", "--setenv HOME \"$visible_home\"", @@ -451,7 +479,101 @@ func addFakeRecordDependencies(t *testing.T, names ...string) { path := filepath.Join(binDir, name) body := "#!/bin/sh\nexit 0\n" if name == "script" { - 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" + body = `#!/bin/sh +if [ -n "${FAKE_SCRIPT_ARGS_FILE:-}" ]; then + : > "$FAKE_SCRIPT_ARGS_FILE" + for arg in "$@"; do + printf '%s\n' "$arg" >> "$FAKE_SCRIPT_ARGS_FILE" + done +fi +in='' +out='' +cmd='' +while [ "$#" -gt 0 ]; do + case "$1" in + -I) + in="$2" + shift 2 + ;; + -O) + out="$2" + shift 2 + ;; + -c) + cmd="$2" + shift 2 + ;; + *) + shift + ;; + esac +done +if [ -n "${FAKE_SCRIPT_COMMAND_BODY_FILE:-}" ] && [ -n "$cmd" ]; then + : > "$FAKE_SCRIPT_COMMAND_BODY_FILE" + while IFS= read -r line || [ -n "$line" ]; do + printf '%s\n' "$line" >> "$FAKE_SCRIPT_COMMAND_BODY_FILE" + done < "$cmd" +fi +command_has_compare_marker=0 +if [ -n "$cmd" ] && /bin/grep -q '__MIRO_E2E_BEGIN__' "$cmd" 2>/dev/null; then + command_has_compare_marker=1 +fi +stdin_file='' +if [ "${FAKE_SCRIPT_ECHO_STDIN:-}" = "1" ] || [ -n "${FAKE_SCRIPT_CAPTURE_STDIN_FILE:-}" ]; then + stdin_file="${TMPDIR:-/tmp}/miro-fake-script-stdin-$$" + /bin/cat > "$stdin_file" +fi +if [ -n "${FAKE_SCRIPT_CAPTURE_STDIN_FILE:-}" ] && [ -n "$stdin_file" ]; then + /bin/cp "$stdin_file" "$FAKE_SCRIPT_CAPTURE_STDIN_FILE" +fi +if [ -n "$in" ] && [ -n "${FAKE_SCRIPT_LOG_IN+x}" ]; then + printf '%s' "$FAKE_SCRIPT_LOG_IN" > "$in" +elif [ -n "$in" ] && [ -n "$stdin_file" ]; then + /bin/cp "$stdin_file" "$in" +elif [ -n "$in" ]; then + printf '%s' 'fake recorded input +' > "$in" +fi +if [ -n "${FAKE_SCRIPT_STREAM_OUT+x}" ]; then + printf '%s' "$FAKE_SCRIPT_STREAM_OUT" +elif [ "${FAKE_SCRIPT_ECHO_STDIN:-}" = "1" ] && [ -n "$stdin_file" ]; then + /bin/cat "$stdin_file" + if [ "${MIRO_COMPARE_MARKER:-0}" = "1" ] && [ "$command_has_compare_marker" = "1" ]; then + printf '%s\n' '__MIRO_E2E_BEGIN__' + /bin/cat "$stdin_file" + fi +fi +if [ -n "${FAKE_SCRIPT_STREAM_ERR+x}" ]; then + printf '%s' "$FAKE_SCRIPT_STREAM_ERR" >&2 +fi +if [ -n "$out" ] && [ -n "${FAKE_SCRIPT_LOG_OUT+x}" ]; then + printf '%s' "$FAKE_SCRIPT_LOG_OUT" > "$out" +elif [ -n "$out" ] && [ "${FAKE_SCRIPT_ECHO_STDIN:-}" = "1" ] && [ -n "$stdin_file" ]; then + { + printf '%s' 'Script started on 2026-03-18 11:13:38+00:00 [TERM="xterm-256color"] +' + /bin/cat "$stdin_file" + if [ "${MIRO_COMPARE_MARKER:-0}" = "1" ] && [ "$command_has_compare_marker" = "1" ]; then + printf '%s\n' '__MIRO_E2E_BEGIN__' + /bin/cat "$stdin_file" + fi + printf '%s' 'Script done on 2026-03-18 11:13:44+00:00 [COMMAND_EXIT_CODE="0"] +' + } > "$out" +elif [ -n "$out" ]; then + printf '%s' 'Script started on 2026-03-18 11:13:38+00:00 [TERM="xterm-256color"] +fake recorded output +Script done on 2026-03-18 11:13:44+00:00 [COMMAND_EXIT_CODE="0"] +' > "$out" +fi +if [ -n "$stdin_file" ]; then + /bin/rm -f "$stdin_file" +fi +if [ -n "${FAKE_SCRIPT_EXIT_CODE:-}" ]; then + exit "$FAKE_SCRIPT_EXIT_CODE" +fi +exit 0 +` } if err := os.WriteFile(path, []byte(body), 0o755); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) diff --git a/internal/miro/test.go b/internal/miro/test.go new file mode 100644 index 0000000..854c237 --- /dev/null +++ b/internal/miro/test.go @@ -0,0 +1,243 @@ +package miro + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + + "miro/internal/output" +) + +type testIO struct { + out io.Writer + err io.Writer +} + +type testScenario struct { + dir string + relPath string + inPath string + outPath string +} + +type testSummary struct { + total int + passed int + failed int +} + +type testFixtureFiles struct { + inPath string + outPath string +} + +type TestSuiteFailedError struct { + Failed int +} + +func (e TestSuiteFailedError) Error() string { + return fmt.Sprintf("%d scenario(s) failed", e.Failed) +} + +// RunTests replays all scenarios under the configured test directory. +func RunTests() error { + return runTests(testIO{ + out: os.Stdout, + err: os.Stderr, + }) +} + +func runTests(tio testIO) error { + root, err := currentProjectRoot() + if err != nil { + return err + } + + cfg, err := readConfigFromRoot(root) + if err != nil { + return fmt.Errorf("failed to resolve test directory: %v", err) + } + + testDir, err := resolveTestDirFromConfig(root, cfg) + if err != nil { + return fmt.Errorf("failed to resolve test directory: %v", err) + } + + shellPath, err := resolveRecordShell(testDir) + if err != nil { + return err + } + + scenarios, err := discoverTestScenarios(testDir) + if err != nil { + return err + } + if len(scenarios) == 0 { + return fmt.Errorf("no test scenarios found in %q", testDir) + } + + summary := testSummary{total: len(scenarios)} + for _, scenario := range scenarios { + output.Fprintf(tio.out, "RUN %s\n", scenario.relPath) + + if err := replayScenario(scenario, shellPath, tio, cfg.Sandbox); err != nil { + summary.failed++ + output.Fprintf(tio.out, "FAIL %s: %v\n", scenario.relPath, err) + continue + } + + summary.passed++ + output.Fprintf(tio.out, "PASS %s\n", scenario.relPath) + } + + output.Fprintf( + tio.out, + "Summary: total=%d passed=%d failed=%d\n", + summary.total, + summary.passed, + summary.failed, + ) + + if summary.failed > 0 { + return TestSuiteFailedError{Failed: summary.failed} + } + + return nil +} + +func discoverTestScenarios(testDir string) ([]testScenario, error) { + fixturesByDir := map[string]testFixtureFiles{} + + if err := filepath.WalkDir(testDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + + base := filepath.Base(path) + if base != "in" && base != "out" { + return nil + } + + dir := filepath.Dir(path) + files := fixturesByDir[dir] + if base == "in" { + files.inPath = path + } else { + files.outPath = path + } + fixturesByDir[dir] = files + + return nil + }); err != nil { + return nil, fmt.Errorf("failed to scan test directory %q: %v", testDir, err) + } + + scenarios := make([]testScenario, 0, len(fixturesByDir)) + for dir, files := range fixturesByDir { + relPath, err := filepath.Rel(testDir, dir) + if err != nil { + return nil, fmt.Errorf("failed to resolve scenario path for %q: %v", dir, err) + } + + switch { + case files.inPath == "": + return nil, fmt.Errorf("malformed scenario %q: missing in fixture", relPath) + case files.outPath == "": + return nil, fmt.Errorf("malformed scenario %q: missing out fixture", relPath) + } + + scenarios = append(scenarios, testScenario{ + dir: dir, + relPath: relPath, + inPath: files.inPath, + outPath: files.outPath, + }) + } + + sort.Slice(scenarios, func(i, j int) bool { + return scenarios[i].relPath < scenarios[j].relPath + }) + + return scenarios, nil +} + +func replayScenario(scenario testScenario, shellPath string, tio testIO, sandboxConfig map[string]string) error { + input, err := loadRecordedInput(scenario.inPath) + if err != nil { + return fmt.Errorf("failed to read recorded input: %v", err) + } + + _, rawOut, cleanupFiles, err := newRecordFiles() + if err != nil { + return fmt.Errorf("failed to prepare replay files: %v", err) + } + defer cleanupFiles() + + sandbox, cleanupSandbox, err := newRecordSandbox() + if err != nil { + return fmt.Errorf("failed to prepare replay sandbox: %v", err) + } + defer cleanupSandbox() + + cmd := exec.Command("script", "-q", "-E", "always", "-O", rawOut, "-c", shellPath) + cmd.Dir = scenario.dir + cmd.Env = recordSessionEnvWithExtra(sandbox, sandboxConfig, map[string]string{ + compareMarkerEnvName: compareMarkerEnabledValue, + }) + cmd.Stdin = bytes.NewReader(input) + cmd.Stdout = tio.out + cmd.Stderr = tio.err + + if err := cmd.Run(); err != nil { + return fmt.Errorf("replay failed: %v", err) + } + + got, err := loadRecordedOutput(rawOut) + if err != nil { + return fmt.Errorf("failed to read replay output: %v", err) + } + got, err = trimReplayOutputToMarker(got, shellPath) + if err != nil { + return err + } + + want, err := os.ReadFile(scenario.outPath) + if err != nil { + return fmt.Errorf("failed to read expected output: %v", err) + } + + if !bytes.Equal(got, want) { + return fmt.Errorf("output differed") + } + + return nil +} + +func trimReplayOutputToMarker(data []byte, shellPath string) ([]byte, error) { + idx := bytes.Index(data, []byte(compareOutputMarker)) + if idx == -1 { + return nil, fmt.Errorf( + "missing compare marker %q in replay output; rerun `miro init` or refresh %q", + compareOutputMarker, + shellPath, + ) + } + + data = data[idx+len(compareOutputMarker):] + switch { + case bytes.HasPrefix(data, []byte("\r\n")): + return data[2:], nil + case bytes.HasPrefix(data, []byte("\n")): + return data[1:], nil + default: + return data, nil + } +} diff --git a/internal/miro/test_test.go b/internal/miro/test_test.go new file mode 100644 index 0000000..b3644b0 --- /dev/null +++ b/internal/miro/test_test.go @@ -0,0 +1,185 @@ +package miro + +import ( + "bytes" + "errors" + "path/filepath" + "strings" + "testing" +) + +func TestDiscoverTestScenariosFindsNestedFixturesAndSorts(t *testing.T) { + testDir := filepath.Join(t.TempDir(), "e2e") + writeScenarioFixtures(t, filepath.Join(testDir, "nested", "b"), "echo two\n", "echo two\n") + writeScenarioFixtures(t, filepath.Join(testDir, "a"), "echo one\n", "echo one\n") + writeFile(t, filepath.Join(testDir, "shell.sh"), "#!/bin/sh\n") + writeFile(t, filepath.Join(testDir, "notes.txt"), "ignore me\n") + + got, err := discoverTestScenarios(testDir) + if err != nil { + t.Fatalf("discoverTestScenarios() error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("len(discoverTestScenarios()) = %d, want 2", len(got)) + } + + want := []struct { + relPath string + dir string + }{ + {relPath: "a", dir: filepath.Join(testDir, "a")}, + {relPath: filepath.Join("nested", "b"), dir: filepath.Join(testDir, "nested", "b")}, + } + for i, tc := range want { + if got[i].relPath != tc.relPath { + t.Fatalf("scenario[%d].relPath = %q, want %q", i, got[i].relPath, tc.relPath) + } + if got[i].dir != tc.dir { + t.Fatalf("scenario[%d].dir = %q, want %q", i, got[i].dir, tc.dir) + } + } +} + +func TestDiscoverTestScenariosRejectsMissingOutFixture(t *testing.T) { + testDir := filepath.Join(t.TempDir(), "e2e") + writeFile(t, filepath.Join(testDir, "broken", "in"), "echo broken\n") + + _, err := discoverTestScenarios(testDir) + if err == nil { + t.Fatal("discoverTestScenarios() error = nil, want error") + } + if !strings.Contains(err.Error(), `malformed scenario "broken": missing out fixture`) { + t.Fatalf("discoverTestScenarios() error = %q, want missing out fixture", err.Error()) + } +} + +func TestDiscoverTestScenariosRejectsMissingInFixture(t *testing.T) { + testDir := filepath.Join(t.TempDir(), "e2e") + writeFile(t, filepath.Join(testDir, "broken", "out"), "echo broken\n") + + _, err := discoverTestScenarios(testDir) + if err == nil { + t.Fatal("discoverTestScenarios() error = nil, want error") + } + if !strings.Contains(err.Error(), `malformed scenario "broken": missing in fixture`) { + t.Fatalf("discoverTestScenarios() error = %q, want missing in fixture", err.Error()) + } +} + +func TestReplayScenarioUsesRecordedInputAndStripsScriptWrapper(t *testing.T) { + addFakeRecordDependencies(t, "script") + t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1") + + testDir := filepath.Join(t.TempDir(), "e2e") + shellPath := filepath.Join(testDir, "shell.sh") + mustWriteRecordShell(t, testDir) + scenarioDir := filepath.Join(testDir, "suite", "spec") + 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) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := replayScenario(testScenario{ + dir: scenarioDir, + relPath: filepath.Join("suite", "spec"), + inPath: filepath.Join(scenarioDir, "in"), + outPath: filepath.Join(scenarioDir, "out"), + }, shellPath, testIO{ + out: &stdout, + err: &stderr, + }, defaultSandboxConfig()) + if err != nil { + t.Fatalf("replayScenario() error = %v", err) + } + + if got := readFile(t, capturedInput); got != "echo replay\nexit\n" { + t.Fatalf("captured stdin = %q, want recorded input", got) + } + if got := stdout.String(); !strings.Contains(got, "echo replay\nexit\n") { + t.Fatalf("stdout = %q, want streamed replay output", got) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) { + addFakeRecordDependencies(t, "script") + t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1") + + testDir := filepath.Join(t.TempDir(), "e2e") + shellPath := filepath.Join(testDir, "shell.sh") + writeFile(t, shellPath, "#!/bin/sh\n") + scenarioDir := filepath.Join(testDir, "suite", "spec") + writeScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\nexit\n") + + err := replayScenario(testScenario{ + dir: scenarioDir, + relPath: filepath.Join("suite", "spec"), + inPath: filepath.Join(scenarioDir, "in"), + outPath: filepath.Join(scenarioDir, "out"), + }, shellPath, testIO{ + out: &bytes.Buffer{}, + err: &bytes.Buffer{}, + }, defaultSandboxConfig()) + if err == nil { + t.Fatal("replayScenario() error = nil, want error") + } + if !strings.Contains(err.Error(), "missing compare marker") || !strings.Contains(err.Error(), "rerun `miro init`") { + t.Fatalf("replayScenario() error = %q, want compare marker refresh hint", err.Error()) + } +} + +func TestRunTestsRunsFullSuiteAndSummarizesFailures(t *testing.T) { + addFakeRecordDependencies(t, "script") + t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1") + + root := t.TempDir() + testDir := filepath.Join(root, "e2e") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) + mustWriteRecordShell(t, testDir) + writeScenarioFixtures(t, filepath.Join(testDir, "a"), "echo one\n", "echo one\n") + writeScenarioFixtures(t, filepath.Join(testDir, "b"), "echo two\n", "different output\n") + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := withWorkingDir(t, root, func() error { + return runTests(testIO{ + out: &stdout, + err: &stderr, + }) + }) + + var suiteErr TestSuiteFailedError + if !errors.As(err, &suiteErr) { + t.Fatalf("runTests() error = %v, want TestSuiteFailedError", err) + } + if suiteErr.Failed != 1 { + t.Fatalf("TestSuiteFailedError.Failed = %d, want 1", suiteErr.Failed) + } + + for _, want := range []string{ + "RUN a", + "PASS a", + "RUN b", + "FAIL b: output differed", + "Summary: total=2 passed=1 failed=1", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("stdout = %q, want substring %q", stdout.String(), want) + } + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func writeScenarioFixtures(t *testing.T, dir, in, out string) { + t.Helper() + + writeFile(t, filepath.Join(dir, "in"), in) + writeFile(t, filepath.Join(dir, "out"), out) +} diff --git a/internal/miro/testdir_test.go b/internal/miro/testdir_test.go index b338431..4cf84ae 100644 --- a/internal/miro/testdir_test.go +++ b/internal/miro/testdir_test.go @@ -44,7 +44,7 @@ func TestInitUsesGitRoot(t *testing.T) { assertRecordShell(t, filepath.Join(root, "e2e", recordShellName)) } -func TestInitLeavesExistingValidConfigUntouched(t *testing.T) { +func TestInitLeavesExistingValidConfigUntouchedAndRefreshesShell(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("custom/suite")) writeFile(t, filepath.Join(root, "custom", "suite", recordShellName), "outdated\n") @@ -60,8 +60,8 @@ func TestInitLeavesExistingValidConfigUntouched(t *testing.T) { if got != validConfigContent("custom/suite") { t.Fatalf("config = %q, want %q", got, validConfigContent("custom/suite")) } - if got := readFile(t, filepath.Join(root, "custom", "suite", recordShellName)); got != "outdated\n" { - t.Fatalf("shell = %q, want existing shell to stay untouched", got) + if got := readFile(t, filepath.Join(root, "custom", "suite", recordShellName)); got != buildRecordShellScript() { + t.Fatalf("shell = %q, want refreshed recorder shell", got) } }