feat: add cmd mire rewrite to rewrite all goldens from input again
This commit is contained in:
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -37,7 +37,7 @@ func newRootCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
rootCmd.AddCommand(newInitCommand(), newRecordCommand(), newTestCommand())
|
rootCmd.AddCommand(newInitCommand(), newRecordCommand(), newRewriteCommand(), newTestCommand())
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func TestRunShowsHelpWhenNoArgs(t *testing.T) {
|
|||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"init Initialise mire in the current project",
|
"init Initialise mire in the current project",
|
||||||
"record Record a new CLI scenario",
|
"record Record a new CLI scenario",
|
||||||
|
"rewrite Refresh recorded CLI output fixtures",
|
||||||
"test Replay recorded CLI scenarios",
|
"test Replay recorded CLI scenarios",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(stdout, want) {
|
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) {
|
func TestRunInitFailsWhenDependenciesMissing(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
t.Setenv("PATH", "")
|
t.Setenv("PATH", "")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-177
@@ -7,14 +7,11 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mire/internal/output"
|
"mire/internal/output"
|
||||||
"mire/internal/script"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type testIO struct {
|
type testIO struct {
|
||||||
@@ -69,45 +66,17 @@ func RunTests(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runTests(path string, tio testIO) error {
|
func runTests(path string, tio testIO) error {
|
||||||
root, err := currentProjectRoot()
|
suite, err := loadReplaySuite(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := readConfigFromRoot(root)
|
summary := testSummary{total: len(suite.scenarios)}
|
||||||
if err != nil {
|
for _, scenario := range suite.scenarios {
|
||||||
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 {
|
|
||||||
output.Fprintf(tio.out, "%s %s\n", output.Label("RUN", output.Info), scenario.relPath)
|
output.Fprintf(tio.out, "%s %s\n", output.Label("RUN", output.Info), scenario.relPath)
|
||||||
|
|
||||||
start := time.Now()
|
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)
|
elapsed := time.Since(start)
|
||||||
summary.failed++
|
summary.failed++
|
||||||
output.Fprintf(tio.out, "%s %s (%s): %v\n", output.Label("FAIL", output.Fail), scenario.relPath, formatElapsed(elapsed), err)
|
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))
|
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
|
summaryColor := output.Pass
|
||||||
if summary.failed > 0 {
|
if summary.failed > 0 {
|
||||||
summaryColor = output.Fail
|
summaryColor = output.Fail
|
||||||
}
|
}
|
||||||
|
|
||||||
output.Fprintf(
|
output.Fprintf(
|
||||||
tio.out,
|
w,
|
||||||
"%s\n",
|
"%s\n",
|
||||||
output.Label(
|
output.Label(
|
||||||
fmt.Sprintf("Summary: total=%d passed=%d failed=%d", summary.total, summary.passed, summary.failed),
|
fmt.Sprintf("Summary: total=%d passed=%d failed=%d", summary.total, summary.passed, summary.failed),
|
||||||
summaryColor,
|
summaryColor,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatElapsed(elapsed time.Duration) string {
|
func formatElapsed(elapsed time.Duration) string {
|
||||||
@@ -236,67 +209,8 @@ func discoverTestScenarios(discoveryRoot, displayRoot string) ([]testScenario, e
|
|||||||
return scenarios, nil
|
return scenarios, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxConfig map[string]string, mounts, paths []string) error {
|
func replayScenario(scenario testScenario, shellPath string, sandboxConfig map[string]string, mounts, paths []string) error {
|
||||||
input, err := loadRecordedInput(scenario.inPath)
|
got, err := replayScenarioOutput(scenario, shellPath, sandboxConfig, mounts, paths)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -317,81 +231,3 @@ func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxCo
|
|||||||
|
|
||||||
return nil
|
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -99,28 +99,16 @@ func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T)
|
|||||||
scenarioDir := filepath.Join(testDir, "suite", "spec")
|
scenarioDir := filepath.Join(testDir, "suite", "spec")
|
||||||
testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\r\nexit\r\n")
|
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{
|
err := replayScenario(testScenario{
|
||||||
dir: scenarioDir,
|
dir: scenarioDir,
|
||||||
relPath: filepath.Join("suite", "spec"),
|
relPath: filepath.Join("suite", "spec"),
|
||||||
inPath: filepath.Join(scenarioDir, "in"),
|
inPath: filepath.Join(scenarioDir, "in"),
|
||||||
outPath: filepath.Join(scenarioDir, "out"),
|
outPath: filepath.Join(scenarioDir, "out"),
|
||||||
setupScripts: nil,
|
setupScripts: nil,
|
||||||
}, shellPath, testIO{
|
}, shellPath, defaultSandboxConfig(), nil, nil)
|
||||||
out: &stdout,
|
|
||||||
err: &stderr,
|
|
||||||
}, defaultSandboxConfig(), nil, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("replayScenario() error = %v", err)
|
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) {
|
func TestReplayScenarioWaitsForPromptReadyMarkerBeforeSendingInput(t *testing.T) {
|
||||||
@@ -143,10 +131,7 @@ func TestReplayScenarioWaitsForPromptReadyMarkerBeforeSendingInput(t *testing.T)
|
|||||||
inPath: filepath.Join(scenarioDir, "in"),
|
inPath: filepath.Join(scenarioDir, "in"),
|
||||||
outPath: filepath.Join(scenarioDir, "out"),
|
outPath: filepath.Join(scenarioDir, "out"),
|
||||||
setupScripts: nil,
|
setupScripts: nil,
|
||||||
}, shellPath, testIO{
|
}, shellPath, defaultSandboxConfig(), nil, nil)
|
||||||
out: &bytes.Buffer{},
|
|
||||||
err: &bytes.Buffer{},
|
|
||||||
}, defaultSandboxConfig(), nil, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("replayScenario() error = %v", err)
|
t.Fatalf("replayScenario() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -168,10 +153,7 @@ func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) {
|
|||||||
inPath: filepath.Join(scenarioDir, "in"),
|
inPath: filepath.Join(scenarioDir, "in"),
|
||||||
outPath: filepath.Join(scenarioDir, "out"),
|
outPath: filepath.Join(scenarioDir, "out"),
|
||||||
setupScripts: nil,
|
setupScripts: nil,
|
||||||
}, shellPath, testIO{
|
}, shellPath, defaultSandboxConfig(), nil, nil)
|
||||||
out: &bytes.Buffer{},
|
|
||||||
err: &bytes.Buffer{},
|
|
||||||
}, defaultSandboxConfig(), nil, nil)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("replayScenario() error = nil, want error")
|
t.Fatal("replayScenario() error = nil, want error")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user