diff --git a/.gitignore b/.gitignore index 68dede1..8068c24 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /verti/ /AGENTS.md /build/ +/e2e/ \ No newline at end of file diff --git a/cmd/record.go b/cmd/record.go index 6c755f5..2ef4faa 100644 --- a/cmd/record.go +++ b/cmd/record.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "path/filepath" "miro/internal/miro" @@ -17,6 +18,9 @@ func newRecordCommand() *cobra.Command { path := filepath.Clean(args[0]) createdPath, err := miro.Record(path) if err != nil { + if errors.Is(err, miro.ErrRecordingDiscarded) { + return nil + } return err } diff --git a/cmd/root.go b/cmd/root.go index eaafb69..85a7e6e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,7 +40,7 @@ func newRootCommand() *cobra.Command { } func ensureDependencies() error { - for _, name := range []string{"bwrap", "screen"} { + for _, name := range []string{"script"} { if _, err := exec.LookPath(name); err != nil { return fmt.Errorf("required command %q not found in PATH", name) } diff --git a/cmd/root_test.go b/cmd/root_test.go index f763742..770778a 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -12,7 +12,7 @@ import ( ) func TestRunShowsHelpWhenNoArgs(t *testing.T) { - addFakeRecordDependencies(t, "bwrap", "screen") + addFakeRecordDependencies(t, "script") stdout, stderr := captureOutput(t, func() { if got := Run(nil); got != 0 { @@ -32,7 +32,7 @@ func TestRunShowsHelpWhenNoArgs(t *testing.T) { } func TestRunInit(t *testing.T) { - addFakeRecordDependencies(t, "bwrap", "screen") + addFakeRecordDependencies(t, "script") stdout, stderr := captureOutput(t, func() { if got := Run([]string{"init"}); got != 0 { @@ -49,7 +49,7 @@ func TestRunInit(t *testing.T) { } func TestRunRecord(t *testing.T) { - addFakeRecordDependencies(t, "bwrap", "screen") + addFakeRecordDependencies(t, "script") root := t.TempDir() wantDir := filepath.Join(root, "e2e") @@ -58,6 +58,7 @@ func TestRunRecord(t *testing.T) { } withWorkingDir(t, root, func() { + withStdin(t, "y\n", func() {}) stdout, stderr := captureOutput(t, func() { if got := Run([]string{"record", "suite/spec"}); got != 0 { t.Fatalf("Run() code = %d, want %d", got, 0) @@ -68,22 +69,20 @@ func TestRunRecord(t *testing.T) { if stdout != prefixed(createdPath+"\n") { t.Fatalf("stdout = %q, want %q", stdout, prefixed(createdPath+"\n")) } - if stderr != "" { - t.Fatalf("stderr = %q, want empty", stderr) + if !strings.Contains(stderr, "Save recording?") { + t.Fatalf("stderr = %q, want save prompt", stderr) } - info, err := os.Stat(createdPath) - if err != nil { - t.Fatalf("Stat() error = %v", err) - } - if !info.IsDir() { - t.Fatalf("created path %q is not a directory", createdPath) + for _, name := range []string{"in", "out"} { + if _, err := os.Stat(filepath.Join(createdPath, name)); err != nil { + t.Fatalf("Stat(%q) error = %v", filepath.Join(createdPath, name), err) + } } }) } func TestRunRecordWithExplicitTestDirPath(t *testing.T) { - addFakeRecordDependencies(t, "bwrap", "screen") + addFakeRecordDependencies(t, "script") root := t.TempDir() wantDir := filepath.Join(root, "e2e") @@ -92,6 +91,7 @@ func TestRunRecordWithExplicitTestDirPath(t *testing.T) { } withWorkingDir(t, root, func() { + withStdin(t, "y\n", func() {}) stdout, stderr := captureOutput(t, func() { if got := Run([]string{"record", filepath.Join("e2e", "suite", "spec")}); got != 0 { t.Fatalf("Run() code = %d, want %d", got, 0) @@ -102,19 +102,19 @@ func TestRunRecordWithExplicitTestDirPath(t *testing.T) { if stdout != prefixed(createdPath+"\n") { t.Fatalf("stdout = %q, want %q", stdout, prefixed(createdPath+"\n")) } - if stderr != "" { - t.Fatalf("stderr = %q, want empty", stderr) + if !strings.Contains(stderr, "Save recording?") { + t.Fatalf("stderr = %q, want save prompt", stderr) } - if _, err := os.Stat(createdPath); err != nil { - t.Fatalf("Stat() error = %v", err) + for _, name := range []string{"in", "out"} { + if _, err := os.Stat(filepath.Join(createdPath, name)); err != nil { + t.Fatalf("Stat(%q) error = %v", filepath.Join(createdPath, name), err) + } } }) } func TestRunRecordRejectsAbsolutePathOutsideTestDir(t *testing.T) { - addFakeRecordDependencies(t, "bwrap", "screen") - root := t.TempDir() wantDir := filepath.Join(root, "e2e") if err := os.MkdirAll(wantDir, 0o755); err != nil { @@ -147,7 +147,7 @@ func TestRunFailsWhenDependenciesMissing(t *testing.T) { if err := os.MkdirAll(wantDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } - addFakeRecordDependencies(t, "screen") + t.Setenv("PATH", "") withWorkingDir(t, root, func() { stdout, stderr := captureOutput(t, func() { @@ -159,17 +159,17 @@ func TestRunFailsWhenDependenciesMissing(t *testing.T) { if stdout != "" { t.Fatalf("stdout = %q, want empty", stdout) } - if !strings.Contains(stderr, `required command "bwrap" not found in PATH`) { + if !strings.Contains(stderr, `required command "script" not found in PATH`) { t.Fatalf("stderr = %q, want missing dependency error", stderr) } - if _, err := os.Stat(filepath.Join(wantDir, "suite", "spec")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(wantDir, "suite", "spec", "in")); !os.IsNotExist(err) { t.Fatalf("Stat() error = %v, want not exists", err) } }) } func TestRunRecordMissingPath(t *testing.T) { - addFakeRecordDependencies(t, "bwrap", "screen") + addFakeRecordDependencies(t, "script") stdout, stderr := captureOutput(t, func() { if got := Run([]string{"record"}); got != 1 { @@ -189,7 +189,7 @@ func TestRunRecordMissingPath(t *testing.T) { } func TestRunInitExtraArgs(t *testing.T) { - addFakeRecordDependencies(t, "bwrap", "screen") + addFakeRecordDependencies(t, "script") stdout, stderr := captureOutput(t, func() { if got := Run([]string{"init", "extra"}); got != 1 { @@ -209,7 +209,7 @@ func TestRunInitExtraArgs(t *testing.T) { } func TestRunUnknownCommand(t *testing.T) { - addFakeRecordDependencies(t, "bwrap", "screen") + addFakeRecordDependencies(t, "script") stdout, stderr := captureOutput(t, func() { if got := Run([]string{"wat"}); got != 1 { @@ -297,10 +297,39 @@ func addFakeRecordDependencies(t *testing.T, names ...string) { binDir := t.TempDir() for _, name := range names { path := filepath.Join(binDir, name) - if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + body := "#!/bin/sh\nexit 0\n" + if name == "script" { + body = "#!/bin/sh\nin=''\nout=''\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n -I)\n in=\"$2\"\n shift 2\n ;;\n -O)\n out=\"$2\"\n shift 2\n ;;\n *)\n shift\n ;;\n esac\ndone\nprintf 'fake recorded input\\n' > \"$in\"\nprintf 'fake recorded output\\n' > \"$out\"\nexit 0\n" + } + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) } } - t.Setenv("PATH", binDir) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func withStdin(t *testing.T, input string, fn func()) { + t.Helper() + + path := filepath.Join(t.TempDir(), "stdin.txt") + if err := os.WriteFile(path, []byte(input), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } + + file, err := os.Open(path) + if err != nil { + t.Fatalf("Open(%q) error = %v", path, err) + } + + oldStdin := os.Stdin + os.Stdin = file + t.Cleanup(func() { + os.Stdin = oldStdin + if err := file.Close(); err != nil { + t.Fatalf("close stdin file: %v", err) + } + }) + + fn() } diff --git a/internal/miro/errors.go b/internal/miro/errors.go new file mode 100644 index 0000000..763faec --- /dev/null +++ b/internal/miro/errors.go @@ -0,0 +1,11 @@ +package miro + +var ( + ErrRecordingDiscarded = RecordingDiscardedError{} +) + +type RecordingDiscardedError struct{} + +func (RecordingDiscardedError) Error() string { + return "recording discarded" +} diff --git a/internal/miro/record.go b/internal/miro/record.go index 206323b..7994661 100644 --- a/internal/miro/record.go +++ b/internal/miro/record.go @@ -7,7 +7,8 @@ import ( "strings" ) -// Record creates the requested directory path under the resolved test directory. +// 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() if err != nil { @@ -23,6 +24,14 @@ func Record(path string) (string, error) { return "", err } + if err := recordScenario(target, recordIO{ + in: os.Stdin, + out: os.Stdout, + err: os.Stderr, + }); err != nil { + return "", err + } + return target, nil } diff --git a/internal/miro/record_session.go b/internal/miro/record_session.go new file mode 100644 index 0000000..0182cdc --- /dev/null +++ b/internal/miro/record_session.go @@ -0,0 +1,141 @@ +package miro + +import ( + "bufio" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "miro/internal/output" +) + +var () + +type recordIO struct { + in io.Reader + out io.Writer + err io.Writer +} + +func recordScenario(target string, rio recordIO) error { + rawIn, rawOut, cleanup, err := newRecordFiles() + if err != nil { + return err + } + defer cleanup() + + 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, rio); err != nil { + return err + } + + save, err := confirmRecordSave(rio) + if err != nil { + return err + } + if !save { + return ErrRecordingDiscarded + } + + if err := copyRecordFile(rawIn, filepath.Join(target, "in")); err != nil { + return err + } + if err := copyRecordFile(rawOut, filepath.Join(target, "out")); err != nil { + return err + } + + return nil +} + +func newRecordFiles() (string, string, func(), error) { + dir, err := os.MkdirTemp("", "miro-record-") + if err != nil { + return "", "", nil, err + } + + cleanup := func() { + _ = os.RemoveAll(dir) + } + + return filepath.Join(dir, "in"), filepath.Join(dir, "out"), cleanup, nil +} + +func runRecordSession(dir, rawIn, rawOut string, rio recordIO) error { + cmd := exec.Command("script", "-q", "-I", rawIn, "-O", rawOut) + cmd.Dir = dir + 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 copyRecordFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + + return os.WriteFile(dst, data, 0o644) +} diff --git a/internal/miro/record_test.go b/internal/miro/record_test.go index 1b22776..43ec5cd 100644 --- a/internal/miro/record_test.go +++ b/internal/miro/record_test.go @@ -1,6 +1,8 @@ package miro import ( + "bytes" + "errors" "os" "path/filepath" "testing" @@ -10,8 +12,10 @@ func TestRecordCreatesRelativePath(t *testing.T) { root := t.TempDir() testDir := filepath.Join(root, "e2e") mustMkdirAll(t, testDir) + addFakeRecordDependencies(t, "script") got := withWorkingDir(t, root, func() string { + 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) @@ -24,12 +28,10 @@ func TestRecordCreatesRelativePath(t *testing.T) { t.Fatalf("Record() = %q, want %q", got, want) } - info, err := os.Stat(want) - if err != nil { - t.Fatalf("Stat() error = %v", err) - } - if !info.IsDir() { - t.Fatalf("created path %q is not a directory", 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) + } } } @@ -37,8 +39,10 @@ func TestRecordStripsExplicitTestDirPrefix(t *testing.T) { root := t.TempDir() testDir := filepath.Join(root, "e2e") mustMkdirAll(t, testDir) + addFakeRecordDependencies(t, "script") got := withWorkingDir(t, root, func() string { + withStdin(t, "y\n", func() {}) path, err := Record(filepath.Join("e2e", "a", "b", "c")) if err != nil { t.Fatalf("Record() error = %v", err) @@ -51,8 +55,10 @@ func TestRecordStripsExplicitTestDirPrefix(t *testing.T) { t.Fatalf("Record() = %q, want %q", got, want) } - if _, err := os.Stat(want); err != nil { - t.Fatalf("Stat() error = %v", err) + 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) + } } } @@ -73,3 +79,140 @@ func TestRecordRejectsAbsolutePathOutsideTestDir(t *testing.T) { t.Fatalf("Stat(%q) error = %v, want not exists", outside, statErr) } } + +func TestRecordReturnsDiscardedErrorWhenSaveDeclined(t *testing.T) { + root := t.TempDir() + testDir := filepath.Join(root, "e2e") + mustMkdirAll(t, testDir) + addFakeRecordDependencies(t, "script") + + err := withWorkingDir(t, root, func() error { + target := filepath.Join(testDir, "a", "b", "c") + mustMkdirAll(t, target) + return withRecordStreams(t, "n\n", func(rio recordIO) error { + return recordScenario(target, rio) + }) + }) + + 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") + mustMkdirAll(t, target) + addFakeRecordDependencies(t, "script") + writeFile(t, filepath.Join(target, "in"), "existing in\n") + writeFile(t, filepath.Join(target, "out"), "existing out\n") + + err := withWorkingDir(t, root, func() error { + return withRecordStreams(t, "n\n", func(rio recordIO) error { + return recordScenario(target, rio) + }) + }) + + 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 withRecordStreams[T any](t *testing.T, input string, fn func(recordIO) T) T { + t.Helper() + + path := filepath.Join(t.TempDir(), "stdin.txt") + if err := os.WriteFile(path, []byte(input), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } + + 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{}, + }) +} + +func withStdin(t *testing.T, input string, fn func()) { + t.Helper() + + path := filepath.Join(t.TempDir(), "stdin.txt") + if err := os.WriteFile(path, []byte(input), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } + + file, err := os.Open(path) + if err != nil { + t.Fatalf("Open(%q) error = %v", path, err) + } + + oldStdin := os.Stdin + os.Stdin = file + t.Cleanup(func() { + os.Stdin = oldStdin + if err := file.Close(); err != nil { + t.Fatalf("close stdin file: %v", err) + } + }) + + fn() +} + +type ioDiscard struct{} + +func (ioDiscard) Write(p []byte) (int, error) { + return len(p), nil +} + +func addFakeRecordDependencies(t *testing.T, names ...string) { + t.Helper() + + binDir := t.TempDir() + for _, name := range names { + path := filepath.Join(binDir, name) + body := "#!/bin/sh\nexit 0\n" + if name == "script" { + body = "#!/bin/sh\nin=''\nout=''\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n -I)\n in=\"$2\"\n shift 2\n ;;\n -O)\n out=\"$2\"\n shift 2\n ;;\n *)\n shift\n ;;\n esac\ndone\nprintf 'fake recorded input\\n' > \"$in\"\nprintf 'fake recorded output\\n' > \"$out\"\nexit 0\n" + } + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } + } + + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/internal/output/output.go b/internal/output/output.go index a00bf33..caf9f2c 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -2,6 +2,8 @@ package output import ( "fmt" + "io" + "os" "strings" ) @@ -25,9 +27,17 @@ func Format(msg string) string { } func Println(msg string) { - fmt.Println(Format(msg)) + Fprintln(os.Stdout, msg) } func Printf(format string, args ...any) { - fmt.Print(Format(fmt.Sprintf(format, args...))) + Fprintf(os.Stdout, format, args...) +} + +func Fprintln(w io.Writer, msg string) { + fmt.Fprintln(w, Format(msg)) +} + +func Fprintf(w io.Writer, format string, args ...any) { + fmt.Fprint(w, Format(fmt.Sprintf(format, args...))) }