feat: miro test command

This commit is contained in:
2026-03-19 20:15:11 +00:00
parent 5ebca22225
commit e5b767e35f
12 changed files with 942 additions and 16 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ func newRootCommand() *cobra.Command {
},
}
rootCmd.AddCommand(newInitCommand(), newRecordCommand())
rootCmd.AddCommand(newInitCommand(), newRecordCommand(), newTestCommand())
return rootCmd
}
+294 -2
View File
@@ -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)
+17
View File
@@ -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()
},
}
}
+1 -1
View File
@@ -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 {
+15 -2
View File
@@ -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
}
+35
View File
@@ -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")
}
}
+21 -6
View File
@@ -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)
+4
View File
@@ -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 \
+123 -1
View File
@@ -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)
+243
View File
@@ -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
}
}
+185
View File
@@ -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)
}
+3 -3
View File
@@ -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)
}
}