Files
mire/internal/miro/test.go
2026-03-21 09:00:14 +00:00

279 lines
6.2 KiB
Go

package miro
import (
"bytes"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"time"
"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
}
// RunTests replays all scenarios under the configured test directory.
func RunTests(path string) error {
return runTests(path, testIO{
out: os.Stdout,
err: os.Stderr,
})
}
func runTests(path string, 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)
}
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)
start := time.Now()
if err := replayScenario(scenario, shellPath, tio, cfg.Sandbox); 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
}
elapsed := time.Since(start)
summary.passed++
output.Fprintf(tio.out, "%s %s (%s)\n", output.Label("PASS", output.Pass), scenario.relPath, formatElapsed(elapsed))
}
summaryColor := output.Pass
if summary.failed > 0 {
summaryColor = output.Fail
}
output.Fprintf(
tio.out,
"%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 {
if elapsed < time.Second {
return fmt.Sprintf("%d ms", elapsed/time.Millisecond)
}
return fmt.Sprintf("%.2f s", elapsed.Seconds())
}
func resolveTestDiscoveryRoot(testDir, path string) (string, error) {
if path == "" {
return testDir, nil
}
target, err := resolvePathWithinTestDir(testDir, path, "test")
if err != nil {
return "", err
}
info, err := os.Stat(target)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("test path %q does not exist", target)
}
return "", fmt.Errorf("failed to check test path %q: %v", target, err)
}
if !info.IsDir() {
return "", fmt.Errorf("test path %q is not a directory", target)
}
return target, nil
}
func discoverTestScenarios(discoveryRoot, displayRoot string) ([]testScenario, error) {
fixturesByDir := map[string]testFixtureFiles{}
if err := filepath.WalkDir(discoveryRoot, 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", discoveryRoot, err)
}
scenarios := make([]testScenario, 0, len(fixturesByDir))
for dir, files := range fixturesByDir {
relPath, err := filepath.Rel(displayRoot, 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, _ 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 = io.Discard
cmd.Stderr = io.Discard
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
}
}