diff --git a/cmd/rewrite.go b/cmd/rewrite.go new file mode 100644 index 0000000..9717aca --- /dev/null +++ b/cmd/rewrite.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "mire/internal/mire" + + "github.com/spf13/cobra" +) + +func newRewriteCommand() *cobra.Command { + return &cobra.Command{ + Use: "rewrite [path]", + Short: "Refresh recorded CLI output fixtures", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := "" + if len(args) == 1 { + path = args[0] + } + + if err := mire.Rewrite(path); err != nil { + cmd.SilenceUsage = true + return err + } + + return nil + }, + } +} diff --git a/cmd/root.go b/cmd/root.go index ac2a581..8ee3e6d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -37,7 +37,7 @@ func newRootCommand() *cobra.Command { }, } - rootCmd.AddCommand(newInitCommand(), newRecordCommand(), newTestCommand()) + rootCmd.AddCommand(newInitCommand(), newRecordCommand(), newRewriteCommand(), newTestCommand()) return rootCmd } diff --git a/cmd/root_test.go b/cmd/root_test.go index e2d8557..7f0362d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -24,6 +24,7 @@ func TestRunShowsHelpWhenNoArgs(t *testing.T) { for _, want := range []string{ "init Initialise mire in the current project", "record Record a new CLI scenario", + "rewrite Refresh recorded CLI output fixtures", "test Replay recorded CLI scenarios", } { if !strings.Contains(stdout, want) { @@ -129,6 +130,51 @@ func TestRunRecord(t *testing.T) { }) } +func TestRunRewrite(t *testing.T) { + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") + + root := t.TempDir() + testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "suite", "spec"), "echo rewrite\nexit\n", "stale output\n") + + testutil.WithWorkingDir(t, root, func() struct{} { + initStdout, initStderr := testutil.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 := testutil.CaptureOutput(t, func() { + if got := Run([]string{"rewrite"}); got != 0 { + t.Fatalf("Run() code = %d, want %d", got, 0) + } + }) + + for _, want := range []string{ + "RUN suite/spec", + "PASS suite/spec (", + "Summary: total=1 passed=1 failed=0", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("stdout = %q, want substring %q", stdout, want) + } + } + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + return struct{}{} + }) + + if got := testutil.ReadFile(t, filepath.Join(root, "e2e", "suite", "spec", "out")); got == "stale output\n" { + t.Fatalf("rewrite left stale output unchanged") + } +} + func TestRunInitFailsWhenDependenciesMissing(t *testing.T) { root := t.TempDir() t.Setenv("PATH", "") diff --git a/internal/mire/replay.go b/internal/mire/replay.go new file mode 100644 index 0000000..5fc07bb --- /dev/null +++ b/internal/mire/replay.go @@ -0,0 +1,211 @@ +package mire + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "sync" + "time" + + "mire/internal/script" +) + +type replaySuite struct { + shellPath string + scenarios []testScenario + sandboxConfig map[string]string + mounts []string + paths []string +} + +func loadReplaySuite(path string) (replaySuite, error) { + root, err := currentProjectRoot() + if err != nil { + return replaySuite{}, err + } + + cfg, err := readConfigFromRoot(root) + if err != nil { + return replaySuite{}, fmt.Errorf("failed to resolve test directory: %v", err) + } + + testDir, err := resolveTestDirFromConfig(root, cfg) + if err != nil { + return replaySuite{}, fmt.Errorf("failed to resolve test directory: %v", err) + } + + discoveryRoot, err := resolveTestDiscoveryRoot(testDir, path) + if err != nil { + return replaySuite{}, err + } + + shellPath, err := resolveRecordShell(testDir) + if err != nil { + return replaySuite{}, err + } + + scenarios, err := discoverTestScenarios(discoveryRoot, testDir) + if err != nil { + return replaySuite{}, err + } + if len(scenarios) == 0 { + return replaySuite{}, fmt.Errorf("no test scenarios found in %q", discoveryRoot) + } + + return replaySuite{ + shellPath: shellPath, + scenarios: scenarios, + sandboxConfig: cfg.Sandbox, + mounts: cfg.Mounts, + paths: cfg.Paths, + }, nil +} + +func replayScenarioOutput(scenario testScenario, shellPath string, sandboxConfig map[string]string, mounts, paths []string) ([]byte, error) { + input, err := loadRecordedInput(scenario.inPath) + if err != nil { + return nil, fmt.Errorf("failed to read recorded input: %v", err) + } + + _, rawOut, cleanupFiles, err := newRecordFiles() + if err != nil { + return nil, fmt.Errorf("failed to prepare replay files: %v", err) + } + defer cleanupFiles() + + sandbox, cleanupSandbox, err := newRecordSandbox() + if err != nil { + return nil, fmt.Errorf("failed to prepare replay sandbox: %v", err) + } + defer cleanupSandbox() + + rawOutFile, err := os.Create(rawOut) + if err != nil { + return nil, fmt.Errorf("failed to prepare replay output: %v", err) + } + + cmd := exec.Command(shellPath) + cmd.Dir = scenario.dir + cmd.Env = recordSessionEnvWithExtra(sandbox, sandboxConfig, mounts, paths, scenario.setupScripts, map[string]string{ + compareMarkerEnvName: compareMarkerEnabledValue, + }) + + ready := make(chan struct{}) + promptWriter := newReplayPromptWriter(rawOutFile, replayPromptReadyMarker, ready) + promptTimeout := time.AfterFunc(replayPromptReadyTimeout, promptWriter.release) + defer promptTimeout.Stop() + + replayResult := script.Replay(script.ReplayRequest{ + Cmd: cmd, + Input: input, + InputReady: ready, + OutputLog: promptWriter, + }) + if err := rawOutFile.Close(); err != nil { + return nil, fmt.Errorf("failed to close replay output: %v", err) + } + if !promptWriter.seen { + if err := replayResult.Err(); err != nil { + return nil, fmt.Errorf("replay failed: %v", err) + } + return nil, fmt.Errorf("replay shell never emitted %q; rerun `mire init` or refresh %q", compareOutputMarker, shellPath) + } + if replayResult.OutputErr != nil { + return nil, fmt.Errorf("replay failed: %v", replayResult.OutputErr) + } + if replayResult.InputErr != nil { + return nil, fmt.Errorf("replay failed: %v", replayResult.InputErr) + } + + got, err := loadRecordedOutput(rawOut) + if err != nil { + return nil, fmt.Errorf("failed to read replay output: %v", err) + } + + got, err = trimReplayOutputToMarker(got, shellPath) + if err != nil { + return nil, err + } + + return got, nil +} + +func trimReplayOutputToMarker(data []byte, shellPath string) ([]byte, error) { + idx := bytes.Index(data, []byte(compareOutputMarker)) + if idx == -1 { + return nil, fmt.Errorf( + "missing replay start marker %q in replay output; rerun `mire 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 + } +} + +type replayPromptWriter struct { + dst io.Writer + marker []byte + tail []byte + ready chan struct{} + once sync.Once + seen bool +} + +func newReplayPromptWriter(dst io.Writer, marker string, ready chan struct{}) *replayPromptWriter { + return &replayPromptWriter{ + dst: dst, + marker: []byte(marker), + ready: ready, + } +} + +func (w *replayPromptWriter) Write(p []byte) (int, error) { + n, err := w.dst.Write(p) + if n > 0 { + w.observe(p[:n]) + } + return n, err +} + +func (w *replayPromptWriter) observe(p []byte) { + if w.seen || len(w.marker) == 0 { + return + } + + data := make([]byte, 0, len(w.tail)+len(p)) + data = append(data, w.tail...) + data = append(data, p...) + + if bytes.Contains(data, w.marker) { + w.seen = true + w.release() + } + + keep := len(w.marker) - 1 + if keep <= 0 { + return + } + if len(data) <= keep { + w.tail = append(w.tail[:0], data...) + return + } + + w.tail = append(w.tail[:0], data[len(data)-keep:]...) +} + +func (w *replayPromptWriter) release() { + w.once.Do(func() { + close(w.ready) + }) +} diff --git a/internal/mire/rewrite.go b/internal/mire/rewrite.go new file mode 100644 index 0000000..ff85aa5 --- /dev/null +++ b/internal/mire/rewrite.go @@ -0,0 +1,57 @@ +package mire + +import ( + "fmt" + "os" + "time" + + "mire/internal/output" +) + +// Rewrite replays scenarios and refreshes their recorded output fixtures. +func Rewrite(path string) error { + return rewrite(path, testIO{ + out: os.Stdout, + err: os.Stderr, + }) +} + +func rewrite(path string, tio testIO) error { + suite, err := loadReplaySuite(path) + if err != nil { + return err + } + + summary := testSummary{total: len(suite.scenarios)} + for _, scenario := range suite.scenarios { + output.Fprintf(tio.out, "%s %s\n", output.Label("RUN", output.Info), scenario.relPath) + + start := time.Now() + got, err := replayScenarioOutput(scenario, suite.shellPath, suite.sandboxConfig, suite.mounts, suite.paths) + if err != nil { + elapsed := time.Since(start) + summary.failed++ + output.Fprintf(tio.out, "%s %s (%s): %v\n", output.Label("FAIL", output.Fail), scenario.relPath, formatElapsed(elapsed), err) + continue + } + + if err := os.WriteFile(scenario.outPath, got, 0o644); err != nil { + elapsed := time.Since(start) + summary.failed++ + output.Fprintf(tio.out, "%s %s (%s): failed to write expected output: %v\n", output.Label("FAIL", output.Fail), scenario.relPath, formatElapsed(elapsed), err) + continue + } + + elapsed := time.Since(start) + summary.passed++ + output.Fprintf(tio.out, "%s %s (%s)\n", output.Label("PASS", output.Pass), scenario.relPath, formatElapsed(elapsed)) + } + + writeScenarioSummary(tio.out, summary) + + if summary.failed > 0 { + return fmt.Errorf("%d scenario(s) failed to rewrite", summary.failed) + } + + return nil +} diff --git a/internal/mire/rewrite_test.go b/internal/mire/rewrite_test.go new file mode 100644 index 0000000..4e59a45 --- /dev/null +++ b/internal/mire/rewrite_test.go @@ -0,0 +1,91 @@ +package mire + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "mire/internal/testutil" +) + +func TestRewriteScopedRunOnlyUpdatesSelectedSubtree(t *testing.T) { + testutil.AddFakeRecordDependencies(t, "bwrap", "bash") + + root := t.TempDir() + testDir := filepath.Join(root, "e2e") + testutil.WriteValidConfig(t, filepath.Join(root, "mire.toml"), "e2e") + mustWriteRecordShell(t, testDir) + testutil.WriteScenarioFixtures(t, filepath.Join(testDir, "a"), "echo root\nexit\n", "stale root\n") + testutil.WriteScenarioFixtures(t, filepath.Join(testDir, "nested", "b"), "echo nested\nexit\n", "stale nested\n") + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := testutil.WithWorkingDir(t, root, func() error { + return rewrite("nested", testIO{ + out: &stdout, + err: &stderr, + }) + }) + if err != nil { + t.Fatalf("rewrite() error = %v", err) + } + + if strings.Contains(stdout.String(), "RUN a") { + t.Fatalf("stdout = %q, want scoped rewrite to skip root scenario", stdout.String()) + } + if got := testutil.ReadFile(t, filepath.Join(testDir, "a", "out")); got != "stale root\n" { + t.Fatalf("root scenario out = %q, want unchanged stale output", got) + } + if got := testutil.ReadFile(t, filepath.Join(testDir, "nested", "b", "out")); got == "stale nested\n" { + t.Fatalf("nested scenario out = %q, want rewritten output", got) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRewriteContinuesAfterFailureAndReturnsError(t *testing.T) { + testutil.RequireCommands(t, "bwrap", "bash") + + root := t.TempDir() + testDir := filepath.Join(root, "e2e") + testutil.WriteValidConfig(t, filepath.Join(root, "mire.toml"), "e2e") + mustWriteRecordShell(t, testDir) + testutil.WriteScenarioFixtures(t, filepath.Join(testDir, "bad"), "echo bad\nexit\n", "stale bad\n") + testutil.WriteFile(t, filepath.Join(testDir, "bad", setupScriptName), "exit 1\n") + testutil.WriteScenarioFixtures(t, filepath.Join(testDir, "ok"), "echo ok\nexit\n", "stale ok\n") + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := testutil.WithWorkingDir(t, root, func() error { + return rewrite("", testIO{ + out: &stdout, + err: &stderr, + }) + }) + if err == nil { + t.Fatal("rewrite() error = nil, want failure summary error") + } + if !strings.Contains(err.Error(), "1 scenario(s) failed to rewrite") { + t.Fatalf("rewrite() error = %q, want failed rewrite summary", err.Error()) + } + for _, want := range []string{ + "FAIL bad (", + "PASS ok (", + "Summary: total=2 passed=1 failed=1", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("stdout = %q, want substring %q", stdout.String(), want) + } + } + if got := testutil.ReadFile(t, filepath.Join(testDir, "bad", "out")); got != "stale bad\n" { + t.Fatalf("bad scenario out = %q, want unchanged stale output", got) + } + if got := testutil.ReadFile(t, filepath.Join(testDir, "ok", "out")); got == "stale ok\n" { + t.Fatalf("ok scenario out = %q, want rewritten output", got) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} diff --git a/internal/mire/test.go b/internal/mire/test.go index cf73f96..5ef28ca 100644 --- a/internal/mire/test.go +++ b/internal/mire/test.go @@ -7,14 +7,11 @@ import ( "io" "io/fs" "os" - "os/exec" "path/filepath" "sort" - "sync" "time" "mire/internal/output" - "mire/internal/script" ) type testIO struct { @@ -69,45 +66,17 @@ func RunTests(path string) error { } func runTests(path string, tio testIO) error { - root, err := currentProjectRoot() + suite, err := loadReplaySuite(path) 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) - } - - discoveryRoot, err := resolveTestDiscoveryRoot(testDir, path) - if err != nil { - return err - } - - shellPath, err := resolveRecordShell(testDir) - if err != nil { - return err - } - - scenarios, err := discoverTestScenarios(discoveryRoot, testDir) - if err != nil { - return err - } - if len(scenarios) == 0 { - return fmt.Errorf("no test scenarios found in %q", discoveryRoot) - } - - summary := testSummary{total: len(scenarios)} - for _, scenario := range scenarios { + summary := testSummary{total: len(suite.scenarios)} + for _, scenario := range suite.scenarios { output.Fprintf(tio.out, "%s %s\n", output.Label("RUN", output.Info), scenario.relPath) start := time.Now() - if err := replayScenario(scenario, shellPath, tio, cfg.Sandbox, cfg.Mounts, cfg.Paths); err != nil { + if err := replayScenario(scenario, suite.shellPath, suite.sandboxConfig, suite.mounts, suite.paths); err != nil { elapsed := time.Since(start) summary.failed++ output.Fprintf(tio.out, "%s %s (%s): %v\n", output.Label("FAIL", output.Fail), scenario.relPath, formatElapsed(elapsed), err) @@ -123,21 +92,25 @@ func runTests(path string, tio testIO) error { output.Fprintf(tio.out, "%s %s (%s)\n", output.Label("PASS", output.Pass), scenario.relPath, formatElapsed(elapsed)) } + writeScenarioSummary(tio.out, summary) + + return nil +} + +func writeScenarioSummary(w io.Writer, summary testSummary) { summaryColor := output.Pass if summary.failed > 0 { summaryColor = output.Fail } output.Fprintf( - tio.out, + w, "%s\n", output.Label( fmt.Sprintf("Summary: total=%d passed=%d failed=%d", summary.total, summary.passed, summary.failed), summaryColor, ), ) - - return nil } func formatElapsed(elapsed time.Duration) string { @@ -236,67 +209,8 @@ func discoverTestScenarios(discoveryRoot, displayRoot string) ([]testScenario, e return scenarios, nil } -func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxConfig map[string]string, mounts, paths []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() - - rawOutFile, err := os.Create(rawOut) - if err != nil { - return fmt.Errorf("failed to prepare replay output: %v", err) - } - - cmd := exec.Command(shellPath) - cmd.Dir = scenario.dir - cmd.Env = recordSessionEnvWithExtra(sandbox, sandboxConfig, mounts, paths, scenario.setupScripts, map[string]string{ - compareMarkerEnvName: compareMarkerEnabledValue, - }) - - ready := make(chan struct{}) - promptWriter := newReplayPromptWriter(rawOutFile, replayPromptReadyMarker, ready) - promptTimeout := time.AfterFunc(replayPromptReadyTimeout, promptWriter.release) - defer promptTimeout.Stop() - - replayResult := script.Replay(script.ReplayRequest{ - Cmd: cmd, - Input: input, - InputReady: ready, - OutputLog: promptWriter, - }) - if err := rawOutFile.Close(); err != nil { - return fmt.Errorf("failed to close replay output: %v", err) - } - if !promptWriter.seen { - if err := replayResult.Err(); err != nil { - return fmt.Errorf("replay failed: %v", err) - } - return fmt.Errorf("replay shell never emitted %q; rerun `mire init` or refresh %q", compareOutputMarker, shellPath) - } - if replayResult.OutputErr != nil { - return fmt.Errorf("replay failed: %v", replayResult.OutputErr) - } - if replayResult.InputErr != nil { - return fmt.Errorf("replay failed: %v", replayResult.InputErr) - } - - got, err := loadRecordedOutput(rawOut) - if err != nil { - return fmt.Errorf("failed to read replay output: %v", err) - } - got, err = trimReplayOutputToMarker(got, shellPath) +func replayScenario(scenario testScenario, shellPath string, sandboxConfig map[string]string, mounts, paths []string) error { + got, err := replayScenarioOutput(scenario, shellPath, sandboxConfig, mounts, paths) if err != nil { return err } @@ -317,81 +231,3 @@ func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxCo return nil } - -func trimReplayOutputToMarker(data []byte, shellPath string) ([]byte, error) { - idx := bytes.Index(data, []byte(compareOutputMarker)) - if idx == -1 { - return nil, fmt.Errorf( - "missing replay start marker %q in replay output; rerun `mire 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 - } -} - -type replayPromptWriter struct { - dst io.Writer - marker []byte - tail []byte - ready chan struct{} - once sync.Once - seen bool -} - -func newReplayPromptWriter(dst io.Writer, marker string, ready chan struct{}) *replayPromptWriter { - return &replayPromptWriter{ - dst: dst, - marker: []byte(marker), - ready: ready, - } -} - -func (w *replayPromptWriter) Write(p []byte) (int, error) { - n, err := w.dst.Write(p) - if n > 0 { - w.observe(p[:n]) - } - return n, err -} - -func (w *replayPromptWriter) observe(p []byte) { - if w.seen || len(w.marker) == 0 { - return - } - - data := make([]byte, 0, len(w.tail)+len(p)) - data = append(data, w.tail...) - data = append(data, p...) - - if bytes.Contains(data, w.marker) { - w.seen = true - w.release() - } - - keep := len(w.marker) - 1 - if keep <= 0 { - return - } - if len(data) <= keep { - w.tail = append(w.tail[:0], data...) - return - } - - w.tail = append(w.tail[:0], data[len(data)-keep:]...) -} - -func (w *replayPromptWriter) release() { - w.once.Do(func() { - close(w.ready) - }) -} diff --git a/internal/mire/test_test.go b/internal/mire/test_test.go index 71a02d4..44bb4f3 100644 --- a/internal/mire/test_test.go +++ b/internal/mire/test_test.go @@ -99,28 +99,16 @@ func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) scenarioDir := filepath.Join(testDir, "suite", "spec") testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\r\nexit\r\n") - 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"), setupScripts: nil, - }, shellPath, testIO{ - out: &stdout, - err: &stderr, - }, defaultSandboxConfig(), nil, nil) + }, shellPath, defaultSandboxConfig(), nil, nil) if err != nil { t.Fatalf("replayScenario() error = %v", err) } - - if stdout.String() != "" { - t.Fatalf("stdout = %q, want empty", stdout.String()) - } - if stderr.String() != "" { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } } func TestReplayScenarioWaitsForPromptReadyMarkerBeforeSendingInput(t *testing.T) { @@ -143,10 +131,7 @@ func TestReplayScenarioWaitsForPromptReadyMarkerBeforeSendingInput(t *testing.T) inPath: filepath.Join(scenarioDir, "in"), outPath: filepath.Join(scenarioDir, "out"), setupScripts: nil, - }, shellPath, testIO{ - out: &bytes.Buffer{}, - err: &bytes.Buffer{}, - }, defaultSandboxConfig(), nil, nil) + }, shellPath, defaultSandboxConfig(), nil, nil) if err != nil { t.Fatalf("replayScenario() error = %v", err) } @@ -168,10 +153,7 @@ func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) { inPath: filepath.Join(scenarioDir, "in"), outPath: filepath.Join(scenarioDir, "out"), setupScripts: nil, - }, shellPath, testIO{ - out: &bytes.Buffer{}, - err: &bytes.Buffer{}, - }, defaultSandboxConfig(), nil, nil) + }, shellPath, defaultSandboxConfig(), nil, nil) if err == nil { t.Fatal("replayScenario() error = nil, want error") }