rename: rebrand to mire
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package mire
|
||||
|
||||
var (
|
||||
ErrRecordingDiscarded = RecordingDiscardedError{}
|
||||
)
|
||||
|
||||
type RecordingDiscardedError struct{}
|
||||
|
||||
func (RecordingDiscardedError) Error() string {
|
||||
return "recording discarded"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user