diff --git a/cmd/root_test.go b/cmd/root_test.go index 5fa0d13..141ac72 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -50,8 +50,8 @@ func TestRunInit(t *testing.T) { } }) - if got := mustReadFile(t, filepath.Join(root, "miro.toml")); got != "[miro]\n test_dir = \"e2e\"\n" { - t.Fatalf("config = %q, want %q", got, "[miro]\n test_dir = \"e2e\"\n") + if got := mustReadFile(t, filepath.Join(root, "miro.toml")); got != defaultWrittenConfig("e2e") { + t.Fatalf("config = %q, want %q", got, defaultWrittenConfig("e2e")) } info, err := os.Stat(filepath.Join(root, "e2e", "shell.sh")) if err != nil { @@ -152,7 +152,7 @@ func TestRunRecordRejectsAbsolutePathOutsideTestDir(t *testing.T) { if err := os.MkdirAll(wantDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"e2e\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) outside := filepath.Join(root, "outside", "spec") withWorkingDir(t, root, func() { @@ -206,7 +206,7 @@ func TestRunRecordFailsWhenDependencyMissing(t *testing.T) { if err := os.MkdirAll(wantDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"e2e\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) for _, tc := range []struct { name string @@ -446,3 +446,11 @@ func withStdin(t *testing.T, input string, fn func()) { fn() } + +func defaultWrittenConfig(testDir string) string { + return "[miro]\n test_dir = \"" + testDir + "\"\n\n[sandbox]\n visible_home = \"/home/test\"\n" +} + +func validConfigContent(testDir string) string { + return "[miro]\ntest_dir = \"" + testDir + "\"\n\n[sandbox]\nvisible_home = \"/home/test\"\n" +} diff --git a/internal/config/config.go b/internal/config/config.go index f595275..a5e7931 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,22 +5,40 @@ import ( "errors" "fmt" "os" + "path/filepath" + "regexp" + "sort" "github.com/BurntSushi/toml" ) +const DefaultVisibleHome = "/home/test" + +var ( + lowerSnakeCasePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$`) + requiredSandboxDefaults = map[string]string{ + "visible_home": DefaultVisibleHome, + } +) + type Config struct { TestDir string + Sandbox map[string]string } type tomlConfig struct { - Miro tomlMiroConfig `toml:"miro"` + Miro tomlMiroConfig `toml:"miro"` + Sandbox map[string]string `toml:"sandbox"` } type tomlMiroConfig struct { TestDir string `toml:"test_dir"` } +func DefaultSandboxConfig() map[string]string { + return cloneSandbox(requiredSandboxDefaults) +} + // ReadConfig reads miro.toml. func ReadConfig(path string) (Config, error) { var raw tomlConfig @@ -41,9 +59,18 @@ func ReadConfig(path string) (Config, error) { if raw.Miro.TestDir == "" { return Config{}, fmt.Errorf("failed to read %s: empty miro.test_dir", path) } + if !meta.IsDefined("sandbox") { + return Config{}, fmt.Errorf("failed to read %s: missing [sandbox] config", path) + } + + sandbox, err := validateSandbox(path, raw.Sandbox) + if err != nil { + return Config{}, err + } return Config{ TestDir: raw.Miro.TestDir, + Sandbox: sandbox, }, nil } @@ -52,15 +79,61 @@ func WriteConfig(path string, cfg Config) error { if cfg.TestDir == "" { return errors.New("empty miro.test_dir") } + sandbox, err := validateSandbox(path, cfg.Sandbox) + if err != nil { + return err + } var buf bytes.Buffer - if err := toml.NewEncoder(&buf).Encode(tomlConfig{ - Miro: tomlMiroConfig{ - TestDir: cfg.TestDir, - }, - }); err != nil { - return fmt.Errorf("failed to encode %s: %v", path, err) + fmt.Fprintf(&buf, "[miro]\n test_dir = %q\n\n[sandbox]\n", cfg.TestDir) + + keys := make([]string, 0, len(sandbox)) + for key := range sandbox { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + fmt.Fprintf(&buf, " %s = %q\n", key, sandbox[key]) } return os.WriteFile(path, buf.Bytes(), 0o644) } + +func validateSandbox(path string, sandbox map[string]string) (map[string]string, error) { + validated := cloneSandbox(sandbox) + + for key := range validated { + if !lowerSnakeCasePattern.MatchString(key) { + return nil, fmt.Errorf("failed to read %s: invalid sandbox key %q: must be lower_snake_case", path, key) + } + } + + for key := range requiredSandboxDefaults { + value, ok := validated[key] + if !ok { + return nil, fmt.Errorf("failed to read %s: missing required sandbox.%s", path, key) + } + if value == "" { + return nil, fmt.Errorf("failed to read %s: empty sandbox.%s", path, key) + } + } + + if !filepath.IsAbs(validated["visible_home"]) { + return nil, fmt.Errorf("failed to read %s: sandbox.visible_home must be an absolute path", path) + } + + return validated, nil +} + +func cloneSandbox(sandbox map[string]string) map[string]string { + if len(sandbox) == 0 { + return map[string]string{} + } + + cloned := make(map[string]string, len(sandbox)) + for key, value := range sandbox { + cloned[key] = value + } + + return cloned +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 91d3d0f..1eb1db4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -13,13 +13,18 @@ func TestReadConfig(t *testing.T) { name string content string wantDir string + wantSandbox map[string]string wantErr string wantMissing bool }{ { - name: "with test dir", - content: "[miro]\ntest_dir = \"custom/suite\"\n", + name: "with test dir and sandbox", + content: "[miro]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nvisible_home = \"/home/test\"\nkey_word = \"value\"\n", wantDir: "custom/suite", + wantSandbox: map[string]string{ + "visible_home": "/home/test", + "key_word": "value", + }, }, { name: "legacy top level key", @@ -41,6 +46,31 @@ func TestReadConfig(t *testing.T) { content: "[miro]\ntest_dir = \"\"\n", wantErr: "empty miro.test_dir", }, + { + name: "without sandbox table", + content: "[miro]\ntest_dir = \"custom/suite\"\n", + wantErr: "missing [sandbox] config", + }, + { + name: "without required visible home", + content: "[miro]\ntest_dir = \"custom/suite\"\n\n[sandbox]\n", + wantErr: "missing required sandbox.visible_home", + }, + { + name: "empty required visible home", + content: "[miro]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nvisible_home = \"\"\n", + wantErr: "empty sandbox.visible_home", + }, + { + name: "relative visible home", + content: "[miro]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nvisible_home = \"home/test\"\n", + wantErr: "sandbox.visible_home must be an absolute path", + }, + { + name: "invalid sandbox key", + content: "[miro]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nvisible_home = \"/home/test\"\nKeyWord = \"value\"\n", + wantErr: "invalid sandbox key", + }, { name: "invalid toml", content: "[miro]\ntest_dir = [\n", @@ -83,6 +113,14 @@ func TestReadConfig(t *testing.T) { if got.TestDir != tt.wantDir { t.Fatalf("ReadConfig() TestDir = %q, want %q", got.TestDir, tt.wantDir) } + if len(got.Sandbox) != len(tt.wantSandbox) { + t.Fatalf("ReadConfig() Sandbox = %#v, want %#v", got.Sandbox, tt.wantSandbox) + } + for key, want := range tt.wantSandbox { + if got.Sandbox[key] != want { + t.Fatalf("ReadConfig() Sandbox[%q] = %q, want %q", key, got.Sandbox[key], want) + } + } }) } } @@ -90,7 +128,14 @@ func TestReadConfig(t *testing.T) { func TestWriteConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "miro.toml") - if err := WriteConfig(path, Config{TestDir: "e2e"}); err != nil { + if err := WriteConfig(path, Config{ + TestDir: "e2e", + Sandbox: map[string]string{ + "visible_home": "/home/test", + "alpha_key": "a", + "zulu_key": "z", + }, + }); err != nil { t.Fatalf("WriteConfig() error = %v", err) } @@ -98,8 +143,9 @@ func TestWriteConfig(t *testing.T) { if err != nil { t.Fatalf("ReadFile() error = %v", err) } - if string(got) != "[miro]\n test_dir = \"e2e\"\n" { - t.Fatalf("config = %q, want %q", string(got), "[miro]\n test_dir = \"e2e\"\n") + want := "[miro]\n test_dir = \"e2e\"\n\n[sandbox]\n alpha_key = \"a\"\n visible_home = \"/home/test\"\n zulu_key = \"z\"\n" + if string(got) != want { + t.Fatalf("config = %q, want %q", string(got), want) } } @@ -114,3 +160,18 @@ func TestWriteConfigEmptyTestDirFails(t *testing.T) { t.Fatalf("WriteConfig() error = %q, want %q", err.Error(), "empty miro.test_dir") } } + +func TestWriteConfigMissingRequiredSandboxFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "miro.toml") + + err := WriteConfig(path, Config{ + TestDir: "e2e", + Sandbox: map[string]string{}, + }) + if err == nil { + t.Fatal("WriteConfig() error = nil, want error") + } + if !strings.Contains(err.Error(), "missing required sandbox.visible_home") { + t.Fatalf("WriteConfig() error = %q, want visible_home error", err.Error()) + } +} diff --git a/internal/miro/init.go b/internal/miro/init.go index 67a5d0b..a8a8bc8 100644 --- a/internal/miro/init.go +++ b/internal/miro/init.go @@ -28,6 +28,7 @@ func Init() error { } else { if err := miroconfig.WriteConfig(configPath, miroconfig.Config{ TestDir: defaultTestDir, + Sandbox: miroconfig.DefaultSandboxConfig(), }); err != nil { return fmt.Errorf("failed to write %s: %v", configPath, err) } @@ -38,5 +39,5 @@ func Init() error { return err } - return writeRecordShell(testDir) + return ensureRecordShell(testDir) } diff --git a/internal/miro/record.go b/internal/miro/record.go index 3b6523e..b8bb564 100644 --- a/internal/miro/record.go +++ b/internal/miro/record.go @@ -10,7 +10,17 @@ import ( // 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) { - testDir, err := ResolveTestDir() + 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) } @@ -33,7 +43,7 @@ func Record(path string) (string, error) { in: os.Stdin, out: os.Stdout, err: os.Stderr, - }); err != nil { + }, cfg.Sandbox); err != nil { return "", err } diff --git a/internal/miro/record_session.go b/internal/miro/record_session.go index b0a0e06..5f727c5 100644 --- a/internal/miro/record_session.go +++ b/internal/miro/record_session.go @@ -12,8 +12,7 @@ import ( ) const ( - recordVisibleHome = "/home/test" - recordGitDate = "2024-01-01T00:00:00Z" + recordGitDate = "2024-01-01T00:00:00Z" ) type recordIO struct { @@ -22,7 +21,7 @@ type recordIO struct { err io.Writer } -func recordScenario(target, shellPath string, rio recordIO) error { +func recordScenario(target, shellPath string, rio recordIO, sandboxConfig map[string]string) error { rawIn, rawOut, cleanup, err := newRecordFiles() if err != nil { return err @@ -45,7 +44,7 @@ func recordScenario(target, shellPath string, rio recordIO) error { output.Fprintln(rio.err, "Run commands in the recorder shell, then type exit to finish.") - if err := runRecordSession(target, rawIn, rawOut, shellPath, sandbox, rio); err != nil { + if err := runRecordSession(target, rawIn, rawOut, shellPath, sandbox, rio, sandboxConfig); err != nil { return err } @@ -123,10 +122,11 @@ func newRecordSandboxForPathEnv(pathEnv string) (recordSandbox, func(), error) { return sandbox, cleanup, nil } -func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbox, rio recordIO) error { + +func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbox, rio recordIO, sandboxConfig map[string]string) error { cmd := exec.Command("script", "-q", "-E", "always", "-I", rawIn, "-O", rawOut, "-c", shellPath) cmd.Dir = dir - cmd.Env = recordSessionEnv(sandbox) + cmd.Env = recordSessionEnv(sandbox, sandboxConfig) cmd.Stdin = rio.in cmd.Stdout = rio.out cmd.Stderr = rio.err diff --git a/internal/miro/record_shell.go b/internal/miro/record_shell.go index f6c7637..0a61359 100644 --- a/internal/miro/record_shell.go +++ b/internal/miro/record_shell.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "text/template" ) @@ -24,6 +25,17 @@ func recordShellPath(testDir string) string { return filepath.Join(testDir, recordShellName) } +func ensureRecordShell(testDir string) error { + path := recordShellPath(testDir) + if _, err := os.Stat(path); err == nil { + return nil + } 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) @@ -59,11 +71,9 @@ func resolveRecordShell(testDir string) (string, error) { func buildRecordShellScript() string { var body bytes.Buffer if err := recordShellTemplate.Execute(&body, struct { - VisibleHome string - GitDate string + GitDate string }{ - VisibleHome: shQuote(recordVisibleHome), - GitDate: shQuote(recordGitDate), + GitDate: shQuote(recordGitDate), }); err != nil { panic(fmt.Sprintf("render record shell template: %v", err)) } @@ -71,17 +81,33 @@ func buildRecordShellScript() string { return body.String() } -func recordSessionEnv(sandbox recordSandbox) []string { +func recordSessionEnv(sandbox recordSandbox, sandboxConfig map[string]string) []string { env := append([]string{}, os.Environ()...) env = append(env, - "MIRO_RECORD_HOST_HOME="+sandbox.hostHome, - "MIRO_RECORD_HOST_TMP="+sandbox.hostTmp, - "MIRO_RECORD_PATH_ENV="+sandbox.pathEnv, + "MIRO_HOST_HOME="+sandbox.hostHome, + "MIRO_HOST_TMP="+sandbox.hostTmp, + "MIRO_PATH_ENV="+sandbox.pathEnv, ) + for _, key := range sortedSandboxKeys(sandboxConfig) { + env = append(env, sandboxEnvName(key)+"="+sandboxConfig[key]) + } return env } +func sandboxEnvName(key string) string { + return "MIRO_" + strings.ToUpper(key) +} + +func sortedSandboxKeys(sandboxConfig map[string]string) []string { + keys := make([]string, 0, len(sandboxConfig)) + for key := range sandboxConfig { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + func shQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } diff --git a/internal/miro/record_shell.sh.tmpl b/internal/miro/record_shell.sh.tmpl index d4ba002..3dd23c3 100644 --- a/internal/miro/record_shell.sh.tmpl +++ b/internal/miro/record_shell.sh.tmpl @@ -2,11 +2,12 @@ set -eu # Host directory mounted as the sandboxed HOME. -host_home=${MIRO_RECORD_HOST_HOME:?} +host_home=${MIRO_HOST_HOME:?} # Host temp directory mounted read-write inside the sandbox. -host_tmp=${MIRO_RECORD_HOST_TMP:?} +host_tmp=${MIRO_HOST_TMP:?} # PATH value forwarded into the sandbox so required tools stay available. -path_env=${MIRO_RECORD_PATH_ENV:?} +path_env=${MIRO_PATH_ENV:?} +visible_home=${MIRO_VISIBLE_HOME:?} if command -v git >/dev/null 2>&1; then HOME="$host_home" GIT_CONFIG_NOSYSTEM=1 git config --global user.name 'Miro Test' >/dev/null 2>&1 || : @@ -15,10 +16,10 @@ if command -v git >/dev/null 2>&1; then HOME="$host_home" GIT_CONFIG_NOSYSTEM=1 git config --global advice.defaultBranchName false >/dev/null 2>&1 || : fi -exec bwrap \ +set -- \ --ro-bind / / \ --tmpfs /home \ - --bind "$host_home" {{ .VisibleHome }} \ + --bind "$host_home" "$visible_home" \ --bind "$host_tmp" '/tmp' \ --dev /dev \ --proc /proc \ @@ -29,7 +30,7 @@ exec bwrap \ --setenv GIT_CONFIG_NOSYSTEM '1' \ --setenv GIT_PAGER 'cat' \ --setenv HISTFILE '/dev/null' \ - --setenv HOME {{ .VisibleHome }} \ + --setenv HOME "$visible_home" \ --setenv LANG 'C' \ --setenv LC_ALL 'C' \ --setenv PAGER 'cat' \ @@ -38,5 +39,6 @@ exec bwrap \ --setenv TERM 'xterm-256color' \ --setenv TMPDIR '/tmp' \ --setenv TZ 'UTC' \ - --chdir {{ .VisibleHome }} \ - bash --noprofile --norc -i + --chdir "$visible_home" + +exec bwrap "$@" bash --noprofile --norc -i diff --git a/internal/miro/record_test.go b/internal/miro/record_test.go index e837020..9efde6c 100644 --- a/internal/miro/record_test.go +++ b/internal/miro/record_test.go @@ -15,7 +15,7 @@ func TestRecordCreatesRelativePath(t *testing.T) { root := t.TempDir() testDir := filepath.Join(root, "e2e") mustMkdirAll(t, testDir) - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"e2e\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) mustWriteRecordShell(t, testDir) addFakeRecordDependencies(t, "script") @@ -51,7 +51,7 @@ func TestRecordAcceptsExplicitTestDirPrefix(t *testing.T) { root := t.TempDir() testDir := filepath.Join(root, "e2e") mustMkdirAll(t, testDir) - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"e2e\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) mustWriteRecordShell(t, testDir) addFakeRecordDependencies(t, "script") @@ -83,7 +83,7 @@ func TestRecordAcceptsExplicitTestDirPrefix(t *testing.T) { func TestRecordRejectsAbsolutePathOutsideTestDir(t *testing.T) { root := t.TempDir() mustMkdirAll(t, filepath.Join(root, "e2e")) - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"e2e\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) outside := filepath.Join(root, "outside", "a", "b", "c") err := withWorkingDir(t, root, func() error { @@ -110,7 +110,7 @@ func TestRecordReturnsDiscardedErrorWhenSaveDeclined(t *testing.T) { target := filepath.Join(testDir, "a", "b", "c") mustMkdirAll(t, target) return withRecordStreams(t, "n\n", func(rio recordIO) error { - return recordScenario(target, recordShellPath(testDir), rio) + return recordScenario(target, recordShellPath(testDir), rio, defaultSandboxConfig()) }) }) @@ -138,7 +138,7 @@ func TestRecordReturnsDiscardedErrorWhenOverwriteDeclined(t *testing.T) { err := withWorkingDir(t, root, func() error { return withRecordStreams(t, "n\n", func(rio recordIO) error { - return recordScenario(target, recordShellPath(testDir), rio) + return recordScenario(target, recordShellPath(testDir), rio, defaultSandboxConfig()) }) }) @@ -167,24 +167,62 @@ func TestBuildRecordShellScriptUsesExpectedCommands(t *testing.T) { body := buildRecordShellScript() for _, want := range []string{ - "host_home=${MIRO_RECORD_HOST_HOME:?}", - "host_tmp=${MIRO_RECORD_HOST_TMP:?}", - "path_env=${MIRO_RECORD_PATH_ENV:?}", + "host_home=${MIRO_HOST_HOME:?}", + "host_tmp=${MIRO_HOST_TMP:?}", + "path_env=${MIRO_PATH_ENV:?}", + "visible_home=${MIRO_VISIBLE_HOME:?}", "HOME=\"$host_home\" GIT_CONFIG_NOSYSTEM=1 git config --global user.name 'Miro Test'", - "--bind \"$host_home\" " + shQuote(recordVisibleHome), + "--bind \"$host_home\" \"$visible_home\"", "--bind \"$host_tmp\" '/tmp'", - "--setenv HOME " + shQuote(recordVisibleHome), + "--setenv HOME \"$visible_home\"", "--setenv PATH \"$path_env\"", "--setenv PS1 '$ '", "--setenv TERM 'xterm-256color'", "--setenv TZ 'UTC'", - "--chdir " + shQuote(recordVisibleHome), - "bash --noprofile --norc -i", + "--chdir \"$visible_home\"", + "exec bwrap \"$@\" bash --noprofile --norc -i", } { if !strings.Contains(body, want) { t.Fatalf("wrapper = %q, want substring %q", body, want) } } + for _, unwanted := range []string{ + "MIRO_SANDBOX_ENVS", + "set -- \"$@\" --setenv \"$env_name\" \"$env_value\"", + } { + if strings.Contains(body, unwanted) { + t.Fatalf("wrapper = %q, want no substring %q", body, unwanted) + } + } + if strings.Contains(body, "/home/test") { + t.Fatalf("wrapper = %q, want no hardcoded visible home", body) + } +} + +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", + }) + + for _, want := range []string{ + "MIRO_HOST_HOME=/tmp/host-home", + "MIRO_HOST_TMP=/tmp/host-tmp", + "MIRO_PATH_ENV=/tmp/bin", + "MIRO_KEY_WORD=value", + "MIRO_VISIBLE_HOME=/sandbox/home", + } { + if !containsEnvEntry(env, want) { + t.Fatalf("env = %#v, want entry %q", env, want) + } + } + if containsEnvEntry(env, "MIRO_SANDBOX_ENVS=MIRO_KEY_WORD:MIRO_VISIBLE_HOME") { + t.Fatalf("env = %#v, want MIRO_SANDBOX_ENVS to be absent", env) + } } func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) { @@ -205,7 +243,7 @@ func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) { 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) + return runRecordSession(t.TempDir(), filepath.Join(t.TempDir(), "raw.in"), filepath.Join(t.TempDir(), "raw.out"), shellPath, sandbox, rio, defaultSandboxConfig()) }) if err != nil { t.Fatalf("runRecordSession() error = %v", err) @@ -230,17 +268,21 @@ func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) { body := readFile(t, commandBodyPath) for _, want := range []string{ - "host_home=${MIRO_RECORD_HOST_HOME:?}", + "host_home=${MIRO_HOST_HOME:?}", + "visible_home=${MIRO_VISIBLE_HOME:?}", "--ro-bind / /", "--tmpfs /home", - "--setenv HOME " + shQuote(recordVisibleHome), + "--setenv HOME \"$visible_home\"", "--setenv TMPDIR '/tmp'", - "bash --noprofile --norc -i", + "exec bwrap \"$@\" bash --noprofile --norc -i", } { if !strings.Contains(body, want) { t.Fatalf("wrapper = %q, want substring %q", body, want) } } + if strings.Contains(body, "MIRO_SANDBOX_ENVS") { + t.Fatalf("wrapper = %q, want no MIRO_SANDBOX_ENVS reference", body) + } } func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { @@ -251,6 +293,11 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { target := filepath.Join(testDir, "suite", "spec") 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 { @@ -267,7 +314,7 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { defer close(writeDone) defer writer.Close() - if _, err := writer.Write([]byte("pwd\necho \"$HOME\"\nif [ -e /home/test/repo ]; then echo FOUND; else echo MISSING; fi\npwd\nexit\n")); err != nil { + if _, err := writer.Write([]byte("pwd\necho \"$HOME\"\necho \"$MIRO_KEY_WORD\"\nif [ -e \"$HOME/repo\" ]; then echo FOUND; else echo MISSING; fi\npwd\nexit\n")); err != nil { writeDone <- err return } @@ -287,7 +334,7 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { in: reader, out: ioDiscard{}, err: &bytes.Buffer{}, - }) + }, sandboxConfig) }) if err != nil { t.Fatalf("recordScenario() error = %v", err) @@ -300,7 +347,7 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { 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", "if [ -e /home/test/repo ]; then echo FOUND; else echo MISSING; fi\n", "exit\n"} { + for _, want := range []string{"pwd\n", "echo \"$HOME\"\n", "echo \"$MIRO_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) } @@ -310,7 +357,7 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { if strings.Contains(recordedOut, "Script started on ") { t.Fatalf("saved out = %q, want stripped script wrapper", recordedOut) } - for _, want := range []string{recordVisibleHome} { + for _, want := range []string{visibleHome, "value"} { if !strings.Contains(recordedOut, want) { t.Fatalf("saved out = %q, want substring %q", recordedOut, want) } @@ -326,7 +373,7 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { func TestRecordFailsWhenRecorderShellMissing(t *testing.T) { root := t.TempDir() testDir := filepath.Join(root, "e2e") - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"e2e\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) mustMkdirAll(t, testDir) addFakeRecordDependencies(t, "script") @@ -449,3 +496,19 @@ func mustWriteRecordShell(t *testing.T, testDir string) { 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 +} diff --git a/internal/miro/testdir.go b/internal/miro/testdir.go index 346ee14..750330d 100644 --- a/internal/miro/testdir.go +++ b/internal/miro/testdir.go @@ -22,12 +22,20 @@ func ResolveTestDir() (string, error) { } func resolveTestDirFromRoot(root string) (string, error) { - configPath := filepath.Join(root, "miro.toml") - cfg, err := miroconfig.ReadConfig(configPath) + cfg, err := readConfigFromRoot(root) if err != nil { return "", err } + return resolveTestDirFromConfig(root, cfg) +} + +func readConfigFromRoot(root string) (miroconfig.Config, error) { + configPath := filepath.Join(root, "miro.toml") + return miroconfig.ReadConfig(configPath) +} + +func resolveTestDirFromConfig(root string, cfg miroconfig.Config) (string, error) { testDir := filepath.Join(root, cfg.TestDir) info, err := os.Stat(testDir) if err == nil { diff --git a/internal/miro/testdir_test.go b/internal/miro/testdir_test.go index cc8e44d..b338431 100644 --- a/internal/miro/testdir_test.go +++ b/internal/miro/testdir_test.go @@ -19,8 +19,8 @@ func TestInitCreatesConfigAtProjectRoot(t *testing.T) { }) got := readFile(t, filepath.Join(root, "miro.toml")) - if got != "[miro]\n test_dir = \"e2e\"\n" { - t.Fatalf("config = %q, want %q", got, "[miro]\n test_dir = \"e2e\"\n") + if got != defaultWrittenConfig("e2e") { + t.Fatalf("config = %q, want %q", got, defaultWrittenConfig("e2e")) } assertRecordShell(t, filepath.Join(root, "e2e", recordShellName)) } @@ -46,7 +46,7 @@ func TestInitUsesGitRoot(t *testing.T) { func TestInitLeavesExistingValidConfigUntouched(t *testing.T) { root := t.TempDir() - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"custom/suite\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("custom/suite")) writeFile(t, filepath.Join(root, "custom", "suite", recordShellName), "outdated\n") withWorkingDir(t, root, func() struct{} { @@ -57,15 +57,17 @@ func TestInitLeavesExistingValidConfigUntouched(t *testing.T) { }) got := readFile(t, filepath.Join(root, "miro.toml")) - if got != "[miro]\ntest_dir = \"custom/suite\"\n" { - t.Fatalf("config = %q, want %q", got, "[miro]\ntest_dir = \"custom/suite\"\n") + if got != validConfigContent("custom/suite") { + t.Fatalf("config = %q, want %q", got, validConfigContent("custom/suite")) + } + if got := readFile(t, filepath.Join(root, "custom", "suite", recordShellName)); got != "outdated\n" { + t.Fatalf("shell = %q, want existing shell to stay untouched", got) } - assertRecordShell(t, filepath.Join(root, "custom", "suite", recordShellName)) } func TestInitCreatesMissingConfiguredTestDir(t *testing.T) { root := t.TempDir() - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"custom/suite\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("custom/suite")) withWorkingDir(t, root, func() struct{} { if err := Init(); err != nil { @@ -96,7 +98,7 @@ func TestResolveTestDirFromConfig(t *testing.T) { root := t.TempDir() configured := filepath.Join(root, "custom", "suite") mustMkdirAll(t, configured) - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"custom/suite\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("custom/suite")) got := withWorkingDir(t, root, func() string { path, err := ResolveTestDir() @@ -181,7 +183,7 @@ func TestResolveTestDirMalformedConfigFails(t *testing.T) { func TestResolveTestDirConfiguredMissingDirectoryReturnsConfiguredPath(t *testing.T) { root := t.TempDir() want := filepath.Join(root, "missing") - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"missing\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("missing")) got := withWorkingDir(t, root, func() string { path, err := ResolveTestDir() @@ -199,7 +201,7 @@ func TestResolveTestDirConfiguredMissingDirectoryReturnsConfiguredPath(t *testin func TestResolveTestDirConfiguredFileFails(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "case.txt"), "hello\n") - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"case.txt\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("case.txt")) err := withWorkingDir(t, root, func() error { _, err := ResolveTestDir() @@ -218,7 +220,7 @@ func TestResolveTestDirUsesGitRoot(t *testing.T) { root := t.TempDir() mustGitInit(t, root) want := filepath.Join(root, "e2e") - writeFile(t, filepath.Join(root, "miro.toml"), "[miro]\ntest_dir = \"e2e\"\n") + writeFile(t, filepath.Join(root, "miro.toml"), validConfigContent("e2e")) subdir := filepath.Join(root, "nested", "dir") mustMkdirAll(t, subdir) @@ -297,3 +299,11 @@ func assertRecordShell(t *testing.T, path string) { t.Fatalf("shell = %q, want generated recorder shell", got) } } + +func defaultWrittenConfig(testDir string) string { + return "[miro]\n test_dir = \"" + testDir + "\"\n\n[sandbox]\n visible_home = \"/home/test\"\n" +} + +func validConfigContent(testDir string) string { + return "[miro]\ntest_dir = \"" + testDir + "\"\n\n[sandbox]\nvisible_home = \"/home/test\"\n" +}