rename: rebrand to mire

This commit is contained in:
2026-03-21 12:49:57 +00:00
parent d72f727322
commit 511a4c912c
32 changed files with 209 additions and 209 deletions
+11
View File
@@ -0,0 +1,11 @@
package mire
var (
ErrRecordingDiscarded = RecordingDiscardedError{}
)
type RecordingDiscardedError struct{}
func (RecordingDiscardedError) Error() string {
return "recording discarded"
}
+43
View File
@@ -0,0 +1,43 @@
package mire
import (
"errors"
"fmt"
"os"
"path/filepath"
mireconfig "mire/internal/config"
)
const defaultTestDir = "e2e"
// Init creates the default config when missing and refreshes the recorder shell.
func Init() error {
root, err := currentProjectRoot()
if err != nil {
return err
}
configPath := filepath.Join(root, "mire.toml")
if _, err := os.Stat(configPath); err == nil {
if _, err := mireconfig.ReadConfig(configPath); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to check %s: %v", configPath, err)
} else {
if err := mireconfig.WriteConfig(configPath, mireconfig.Config{
TestDir: defaultTestDir,
Sandbox: mireconfig.DefaultSandboxConfig(),
}); err != nil {
return fmt.Errorf("failed to write %s: %v", configPath, err)
}
}
testDir, err := resolveTestDirFromRoot(root)
if err != nil {
return err
}
return ensureRecordShell(testDir)
}
+57
View File
@@ -0,0 +1,57 @@
package mire
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func resolveRecordTarget(testDir, path string) (string, error) {
return resolvePathWithinTestDir(testDir, path, "record")
}
func resolvePathWithinTestDir(testDir, path, pathType string) (string, error) {
absTestDir, err := filepath.Abs(testDir)
if err != nil {
return "", fmt.Errorf("failed to resolve test directory path: %v", err)
}
ensureWithinTestDir := func(candidate string) (string, bool) {
relToTestDir, relErr := filepath.Rel(absTestDir, candidate)
if relErr != nil || !isWithinBase(relToTestDir) {
return "", false
}
return candidate, true
}
if filepath.IsAbs(path) {
absPath := filepath.Clean(path)
if candidate, ok := ensureWithinTestDir(absPath); ok {
return candidate, nil
}
return "", fmt.Errorf("%s path %q must be inside test directory %q", pathType, path, absTestDir)
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to resolve %s path: %v", pathType, err)
}
if candidate, ok := ensureWithinTestDir(absPath); ok {
return candidate, nil
}
candidate := filepath.Clean(filepath.Join(absTestDir, path))
if candidate, ok := ensureWithinTestDir(candidate); ok {
return candidate, nil
}
return "", fmt.Errorf("%s path %q must be inside test directory %q", pathType, path, absTestDir)
}
func isWithinBase(rel string) bool {
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
}
+131
View File
@@ -0,0 +1,131 @@
package mire
import (
"os"
"path/filepath"
"strings"
"testing"
"mire/internal/testutil"
)
func TestResolvePathWithinTestDirAcceptsRelativePath(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
got := testutil.WithWorkingDir(t, root, func() string {
path, err := resolvePathWithinTestDir(testDir, filepath.Join("suite", "spec"), "record")
if err != nil {
t.Fatalf("resolvePathWithinTestDir() error = %v", err)
}
return path
})
want := filepath.Join(testDir, "suite", "spec")
if got != want {
t.Fatalf("resolvePathWithinTestDir() = %q, want %q", got, want)
}
}
func TestResolvePathWithinTestDirAcceptsExplicitTestDirPrefix(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
got := testutil.WithWorkingDir(t, root, func() string {
path, err := resolvePathWithinTestDir(testDir, filepath.Join("e2e", "suite", "spec"), "record")
if err != nil {
t.Fatalf("resolvePathWithinTestDir() error = %v", err)
}
return path
})
want := filepath.Join(testDir, "suite", "spec")
if got != want {
t.Fatalf("resolvePathWithinTestDir() = %q, want %q", got, want)
}
}
func TestResolvePathWithinTestDirAcceptsAbsolutePathInsideTestDir(t *testing.T) {
testDir := filepath.Join(t.TempDir(), "e2e")
want := filepath.Join(testDir, "suite", "spec")
got, err := resolvePathWithinTestDir(testDir, want, "record")
if err != nil {
t.Fatalf("resolvePathWithinTestDir() error = %v", err)
}
if got != want {
t.Fatalf("resolvePathWithinTestDir() = %q, want %q", got, want)
}
}
func TestResolvePathWithinTestDirRejectsAbsolutePathOutsideTestDir(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
outside := filepath.Join(root, "outside", "suite", "spec")
_, err := resolvePathWithinTestDir(testDir, outside, "record")
if err == nil {
t.Fatal("resolvePathWithinTestDir() error = nil, want error")
}
if !strings.Contains(err.Error(), "must be inside test directory") {
t.Fatalf("resolvePathWithinTestDir() error = %q, want inside test directory error", err.Error())
}
}
func TestResolvePathWithinTestDirRejectsRelativePathOutsideTestDir(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
_, err := resolvePathWithinTestDir(testDir, filepath.Join("..", "outside"), "record")
if err == nil {
t.Fatal("resolvePathWithinTestDir() error = nil, want error")
}
if !strings.Contains(err.Error(), "must be inside test directory") {
t.Fatalf("resolvePathWithinTestDir() error = %q, want inside test directory error", err.Error())
}
}
func TestResolvePathWithinTestDirAllowsPathCurrentDirectory(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
testutil.MustMkdirAll(t, testDir)
got := testutil.WithWorkingDir(t, testDir, func() string {
path, err := resolvePathWithinTestDir(testDir, ".", "record")
if err != nil {
t.Fatalf("resolvePathWithinTestDir() error = %v", err)
}
return path
})
want, err := filepath.Abs(testDir)
if err != nil {
t.Fatalf("Abs(%q) error = %v", testDir, err)
}
if got != want {
t.Fatalf("resolvePathWithinTestDir() = %q, want %q", got, want)
}
}
func TestIsWithinBase(t *testing.T) {
tests := []struct {
name string
rel string
want bool
}{
{name: "dot", rel: ".", want: true},
{name: "nested", rel: filepath.Join("a", "b"), want: true},
{name: "parent", rel: "..", want: false},
{name: "outside", rel: filepath.Join("..", "a"), want: false},
{name: "same-prefix", rel: ".." + string(os.PathSeparator) + "a", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isWithinBase(tt.rel); got != tt.want {
t.Fatalf("isWithinBase(%q) = %v, want %v", tt.rel, got, tt.want)
}
})
}
}
+122
View File
@@ -0,0 +1,122 @@
package mire
import (
"bytes"
"fmt"
"os"
)
var (
scriptStartPrefix = []byte("Script started on ")
scriptDonePrefix = []byte("Script done on ")
)
const eofByte = byte(0x04)
func loadRecordedInput(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if hasScriptWrapper(data) {
data, err = stripScriptWrapper(data)
if err != nil {
return nil, err
}
}
return trimTrailingReplayNewline(data), nil
}
func loadRecordedOutput(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return stripScriptWrapper(data)
}
func hasScriptWrapper(data []byte) bool {
if bytes.HasPrefix(data, scriptStartPrefix) {
return true
}
_, state := findScriptFooter(data)
return state != footerMissing
}
func stripScriptWrapper(data []byte) ([]byte, error) {
hasHeader := bytes.HasPrefix(data, scriptStartPrefix)
headerEnd := bytes.IndexByte(data, '\n')
if hasHeader && headerEnd == -1 {
return nil, fmt.Errorf("could not parse script wrapper from recorded output: incomplete header line")
}
body := data
if hasHeader {
body = data[headerEnd+1:]
}
footerStart, footerState := findScriptFooter(body)
switch {
case hasHeader && footerState == footerMissing:
return nil, fmt.Errorf("could not parse script wrapper from recorded output: missing footer line")
case !hasHeader && footerState == footerFound:
return nil, fmt.Errorf("could not parse script wrapper from recorded output: missing header line")
case !hasHeader && footerState == footerMissing:
return nil, fmt.Errorf("could not parse script wrapper from recorded output: missing header and footer lines")
case footerState == footerMalformed:
return nil, fmt.Errorf("could not parse script wrapper from recorded output: incomplete footer line")
}
return body[:footerStart], nil
}
type footerMatchState int
const (
footerMissing footerMatchState = iota
footerMalformed
footerFound
)
func findScriptFooter(data []byte) (int, footerMatchState) {
candidate := -1
switch {
case bytes.HasPrefix(data, scriptDonePrefix):
candidate = 0
default:
idx := bytes.LastIndex(data, append([]byte{'\n'}, scriptDonePrefix...))
if idx != -1 {
candidate = idx + 1
}
}
if candidate == -1 {
return 0, footerMissing
}
footer := data[candidate:]
newline := bytes.IndexByte(footer, '\n')
if newline == -1 || candidate+newline+1 != len(data) {
return 0, footerMalformed
}
return candidate, footerFound
}
func trimTrailingReplayNewline(data []byte) []byte {
// util-linux script records the final Enter as "\r\n", but replay feeds the
// bytes from a file rather than a tty. If the shell exits after consuming the
// trailing '\r', the leftover '\n' can trigger script's ~2s non-tty stdin
// delay before it shuts down. Keep the '\r' that bash consumed, drop only the
// synthetic final '\n'. The same cleanup is needed after a terminal EOF byte.
if len(data) >= 2 && data[len(data)-1] == '\n' && (data[len(data)-2] == '\r' || data[len(data)-2] == eofByte) {
return data[:len(data)-1]
}
return data
}
+52
View File
@@ -0,0 +1,52 @@
package mire
import (
"path/filepath"
"testing"
"mire/internal/testutil"
)
func TestLoadRecordedInputTrimsTrailingNewlineAfterEOF(t *testing.T) {
path := filepath.Join(t.TempDir(), "in")
testutil.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 TestLoadRecordedInputTrimsTrailingNewlineAfterCarriageReturn(t *testing.T) {
path := filepath.Join(t.TempDir(), "in")
testutil.WriteFile(t, path, "echo hi\rexit\r\n")
got, err := loadRecordedInput(path)
if err != nil {
t.Fatalf("loadRecordedInput() error = %v", err)
}
want := "echo hi\rexit\r"
if string(got) != want {
t.Fatalf("loadRecordedInput() = %q, want %q", string(got), want)
}
}
func TestLoadRecordedInputLeavesNormalTrailingNewline(t *testing.T) {
path := filepath.Join(t.TempDir(), "in")
testutil.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")
}
}
+54
View File
@@ -0,0 +1,54 @@
package mire
import (
"fmt"
"os"
)
// Record creates the requested scenario path under the resolved test directory
// and records an interactive shell session into in/out fixtures when saved.
func Record(path string) (string, 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)
}
target, err := resolveRecordTarget(testDir, path)
if err != nil {
return "", err
}
shellPath, err := resolveRecordShell(testDir)
if err != nil {
return "", err
}
if err := os.MkdirAll(target, 0o755); err != nil {
return "", err
}
setupScripts, err := discoverSetupScripts(testDir, target)
if err != nil {
return "", err
}
if err := recordScenario(target, shellPath, recordIO{
in: os.Stdin,
out: os.Stdout,
err: os.Stderr,
}, cfg.Sandbox, setupScripts); err != nil {
return "", err
}
return target, nil
}
+195
View File
@@ -0,0 +1,195 @@
package mire
import (
"bufio"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"mire/internal/output"
)
type recordIO struct {
in io.Reader
out io.Writer
err io.Writer
}
func recordScenario(target, shellPath string, rio recordIO, sandboxConfig map[string]string, setupScripts []string) error {
rawIn, rawOut, cleanup, err := newRecordFiles()
if err != nil {
return err
}
defer cleanup()
sandbox, cleanupSandbox, err := newRecordSandbox()
if err != nil {
return err
}
defer cleanupSandbox()
overwrite, err := confirmRecordOverwrite(target, rio)
if err != nil {
return err
}
if !overwrite {
return ErrRecordingDiscarded
}
output.Fprintln(rio.err, "Run commands in the recorder shell, then type exit to finish.")
if err := runRecordSession(target, rawIn, rawOut, shellPath, sandbox, rio, sandboxConfig, setupScripts); err != nil {
return err
}
save, err := confirmRecordSave(rio)
if err != nil {
return err
}
if !save {
return ErrRecordingDiscarded
}
recordedIn, recordedOut, err := loadRecordedFixtures(rawIn, rawOut)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "in"), recordedIn, 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "out"), recordedOut, 0o644); err != nil {
return err
}
return nil
}
func newRecordFiles() (string, string, func(), error) {
dir, err := os.MkdirTemp("", "mire-record-")
if err != nil {
return "", "", nil, err
}
cleanup := func() {
_ = os.RemoveAll(dir)
}
return filepath.Join(dir, "in"), filepath.Join(dir, "out"), cleanup, nil
}
type recordSandbox struct {
hostHome string
hostTmp string
pathEnv string
}
func newRecordSandbox() (recordSandbox, func(), error) {
return newRecordSandboxForPathEnv(os.Getenv("PATH"))
}
func newRecordSandboxForPathEnv(pathEnv string) (recordSandbox, func(), error) {
dir, err := os.MkdirTemp("", "mire-record-sandbox-")
if err != nil {
return recordSandbox{}, nil, err
}
cleanup := func() {
_ = os.RemoveAll(dir)
}
sandbox := recordSandbox{
hostHome: filepath.Join(dir, "home"),
hostTmp: filepath.Join(dir, "tmp"),
pathEnv: pathEnv,
}
for _, path := range []string{
sandbox.hostHome,
sandbox.hostTmp,
} {
if err := os.MkdirAll(path, 0o755); err != nil {
cleanup()
return recordSandbox{}, nil, err
}
}
return sandbox, cleanup, nil
}
func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbox, rio recordIO, sandboxConfig map[string]string, setupScripts []string) error {
cmd := exec.Command("script", "-q", "-E", "always", "-I", rawIn, "-O", rawOut, "-c", shellPath)
cmd.Dir = dir
cmd.Env = recordSessionEnv(sandbox, sandboxConfig, setupScripts)
cmd.Stdin = rio.in
cmd.Stdout = rio.out
cmd.Stderr = rio.err
return cmd.Run()
}
func confirmRecordOverwrite(target string, rio recordIO) (bool, error) {
exists, err := recordFixturesExist(target)
if err != nil {
return false, err
}
if !exists {
return true, nil
}
output.Fprintf(rio.err, "Overwrite existing recording? [y/N] ")
return readRecordConfirmation(rio)
}
func confirmRecordSave(rio recordIO) (bool, error) {
output.Fprintf(rio.err, "Save recording? [y/N] ")
return readRecordConfirmation(rio)
}
func readRecordConfirmation(rio recordIO) (bool, error) {
reply, err := bufio.NewReader(rio.in).ReadString('\n')
if err != nil && err != io.EOF {
return false, err
}
reply = strings.TrimSpace(reply)
switch strings.ToLower(reply) {
case "y", "yes":
return true, nil
default:
return false, nil
}
}
func recordFixturesExist(target string) (bool, error) {
for _, path := range []string{
filepath.Join(target, "in"),
filepath.Join(target, "out"),
} {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if !os.IsNotExist(err) {
return false, err
}
}
return false, nil
}
func loadRecordedFixtures(rawIn, rawOut string) ([]byte, []byte, error) {
recordedIn, err := loadRecordedInput(rawIn)
if err != nil {
return nil, nil, err
}
recordedOut, err := loadRecordedOutput(rawOut)
if err != nil {
return nil, nil, err
}
return recordedIn, recordedOut, nil
}
+115
View File
@@ -0,0 +1,115 @@
package mire
import (
"embed"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
const recordShellName = "shell.sh"
const (
compareMarkerEnvName = "MIRE_COMPARE_MARKER"
compareMarkerEnabledValue = "1"
compareOutputMarker = "__MIRE_E2E_BEGIN__"
)
//go:embed record_shell.sh
var recordShellFS embed.FS
func recordShellPath(testDir string) string {
return filepath.Join(testDir, recordShellName)
}
func ensureRecordShell(testDir string) error {
path := recordShellPath(testDir)
if info, err := os.Stat(path); err == nil {
if info.IsDir() {
return fmt.Errorf("recorder shell %q is a directory; remove it and rerun `mire init`", path)
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to check recorder shell %q: %v", path, err)
}
return writeRecordShell(testDir)
}
func writeRecordShell(testDir string) error {
if err := os.MkdirAll(testDir, 0o755); err != nil {
return fmt.Errorf("failed to create test directory %q: %v", testDir, err)
}
path := recordShellPath(testDir)
if err := os.WriteFile(path, []byte(buildRecordShellScript()), 0o644); err != nil {
return fmt.Errorf("failed to write recorder shell %q: %v", path, err)
}
if err := os.Chmod(path, 0o755); err != nil {
return fmt.Errorf("failed to make recorder shell executable %q: %v", path, err)
}
return nil
}
func resolveRecordShell(testDir string) (string, error) {
path := recordShellPath(testDir)
info, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("missing recorder shell %q; rerun `mire init` or restore the file", path)
}
return "", fmt.Errorf("failed to check recorder shell %q: %v", path, err)
}
if info.IsDir() {
return "", fmt.Errorf("recorder shell %q is a directory; rerun `mire init` or restore the file", path)
}
return path, nil
}
func buildRecordShellScript() string {
body, err := recordShellFS.ReadFile("record_shell.sh")
if err != nil {
panic(fmt.Sprintf("read record shell: %v", err))
}
return string(body)
}
func recordSessionEnv(sandbox recordSandbox, sandboxConfig map[string]string, setupScripts []string) []string {
return recordSessionEnvWithExtra(sandbox, sandboxConfig, setupScripts, nil)
}
func recordSessionEnvWithExtra(sandbox recordSandbox, sandboxConfig map[string]string, setupScripts []string, extraEnv map[string]string) []string {
env := append([]string{}, os.Environ()...)
env = append(env,
"MIRE_HOST_HOME="+sandbox.hostHome,
"MIRE_HOST_TMP="+sandbox.hostTmp,
"MIRE_PATH_ENV="+sandbox.pathEnv,
setupScriptsEnvName+"="+strings.Join(setupScripts, "\n"),
)
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
}
func sandboxEnvName(key string) string {
return "MIRE_" + strings.ToUpper(key)
}
func sortedKeys(values map[string]string) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
set -eu
# hoisted vars to fail fast if any missing
host_home=${MIRE_HOST_HOME:?}
host_tmp=${MIRE_HOST_TMP:?}
path_env=${MIRE_PATH_ENV:?}
visible_home=${MIRE_VISIBLE_HOME:?}
bootstrap_rc="$host_home/.mire-shell-rc"
visible_bootstrap_rc="$visible_home/.mire-shell-rc"
setup_scripts_dir='/tmp/mire-setup-scripts'
cat >"$bootstrap_rc" <<'EOF'
cd "${HOME:?}"
for path in /tmp/mire-setup-scripts/*.sh; do
[ -e "$path" ] || continue
cd "${HOME:?}"
source "$path"
cd "${HOME:?}"
done
EOF
if [ "${MIRE_COMPARE_MARKER:-0}" = "1" ]; then
printf '__MIRE_E2E_BEGIN__\n'
fi
set -- \
--ro-bind / / \
--tmpfs /home \
--bind "$host_home" "$visible_home" \
--bind "$host_tmp" '/tmp' \
--dev /dev \
--proc /proc \
--unshare-pid \
--die-with-parent \
--setenv HISTFILE '/dev/null' \
--setenv HOME "$visible_home" \
--setenv LANG 'C' \
--setenv LC_ALL 'C' \
--setenv PAGER 'cat' \
--setenv PATH "$path_env" \
--setenv PS1 '$ ' \
--setenv TERM 'xterm-256color' \
--setenv TMPDIR '/tmp' \
--setenv TZ 'UTC' \
--chdir "$visible_home"
if [ -n "${MIRE_SETUP_SCRIPTS:-}" ]; then
i=1
while IFS= read -r host_path || [ -n "$host_path" ]; do
[ -n "$host_path" ] || continue
visible_path=$(printf '%s/%03d.sh' "$setup_scripts_dir" "$i")
set -- "$@" --ro-bind "$host_path" "$visible_path"
i=$((i + 1))
done <<EOF
${MIRE_SETUP_SCRIPTS-}
EOF
fi
exec bwrap "$@" bash --noprofile --rcfile "$visible_bootstrap_rc" -i
+378
View File
@@ -0,0 +1,378 @@
package mire
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"mire/internal/testutil"
)
func TestRecordCreatesRelativePath(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
testutil.MustMkdirAll(t, testDir)
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
mustWriteRecordShell(t, testDir)
testutil.AddFakeRecordDependencies(t, "script")
got := testutil.WithWorkingDir(t, root, func() string {
testutil.WithStdin(t, "y\n", func() {})
path, err := Record(filepath.Join("a", "b", "c") + string(os.PathSeparator))
if err != nil {
t.Fatalf("Record() error = %v", err)
}
return path
})
want := filepath.Join(testDir, "a", "b", "c")
if got != want {
t.Fatalf("Record() = %q, want %q", got, want)
}
for _, name := range []string{"in", "out"} {
if _, err := os.Stat(filepath.Join(want, name)); err != nil {
t.Fatalf("Stat(%q) error = %v", filepath.Join(want, name), err)
}
}
if got := testutil.ReadFile(t, filepath.Join(want, "in")); got != "fake recorded input\n" {
t.Fatalf("saved in = %q, want %q", got, "fake recorded input\n")
}
if got := testutil.ReadFile(t, filepath.Join(want, "out")); got != "fake recorded output\n" {
t.Fatalf("saved out = %q, want %q", got, "fake recorded output\n")
}
}
func TestRecordReturnsDiscardedErrorWhenSaveDeclined(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
testutil.MustMkdirAll(t, testDir)
mustWriteRecordShell(t, testDir)
testutil.AddFakeRecordDependencies(t, "script")
err := testutil.WithWorkingDir(t, root, func() error {
target := filepath.Join(testDir, "a", "b", "c")
testutil.MustMkdirAll(t, target)
return withRecordStreams(t, "n\n", func(rio recordIO) error {
return recordScenario(target, recordShellPath(testDir), rio, defaultSandboxConfig(), nil)
})
})
if !errors.Is(err, ErrRecordingDiscarded) {
t.Fatalf("Record() error = %v, want ErrRecordingDiscarded", err)
}
target := filepath.Join(testDir, "a", "b", "c")
for _, name := range []string{"in", "out"} {
if _, err := os.Stat(filepath.Join(target, name)); !os.IsNotExist(err) {
t.Fatalf("Stat(%q) error = %v, want not exists", filepath.Join(target, name), err)
}
}
}
func TestRecordReturnsDiscardedErrorWhenOverwriteDeclined(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
target := filepath.Join(testDir, "a", "b", "c")
testutil.MustMkdirAll(t, target)
mustWriteRecordShell(t, testDir)
testutil.AddFakeRecordDependencies(t, "script")
testutil.WriteFile(t, filepath.Join(target, "in"), "existing in\n")
testutil.WriteFile(t, filepath.Join(target, "out"), "existing out\n")
err := testutil.WithWorkingDir(t, root, func() error {
return withRecordStreams(t, "n\n", func(rio recordIO) error {
return recordScenario(target, recordShellPath(testDir), rio, defaultSandboxConfig(), nil)
})
})
if !errors.Is(err, ErrRecordingDiscarded) {
t.Fatalf("recordScenario() error = %v, want ErrRecordingDiscarded", err)
}
for _, tc := range []struct {
name string
want string
}{
{name: "in", want: "existing in\n"},
{name: "out", want: "existing out\n"},
} {
got, readErr := os.ReadFile(filepath.Join(target, tc.name))
if readErr != nil {
t.Fatalf("ReadFile(%q) error = %v", filepath.Join(target, tc.name), readErr)
}
if string(got) != tc.want {
t.Fatalf("%s = %q, want %q", tc.name, string(got), tc.want)
}
}
}
func TestBuildRecordShellScriptUsesExpectedCommands(t *testing.T) {
body := buildRecordShellScript()
for _, want := range []string{
"host_home=${MIRE_HOST_HOME:?}",
"host_tmp=${MIRE_HOST_TMP:?}",
"path_env=${MIRE_PATH_ENV:?}",
"visible_home=${MIRE_VISIBLE_HOME:?}",
"bootstrap_rc=\"$host_home/.mire-shell-rc\"",
"setup_scripts_dir='/tmp/mire-setup-scripts'",
"visible_bootstrap_rc=\"$visible_home/.mire-shell-rc\"",
"for path in /tmp/mire-setup-scripts/*.sh; do",
"source \"$path\"",
`if [ -n "${MIRE_SETUP_SCRIPTS:-}" ]; then`,
"i=1",
`while IFS= read -r host_path || [ -n "$host_path" ]; do`,
`visible_path=$(printf '%s/%03d.sh' "$setup_scripts_dir" "$i")`,
`set -- "$@" --ro-bind "$host_path" "$visible_path"`,
"${MIRE_SETUP_SCRIPTS-}",
`if [ "${MIRE_COMPARE_MARKER:-0}" = "1" ]; then`,
"printf '__MIRE_E2E_BEGIN__\\n'",
"--bind \"$host_home\" \"$visible_home\"",
"--bind \"$host_tmp\" '/tmp'",
"--setenv HOME \"$visible_home\"",
"--setenv PATH \"$path_env\"",
"--setenv PS1 '$ '",
"--setenv TERM 'xterm-256color'",
"--setenv TZ 'UTC'",
"--chdir \"$visible_home\"",
"exec bwrap \"$@\" bash --noprofile --rcfile \"$visible_bootstrap_rc\" -i",
} {
if !strings.Contains(body, want) {
t.Fatalf("wrapper = %q, want substring %q", body, want)
}
}
}
func TestRecordSessionEnvIncludesConfiguredSandboxEnv(t *testing.T) {
env := recordSessionEnv(recordSandbox{
hostHome: "/tmp/host-home",
hostTmp: "/tmp/host-tmp",
pathEnv: "/tmp/bin",
}, map[string]string{
"visible_home": "/sandbox/home",
"key_word": "value",
}, []string{"/repo/e2e/setup.sh", "/repo/e2e/suite/setup.sh"})
for _, want := range []string{
"MIRE_HOST_HOME=/tmp/host-home",
"MIRE_HOST_TMP=/tmp/host-tmp",
"MIRE_PATH_ENV=/tmp/bin",
"MIRE_KEY_WORD=value",
"MIRE_VISIBLE_HOME=/sandbox/home",
"MIRE_SETUP_SCRIPTS=/repo/e2e/setup.sh\n/repo/e2e/suite/setup.sh",
} {
if !containsEnvEntry(env, want) {
t.Fatalf("env = %#v, want entry %q", env, want)
}
}
if containsEnvKey(env, "MIRE_SETUP_SCRIPT_BINDS") {
t.Fatalf("env = %#v, want MIRE_SETUP_SCRIPT_BINDS omitted", env)
}
}
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",
}, []string{"/repo/e2e/setup.sh"}, map[string]string{
compareMarkerEnvName: compareMarkerEnabledValue,
})
for _, want := range []string{
"MIRE_HOST_HOME=/tmp/host-home",
"MIRE_HOST_TMP=/tmp/host-tmp",
"MIRE_PATH_ENV=/tmp/bin",
"MIRE_VISIBLE_HOME=/sandbox/home",
"MIRE_SETUP_SCRIPTS=/repo/e2e/setup.sh",
"MIRE_COMPARE_MARKER=1",
} {
if !containsEnvEntry(env, want) {
t.Fatalf("env = %#v, want entry %q", env, want)
}
}
if containsEnvKey(env, "MIRE_SETUP_SCRIPT_BINDS") {
t.Fatalf("env = %#v, want MIRE_SETUP_SCRIPT_BINDS omitted", env)
}
}
func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) {
testDir := filepath.Join(t.TempDir(), "e2e")
mustWriteRecordShell(t, testDir)
testutil.AddFakeRecordDependencies(t, "script")
argsPath := filepath.Join(t.TempDir(), "script.args")
commandBodyPath := filepath.Join(t.TempDir(), "script.command")
t.Setenv("FAKE_SCRIPT_ARGS_FILE", argsPath)
t.Setenv("FAKE_SCRIPT_COMMAND_BODY_FILE", commandBodyPath)
sandbox, cleanup, err := newRecordSandboxForPathEnv(os.Getenv("PATH"))
if err != nil {
t.Fatalf("newRecordSandboxForPathEnv() error = %v", err)
}
defer cleanup()
shellPath := recordShellPath(testDir)
err = withRecordStreams(t, "", func(rio recordIO) error {
return runRecordSession(t.TempDir(), filepath.Join(t.TempDir(), "raw.in"), filepath.Join(t.TempDir(), "raw.out"), shellPath, sandbox, rio, defaultSandboxConfig(), []string{"/repo/e2e/setup.sh"})
})
if err != nil {
t.Fatalf("runRecordSession() error = %v", err)
}
args := strings.Split(strings.TrimSpace(testutil.ReadFile(t, argsPath)), "\n")
if len(args) != 9 {
t.Fatalf("script args = %q, want 9 args", args)
}
if got := args[:4]; strings.Join(got, "\n") != strings.Join([]string{"-q", "-E", "always", "-I"}, "\n") {
t.Fatalf("script args prefix = %q, want %q", got, []string{"-q", "-E", "always", "-I"})
}
if args[5] != "-O" {
t.Fatalf("script args[5] = %q, want %q", args[5], "-O")
}
if args[7] != "-c" {
t.Fatalf("script args[7] = %q, want %q", args[7], "-c")
}
if args[8] != shellPath {
t.Fatalf("script args[8] = %q, want %q", args[8], shellPath)
}
body := testutil.ReadFile(t, commandBodyPath)
for _, want := range []string{
"host_home=${MIRE_HOST_HOME:?}",
"visible_home=${MIRE_VISIBLE_HOME:?}",
"bootstrap_rc=\"$host_home/.mire-shell-rc\"",
"visible_bootstrap_rc=\"$visible_home/.mire-shell-rc\"",
"for path in /tmp/mire-setup-scripts/*.sh; do",
"source \"$path\"",
`if [ -n "${MIRE_SETUP_SCRIPTS:-}" ]; then`,
`set -- "$@" --ro-bind "$host_path" "$visible_path"`,
`if [ "${MIRE_COMPARE_MARKER:-0}" = "1" ]; then`,
"printf '__MIRE_E2E_BEGIN__\\n'",
"--ro-bind / /",
"--tmpfs /home",
"--setenv HOME \"$visible_home\"",
"--setenv TMPDIR '/tmp'",
"exec bwrap \"$@\" bash --noprofile --rcfile \"$visible_bootstrap_rc\" -i",
} {
if !strings.Contains(body, want) {
t.Fatalf("wrapper = %q, want substring %q", body, want)
}
}
}
func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) {
testutil.RequireCommands(t, "script", "bwrap", "bash")
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
target := filepath.Join(testDir, "suite", "spec")
testutil.MustMkdirAll(t, target)
mustWriteRecordShell(t, testDir)
sandboxConfig := map[string]string{
"visible_home": "/home/test",
"key_word": "value",
}
visibleHome := sandboxConfig["visible_home"]
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
t.Cleanup(func() {
if err := reader.Close(); err != nil {
t.Fatalf("close pipe reader: %v", err)
}
})
writeDone := make(chan error, 1)
go func() {
defer close(writeDone)
defer writer.Close()
if _, err := writer.Write([]byte("pwd\necho \"$HOME\"\necho \"$MIRE_KEY_WORD\"\nif [ -e \"$HOME/repo\" ]; then echo FOUND; else echo MISSING; fi\npwd\nexit\n")); err != nil {
writeDone <- err
return
}
time.Sleep(300 * time.Millisecond)
if _, err := writer.Write([]byte("y\n")); err != nil {
writeDone <- err
return
}
writeDone <- nil
}()
err = testutil.WithWorkingDir(t, root, func() error {
return recordScenario(target, recordShellPath(testDir), recordIO{
in: reader,
out: ioDiscard{},
err: &bytes.Buffer{},
}, sandboxConfig, nil)
})
if err != nil {
t.Fatalf("recordScenario() error = %v", err)
}
if err := <-writeDone; err != nil {
t.Fatalf("write session input: %v", err)
}
recordedIn := testutil.ReadFile(t, filepath.Join(target, "in"))
if strings.Contains(recordedIn, "Script started on ") {
t.Fatalf("saved in = %q, want stripped script wrapper", recordedIn)
}
for _, want := range []string{"pwd\n", "echo \"$HOME\"\n", "echo \"$MIRE_KEY_WORD\"\n", "if [ -e \"$HOME/repo\" ]; then echo FOUND; else echo MISSING; fi\n", "exit\n"} {
if !strings.Contains(recordedIn, want) {
t.Fatalf("saved in = %q, want substring %q", recordedIn, want)
}
}
recordedOut := testutil.ReadFile(t, filepath.Join(target, "out"))
if strings.Contains(recordedOut, "Script started on ") {
t.Fatalf("saved out = %q, want stripped script wrapper", recordedOut)
}
for _, want := range []string{visibleHome, "value"} {
if !strings.Contains(recordedOut, want) {
t.Fatalf("saved out = %q, want substring %q", recordedOut, want)
}
}
if !strings.Contains(recordedOut, "MISSING") {
t.Fatalf("saved out = %q, want missing repo confirmation", recordedOut)
}
if strings.Contains(recordedOut, "\r\nFOUND\r\n") {
t.Fatalf("saved out = %q, want repo to stay unavailable", recordedOut)
}
}
func TestRecordFailsWhenRecorderShellMissing(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
testutil.MustMkdirAll(t, testDir)
testutil.AddFakeRecordDependencies(t, "script")
target := filepath.Join(testDir, "suite", "spec")
err := testutil.WithWorkingDir(t, root, func() error {
_, err := Record(filepath.Join("suite", "spec"))
return err
})
if err == nil {
t.Fatal("Record() error = nil, want error")
}
if !strings.Contains(err.Error(), "rerun `mire init`") {
t.Fatalf("Record() error = %q, want rerun init hint", err.Error())
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("Stat(%q) error = %v, want not exists", target, statErr)
}
}
+61
View File
@@ -0,0 +1,61 @@
package mire
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
setupScriptName = "setup.sh"
setupScriptsEnvName = "MIRE_SETUP_SCRIPTS"
)
func discoverSetupScripts(testDir, scenarioDir string) ([]string, error) {
absTestDir, err := filepath.Abs(testDir)
if err != nil {
return nil, fmt.Errorf("failed to resolve test directory path: %v", err)
}
absScenarioDir, err := filepath.Abs(scenarioDir)
if err != nil {
return nil, fmt.Errorf("failed to resolve scenario directory path: %v", err)
}
relToTestDir, err := filepath.Rel(absTestDir, absScenarioDir)
if err != nil {
return nil, fmt.Errorf("failed to resolve scenario directory %q: %v", absScenarioDir, err)
}
if !isWithinBase(relToTestDir) {
return nil, fmt.Errorf("scenario directory %q must be inside test directory %q", absScenarioDir, absTestDir)
}
dirs := []string{absTestDir}
if relToTestDir != "." {
current := absTestDir
for _, part := range strings.Split(relToTestDir, string(os.PathSeparator)) {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
}
scripts := make([]string, 0, len(dirs))
for _, dir := range dirs {
path := filepath.Join(dir, setupScriptName)
info, err := os.Stat(path)
if err == nil {
if info.IsDir() {
return nil, fmt.Errorf("setup fixture %q is a directory", path)
}
scripts = append(scripts, path)
continue
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("failed to check setup fixture %q: %v", path, err)
}
}
return scripts, nil
}
+35
View File
@@ -0,0 +1,35 @@
package mire
import (
"path/filepath"
"testing"
"mire/internal/testutil"
)
func TestDiscoverSetupScriptsFindsRootToLeafSetupFiles(t *testing.T) {
testDir := filepath.Join(t.TempDir(), "e2e")
scenarioDir := filepath.Join(testDir, "suite", "spec")
rootSetup := filepath.Join(testDir, setupScriptName)
suiteSetup := filepath.Join(testDir, "suite", setupScriptName)
scenarioSetup := filepath.Join(scenarioDir, setupScriptName)
testutil.WriteFile(t, rootSetup, "export ROOT=1\n")
testutil.WriteFile(t, suiteSetup, "export SUITE=1\n")
testutil.WriteFile(t, scenarioSetup, "export SPEC=1\n")
got, err := discoverSetupScripts(testDir, scenarioDir)
if err != nil {
t.Fatalf("discoverSetupScripts() error = %v", err)
}
want := []string{rootSetup, suiteSetup, scenarioSetup}
if len(got) != len(want) {
t.Fatalf("len(discoverSetupScripts()) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("discoverSetupScripts()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
+284
View File
@@ -0,0 +1,284 @@
package mire
import (
"bytes"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"time"
"mire/internal/output"
)
type testIO struct {
out io.Writer
err io.Writer
}
type testScenario struct {
dir string
relPath string
inPath string
outPath string
setupScripts []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)
}
setupScripts, err := discoverSetupScripts(displayRoot, dir)
if err != nil {
return nil, 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,
setupScripts: setupScripts,
})
}
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", "-E", "always", "-O", rawOut, "-c", shellPath)
cmd.Dir = scenario.dir
cmd.Env = recordSessionEnvWithExtra(sandbox, sandboxConfig, scenario.setupScripts, 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 `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
}
}
+231
View File
@@ -0,0 +1,231 @@
package mire
import (
"bytes"
"path/filepath"
"strings"
"testing"
"time"
"mire/internal/testutil"
)
func TestFormatElapsed(t *testing.T) {
tests := []struct {
name string
elapsed time.Duration
wantText string
}{
{name: "sub-second", elapsed: 123 * time.Millisecond, wantText: "123 ms"},
{name: "boundary", elapsed: time.Second, wantText: "1.00 s"},
{name: "multi-second", elapsed: 1534 * time.Millisecond, wantText: "1.53 s"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatElapsed(tt.elapsed); got != tt.wantText {
t.Fatalf("formatElapsed(%v) = %q, want %q", tt.elapsed, got, tt.wantText)
}
})
}
}
func TestDiscoverTestScenariosFindsNestedFixturesAndSorts(t *testing.T) {
testDir := filepath.Join(t.TempDir(), "e2e")
testutil.WriteScenarioFixtures(t, filepath.Join(testDir, "nested", "b"), "echo two\n", "echo two\n")
testutil.WriteScenarioFixtures(t, filepath.Join(testDir, "a"), "echo one\n", "echo one\n")
testutil.WriteFile(t, filepath.Join(testDir, "shell.sh"), "#!/bin/sh\n")
testutil.WriteFile(t, filepath.Join(testDir, "notes.txt"), "ignore me\n")
got, err := discoverTestScenarios(testDir, 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")
testutil.WriteFile(t, filepath.Join(testDir, "broken", "in"), "echo broken\n")
_, err := discoverTestScenarios(testDir, 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")
testutil.WriteFile(t, filepath.Join(testDir, "broken", "out"), "echo broken\n")
_, err := discoverTestScenarios(testDir, 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 TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) {
testutil.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")
testutil.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"),
setupScripts: nil,
}, shellPath, testIO{
out: &stdout,
err: &stderr,
}, defaultSandboxConfig())
if err != nil {
t.Fatalf("replayScenario() error = %v", err)
}
if got := testutil.ReadFile(t, capturedInput); got != "echo replay\nexit\n" {
t.Fatalf("captured stdin = %q, want recorded input", got)
}
if stdout.String() != "" {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if stderr.String() != "" {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) {
testutil.AddFakeRecordDependencies(t, "script")
t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1")
testDir := filepath.Join(t.TempDir(), "e2e")
shellPath := filepath.Join(testDir, "shell.sh")
testutil.WriteFile(t, shellPath, "#!/bin/sh\n")
scenarioDir := filepath.Join(testDir, "suite", "spec")
testutil.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"),
setupScripts: nil,
}, 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 `mire init`") {
t.Fatalf("replayScenario() error = %q, want compare marker refresh hint", err.Error())
}
}
func TestDiscoverTestScenariosUsesDisplayRootForRelativePaths(t *testing.T) {
testDir := filepath.Join(t.TempDir(), "e2e")
scopedDir := filepath.Join(testDir, "nested")
testutil.WriteFile(t, filepath.Join(testDir, setupScriptName), "export ROOT=1\n")
testutil.WriteScenarioFixtures(t, filepath.Join(scopedDir, "b"), "echo two\n", "echo two\n")
got, err := discoverTestScenarios(scopedDir, testDir)
if err != nil {
t.Fatalf("discoverTestScenarios() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("len(discoverTestScenarios()) = %d, want 1", len(got))
}
if got[0].relPath != filepath.Join("nested", "b") {
t.Fatalf("scenario relPath = %q, want %q", got[0].relPath, filepath.Join("nested", "b"))
}
if len(got[0].setupScripts) != 1 || got[0].setupScripts[0] != filepath.Join(testDir, setupScriptName) {
t.Fatalf("scenario setupScripts = %#v, want root setup", got[0].setupScripts)
}
}
func TestResolveTestDiscoveryRootRejectsMissingPath(t *testing.T) {
testDir := filepath.Join(t.TempDir(), "e2e")
testutil.MustMkdirAll(t, testDir)
_, err := resolveTestDiscoveryRoot(testDir, "missing")
if err == nil {
t.Fatal("resolveTestDiscoveryRoot() error = nil, want error")
}
if !strings.Contains(err.Error(), `does not exist`) {
t.Fatalf("resolveTestDiscoveryRoot() error = %q, want missing-path error", err.Error())
}
}
func TestResolveTestDiscoveryRootRejectsFile(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
testutil.WriteFile(t, filepath.Join(testDir, "case.txt"), "hello\n")
err := testutil.WithWorkingDir(t, root, func() error {
_, err := resolveTestDiscoveryRoot(testDir, filepath.Join("e2e", "case.txt"))
return err
})
if err == nil {
t.Fatal("resolveTestDiscoveryRoot() error = nil, want error")
}
if !strings.Contains(err.Error(), `is not a directory`) {
t.Fatalf("resolveTestDiscoveryRoot() error = %q, want directory error", err.Error())
}
}
func TestRunTestsScopedRunEmptyDirectoryFails(t *testing.T) {
root := t.TempDir()
testDir := filepath.Join(root, "e2e")
emptyDir := filepath.Join(testDir, "empty")
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
mustWriteRecordShell(t, testDir)
testutil.MustMkdirAll(t, emptyDir)
err := testutil.WithWorkingDir(t, root, func() error {
return runTests("empty", testIO{
out: &bytes.Buffer{},
err: &bytes.Buffer{},
})
})
if err == nil {
t.Fatal("runTests() error = nil, want error")
}
if !strings.Contains(err.Error(), `no test scenarios found in "`) || !strings.Contains(err.Error(), filepath.Join("e2e", "empty")) {
t.Fatalf("runTests() error = %q, want empty-directory error", err.Error())
}
}
+75
View File
@@ -0,0 +1,75 @@
package mire
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
mireconfig "mire/internal/config"
)
// ResolveTestDir resolves the project test directory from config.
func ResolveTestDir() (string, error) {
root, err := currentProjectRoot()
if err != nil {
return "", err
}
return resolveTestDirFromRoot(root)
}
func resolveTestDirFromRoot(root string) (string, error) {
cfg, err := readConfigFromRoot(root)
if err != nil {
return "", err
}
return resolveTestDirFromConfig(root, cfg)
}
func readConfigFromRoot(root string) (mireconfig.Config, error) {
configPath := filepath.Join(root, "mire.toml")
return mireconfig.ReadConfig(configPath)
}
func resolveTestDirFromConfig(root string, cfg mireconfig.Config) (string, error) {
testDir := filepath.Join(root, cfg.TestDir)
info, err := os.Stat(testDir)
if err == nil {
if !info.IsDir() {
return "", fmt.Errorf("configured test_dir is not a directory: %s", testDir)
}
return testDir, nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("failed to check test directory %s: %v", testDir, err)
}
return testDir, nil
}
func currentProjectRoot() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %v", err)
}
return projectRoot(cwd), nil
}
// returns the git root if in a git repo else current pwd
func projectRoot(cwd string) string {
out, err := exec.Command("git", "-C", cwd, "rev-parse", "--show-toplevel").CombinedOutput()
if err != nil {
return cwd
}
root := strings.TrimSpace(string(out))
if root == "" {
return cwd
}
return filepath.Clean(root)
}
+239
View File
@@ -0,0 +1,239 @@
package mire
import (
"os"
"path/filepath"
"strings"
"testing"
"mire/internal/testutil"
)
func TestInitCreatesConfigAtProjectRoot(t *testing.T) {
root := t.TempDir()
testutil.WithWorkingDir(t, root, func() struct{} {
if err := Init(); err != nil {
t.Fatalf("Init() error = %v", err)
}
return struct{}{}
})
got := testutil.ReadFile(t, filepath.Join(root, "mire.toml"))
if got != testutil.DefaultWrittenConfig("e2e") {
t.Fatalf("config = %q, want %q", got, testutil.DefaultWrittenConfig("e2e"))
}
assertRecordShell(t, filepath.Join(root, "e2e", recordShellName))
}
func TestInitUsesGitRoot(t *testing.T) {
root := t.TempDir()
testutil.MustGitInit(t, root)
subdir := filepath.Join(root, "nested", "dir")
testutil.MustMkdirAll(t, subdir)
testutil.WithWorkingDir(t, subdir, func() struct{} {
if err := Init(); err != nil {
t.Fatalf("Init() error = %v", err)
}
return struct{}{}
})
if _, err := os.Stat(filepath.Join(root, "mire.toml")); err != nil {
t.Fatalf("Stat(%q) error = %v", filepath.Join(root, "mire.toml"), err)
}
assertRecordShell(t, filepath.Join(root, "e2e", recordShellName))
}
func TestInitLeavesExistingValidConfigUntouchedAndRefreshesShell(t *testing.T) {
root := t.TempDir()
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("custom/suite"))
testutil.WriteFile(t, filepath.Join(root, "custom", "suite", recordShellName), "outdated\n")
testutil.WithWorkingDir(t, root, func() struct{} {
if err := Init(); err != nil {
t.Fatalf("Init() error = %v", err)
}
return struct{}{}
})
got := testutil.ReadFile(t, filepath.Join(root, "mire.toml"))
if got != testutil.ValidConfigContent("custom/suite") {
t.Fatalf("config = %q, want %q", got, testutil.ValidConfigContent("custom/suite"))
}
if got := testutil.ReadFile(t, filepath.Join(root, "custom", "suite", recordShellName)); got != buildRecordShellScript() {
t.Fatalf("shell = %q, want refreshed recorder shell", got)
}
}
func TestInitCreatesMissingConfiguredTestDir(t *testing.T) {
root := t.TempDir()
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("custom/suite"))
testutil.WithWorkingDir(t, root, func() struct{} {
if err := Init(); err != nil {
t.Fatalf("Init() error = %v", err)
}
return struct{}{}
})
assertRecordShell(t, filepath.Join(root, "custom", "suite", recordShellName))
}
func TestInitFailsWhenExistingConfigInvalid(t *testing.T) {
root := t.TempDir()
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), "test_dir = \"e2e\"\n")
err := testutil.WithWorkingDir(t, root, func() error {
return Init()
})
if err == nil {
t.Fatal("Init() error = nil, want error")
}
if !strings.Contains(err.Error(), "missing [mire] config") {
t.Fatalf("Init() error = %q, want missing [mire] config error", err.Error())
}
}
func TestResolveTestDirFromConfig(t *testing.T) {
root := t.TempDir()
configured := filepath.Join(root, "custom", "suite")
testutil.MustMkdirAll(t, configured)
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("custom/suite"))
got := testutil.WithWorkingDir(t, root, func() string {
path, err := ResolveTestDir()
if err != nil {
t.Fatalf("ResolveTestDir() error = %v", err)
}
return path
})
if got != configured {
t.Fatalf("ResolveTestDir() = %q, want %q", got, configured)
}
}
func TestResolveTestDirMissingConfigFails(t *testing.T) {
root := t.TempDir()
err := testutil.WithWorkingDir(t, root, func() error {
_, err := ResolveTestDir()
return err
})
if err == nil {
t.Fatal("ResolveTestDir() error = nil, want error")
}
if !os.IsNotExist(err) {
t.Fatalf("ResolveTestDir() error = %v, want os.ErrNotExist", err)
}
}
func TestResolveTestDirMissingTestDirFails(t *testing.T) {
root := t.TempDir()
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), "[mire]\n")
err := testutil.WithWorkingDir(t, root, func() error {
_, err := ResolveTestDir()
return err
})
if err == nil {
t.Fatal("ResolveTestDir() error = nil, want error")
}
if !strings.Contains(err.Error(), "missing required mire.test_dir") {
t.Fatalf("ResolveTestDir() error = %q, want missing required mire.test_dir", err.Error())
}
}
func TestResolveTestDirEmptyTestDirFails(t *testing.T) {
root := t.TempDir()
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), "[mire]\ntest_dir = \"\"\n")
err := testutil.WithWorkingDir(t, root, func() error {
_, err := ResolveTestDir()
return err
})
if err == nil {
t.Fatal("ResolveTestDir() error = nil, want error")
}
if !strings.Contains(err.Error(), "empty mire.test_dir") {
t.Fatalf("ResolveTestDir() error = %q, want empty mire.test_dir", err.Error())
}
}
func TestResolveTestDirMalformedConfigFails(t *testing.T) {
root := t.TempDir()
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), "[mire]\ntest_dir = [\n")
err := testutil.WithWorkingDir(t, root, func() error {
_, err := ResolveTestDir()
return err
})
if err == nil {
t.Fatal("ResolveTestDir() error = nil, want error")
}
if !strings.Contains(err.Error(), "failed to read") {
t.Fatalf("ResolveTestDir() error = %q, want read failure", err.Error())
}
}
func TestResolveTestDirConfiguredMissingDirectoryReturnsConfiguredPath(t *testing.T) {
root := t.TempDir()
want := filepath.Join(root, "missing")
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("missing"))
got := testutil.WithWorkingDir(t, root, func() string {
path, err := ResolveTestDir()
if err != nil {
t.Fatalf("ResolveTestDir() error = %v", err)
}
return path
})
if got != want {
t.Fatalf("ResolveTestDir() = %q, want %q", got, want)
}
}
func TestResolveTestDirConfiguredFileFails(t *testing.T) {
root := t.TempDir()
testutil.WriteFile(t, filepath.Join(root, "case.txt"), "hello\n")
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("case.txt"))
err := testutil.WithWorkingDir(t, root, func() error {
_, err := ResolveTestDir()
return err
})
if err == nil {
t.Fatal("ResolveTestDir() error = nil, want error")
}
if !strings.Contains(err.Error(), "configured test_dir is not a directory") {
t.Fatalf("ResolveTestDir() error = %q, want file path error", err.Error())
}
}
func TestResolveTestDirUsesGitRoot(t *testing.T) {
root := t.TempDir()
testutil.MustGitInit(t, root)
want := filepath.Join(root, "e2e")
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
subdir := filepath.Join(root, "nested", "dir")
testutil.MustMkdirAll(t, subdir)
got := testutil.WithWorkingDir(t, subdir, func() string {
path, err := ResolveTestDir()
if err != nil {
t.Fatalf("ResolveTestDir() error = %v", err)
}
return path
})
if got != want {
t.Fatalf("ResolveTestDir() = %q, want %q", got, want)
}
}
+92
View File
@@ -0,0 +1,92 @@
package mire
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"mire/internal/testutil"
)
func assertRecordShell(t *testing.T, path string) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if info.Mode().Perm()&0o111 == 0 {
t.Fatalf("%q mode = %o, want executable", path, info.Mode().Perm())
}
if got := testutil.ReadFile(t, path); got != buildRecordShellScript() {
t.Fatalf("shell = %q, want generated recorder shell", got)
}
}
func withRecordStreams[T any](t *testing.T, input string, fn func(recordIO) T) T {
t.Helper()
path := filepath.Join(t.TempDir(), "stdin.txt")
testutil.WriteFile(t, path, input)
file, err := os.Open(path)
if err != nil {
t.Fatalf("Open(%q) error = %v", path, err)
}
t.Cleanup(func() {
if err := file.Close(); err != nil {
t.Fatalf("close record input: %v", err)
}
})
return fn(recordIO{
in: file,
out: ioDiscard{},
err: &bytes.Buffer{},
})
}
type ioDiscard struct{}
func (ioDiscard) Write(p []byte) (int, error) {
return len(p), nil
}
func mustWriteRecordShell(t *testing.T, testDir string) {
t.Helper()
if err := writeRecordShell(testDir); err != nil {
t.Fatalf("writeRecordShell(%q) error = %v", testDir, err)
}
}
func defaultSandboxConfig() map[string]string {
return map[string]string{
"visible_home": "/home/test",
}
}
func containsEnvEntry(env []string, want string) bool {
for _, entry := range env {
if entry == want {
return true
}
}
return false
}
func containsEnvKey(env []string, key string) bool {
prefix := key + "="
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
return true
}
}
return false
}