feat: move to custom screen impl
This commit is contained in:
+1
-1
@@ -40,7 +40,7 @@ func newRootCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureDependencies() error {
|
func ensureDependencies() error {
|
||||||
for _, name := range []string{"script", "bwrap", "bash"} {
|
for _, name := range []string{"bwrap", "bash"} {
|
||||||
if _, err := exec.LookPath(name); err != nil {
|
if _, err := exec.LookPath(name); err != nil {
|
||||||
return fmt.Errorf("required command %q not found in PATH", name)
|
return fmt.Errorf("required command %q not found in PATH", name)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-16
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestRunShowsHelpWhenNoArgs(t *testing.T) {
|
func TestRunShowsHelpWhenNoArgs(t *testing.T) {
|
||||||
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
|
|
||||||
stdout, stderr := testutil.CaptureOutput(t, func() {
|
stdout, stderr := testutil.CaptureOutput(t, func() {
|
||||||
if got := Run(nil); got != 0 {
|
if got := Run(nil); got != 0 {
|
||||||
@@ -31,7 +31,7 @@ func TestRunShowsHelpWhenNoArgs(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunInit(t *testing.T) {
|
func TestRunInit(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
|
|
||||||
testutil.WithWorkingDir(t, root, func() struct{} {
|
testutil.WithWorkingDir(t, root, func() struct{} {
|
||||||
stdout, stderr := testutil.CaptureOutput(t, func() {
|
stdout, stderr := testutil.CaptureOutput(t, func() {
|
||||||
@@ -62,7 +62,7 @@ func TestRunInit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunRecord(t *testing.T) {
|
func TestRunRecord(t *testing.T) {
|
||||||
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
wantDir := filepath.Join(root, "e2e")
|
wantDir := filepath.Join(root, "e2e")
|
||||||
@@ -80,16 +80,15 @@ func TestRunRecord(t *testing.T) {
|
|||||||
t.Fatalf("stderr = %q, want empty", initStderr)
|
t.Fatalf("stderr = %q, want empty", initStderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
testutil.WithStdin(t, "y\n", func() {})
|
stdout, stderr := testutil.CapturePromptedOutput(t, "exit\n", "Save recording?", "y\n", func() {
|
||||||
stdout, stderr := testutil.CaptureOutput(t, func() {
|
|
||||||
if got := Run([]string{"record", "suite/spec"}); got != 0 {
|
if got := Run([]string{"record", "suite/spec"}); got != 0 {
|
||||||
t.Fatalf("Run() code = %d, want %d", got, 0)
|
t.Fatalf("Run() code = %d, want %d", got, 0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createdPath := filepath.Join(wantDir, "suite", "spec")
|
createdPath := filepath.Join(wantDir, "suite", "spec")
|
||||||
if stdout != prefixed(createdPath+"\n") {
|
if !strings.Contains(stdout, createdPath) {
|
||||||
t.Fatalf("stdout = %q, want %q", stdout, prefixed(createdPath+"\n"))
|
t.Fatalf("stdout = %q, want created path", stdout)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr, "Save recording?") {
|
if !strings.Contains(stderr, "Save recording?") {
|
||||||
t.Fatalf("stderr = %q, want save prompt", stderr)
|
t.Fatalf("stderr = %q, want save prompt", stderr)
|
||||||
@@ -118,7 +117,7 @@ func TestRunInitFailsWhenDependenciesMissing(t *testing.T) {
|
|||||||
if stdout != "" {
|
if stdout != "" {
|
||||||
t.Fatalf("stdout = %q, want empty", stdout)
|
t.Fatalf("stdout = %q, want empty", stdout)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr, `required command "script" not found in PATH`) {
|
if !strings.Contains(stderr, `required command "bwrap" not found in PATH`) {
|
||||||
t.Fatalf("stderr = %q, want missing dependency error", stderr)
|
t.Fatalf("stderr = %q, want missing dependency error", stderr)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(root, "mire.toml")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(root, "mire.toml")); !os.IsNotExist(err) {
|
||||||
@@ -132,7 +131,7 @@ func TestRunInitFailsWhenDependenciesMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunRecordMissingPath(t *testing.T) {
|
func TestRunRecordMissingPath(t *testing.T) {
|
||||||
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
|
|
||||||
stdout, stderr := testutil.CaptureOutput(t, func() {
|
stdout, stderr := testutil.CaptureOutput(t, func() {
|
||||||
if got := Run([]string{"record"}); got != 1 {
|
if got := Run([]string{"record"}); got != 1 {
|
||||||
@@ -152,12 +151,11 @@ func TestRunRecordMissingPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunTest(t *testing.T) {
|
func TestRunTest(t *testing.T) {
|
||||||
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1")
|
|
||||||
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "a"), "echo one\n", "echo one\n")
|
testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "a"), "echo one\n", "echo one\r\n")
|
||||||
testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "nested", "b"), "echo two\n", "echo two\n")
|
testutil.WriteScenarioFixtures(t, filepath.Join(root, "e2e", "nested", "b"), "echo two\n", "echo two\r\n")
|
||||||
|
|
||||||
testutil.WithWorkingDir(t, root, func() struct{} {
|
testutil.WithWorkingDir(t, root, func() struct{} {
|
||||||
initStdout, initStderr := testutil.CaptureOutput(t, func() {
|
initStdout, initStderr := testutil.CaptureOutput(t, func() {
|
||||||
@@ -198,7 +196,7 @@ func TestRunTest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunTestExtraArgs(t *testing.T) {
|
func TestRunTestExtraArgs(t *testing.T) {
|
||||||
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
|
|
||||||
stdout, stderr := testutil.CaptureOutput(t, func() {
|
stdout, stderr := testutil.CaptureOutput(t, func() {
|
||||||
if got := Run([]string{"test", "a", "b"}); got != 1 {
|
if got := Run([]string{"test", "a", "b"}); got != 1 {
|
||||||
@@ -218,7 +216,7 @@ func TestRunTestExtraArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunInitExtraArgs(t *testing.T) {
|
func TestRunInitExtraArgs(t *testing.T) {
|
||||||
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
|
|
||||||
stdout, stderr := testutil.CaptureOutput(t, func() {
|
stdout, stderr := testutil.CaptureOutput(t, func() {
|
||||||
if got := Run([]string{"init", "extra"}); got != 1 {
|
if got := Run([]string{"init", "extra"}); got != 1 {
|
||||||
@@ -238,7 +236,7 @@ func TestRunInitExtraArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunUnknownCommand(t *testing.T) {
|
func TestRunUnknownCommand(t *testing.T) {
|
||||||
testutil.AddFakeRecordDependencies(t, "script", "bwrap", "bash")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
|
|
||||||
stdout, stderr := testutil.CaptureOutput(t, func() {
|
stdout, stderr := testutil.CaptureOutput(t, func() {
|
||||||
if got := Run([]string{"wat"}); got != 1 {
|
if got := Run([]string{"wat"}); got != 1 {
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
|
github.com/creack/pty v1.1.24
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
golang.org/x/term v0.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
@@ -9,4 +11,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
|
|||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||||
|
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ func loadRecordedOutput(path string) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hasScriptWrapper(data) {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
return stripScriptWrapper(data)
|
return stripScriptWrapper(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"mire/internal/output"
|
"mire/internal/output"
|
||||||
|
"mire/internal/screen"
|
||||||
)
|
)
|
||||||
|
|
||||||
type recordIO struct {
|
type recordIO struct {
|
||||||
@@ -120,13 +121,35 @@ func newRecordSandboxForPathEnv(pathEnv string) (recordSandbox, func(), error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbox, rio recordIO, sandboxConfig map[string]string, setupScripts []string) error {
|
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)
|
rawInFile, err := os.Create(rawIn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rawInFile.Close()
|
||||||
|
|
||||||
|
rawOutFile, err := os.Create(rawOut)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rawOutFile.Close()
|
||||||
|
|
||||||
|
cmd := exec.Command(shellPath)
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
cmd.Env = recordSessionEnv(sandbox, sandboxConfig, setupScripts)
|
cmd.Env = recordSessionEnv(sandbox, sandboxConfig, setupScripts)
|
||||||
cmd.Stdin = rio.in
|
|
||||||
cmd.Stdout = rio.out
|
var tty *os.File
|
||||||
cmd.Stderr = rio.err
|
if file, ok := rio.in.(*os.File); ok {
|
||||||
return cmd.Run()
|
tty = file
|
||||||
|
}
|
||||||
|
|
||||||
|
return screen.Record(screen.RecordRequest{
|
||||||
|
Cmd: cmd,
|
||||||
|
Input: rio.in,
|
||||||
|
Output: rio.out,
|
||||||
|
TTY: tty,
|
||||||
|
InputLog: rawInFile,
|
||||||
|
OutputLog: rawOutFile,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmRecordOverwrite(target string, rio recordIO) (bool, error) {
|
func confirmRecordOverwrite(target string, rio recordIO) (bool, error) {
|
||||||
|
|||||||
+40
-101
@@ -1,13 +1,11 @@
|
|||||||
package mire
|
package mire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"mire/internal/testutil"
|
"mire/internal/testutil"
|
||||||
)
|
)
|
||||||
@@ -18,15 +16,18 @@ func TestRecordCreatesRelativePath(t *testing.T) {
|
|||||||
testutil.MustMkdirAll(t, testDir)
|
testutil.MustMkdirAll(t, testDir)
|
||||||
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
|
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
|
||||||
mustWriteRecordShell(t, testDir)
|
mustWriteRecordShell(t, testDir)
|
||||||
testutil.AddFakeRecordDependencies(t, "script")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
|
|
||||||
got := testutil.WithWorkingDir(t, root, func() string {
|
var got string
|
||||||
testutil.WithStdin(t, "y\n", func() {})
|
testutil.WithWorkingDir(t, root, func() struct{} {
|
||||||
|
testutil.CapturePromptedOutput(t, "exit\n", "Save recording?", "y\n", func() {
|
||||||
path, err := Record(filepath.Join("a", "b", "c") + string(os.PathSeparator))
|
path, err := Record(filepath.Join("a", "b", "c") + string(os.PathSeparator))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Record() error = %v", err)
|
t.Fatalf("Record() error = %v", err)
|
||||||
}
|
}
|
||||||
return path
|
got = path
|
||||||
|
})
|
||||||
|
return struct{}{}
|
||||||
})
|
})
|
||||||
|
|
||||||
want := filepath.Join(testDir, "a", "b", "c")
|
want := filepath.Join(testDir, "a", "b", "c")
|
||||||
@@ -40,11 +41,11 @@ func TestRecordCreatesRelativePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := testutil.ReadFile(t, filepath.Join(want, "in")); got != "fake recorded input\n" {
|
if got := testutil.ReadFile(t, filepath.Join(want, "in")); got != "exit\n" {
|
||||||
t.Fatalf("saved in = %q, want %q", got, "fake recorded input\n")
|
t.Fatalf("saved in = %q, want %q", got, "exit\n")
|
||||||
}
|
}
|
||||||
if got := testutil.ReadFile(t, filepath.Join(want, "out")); got != "fake recorded output\n" {
|
if got := testutil.ReadFile(t, filepath.Join(want, "out")); got != "exit\r\nexit\r\n" {
|
||||||
t.Fatalf("saved out = %q, want %q", got, "fake recorded output\n")
|
t.Fatalf("saved out = %q, want %q", got, "exit\\r\\nexit\\r\\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,12 +54,12 @@ func TestRecordReturnsDiscardedErrorWhenSaveDeclined(t *testing.T) {
|
|||||||
testDir := filepath.Join(root, "e2e")
|
testDir := filepath.Join(root, "e2e")
|
||||||
testutil.MustMkdirAll(t, testDir)
|
testutil.MustMkdirAll(t, testDir)
|
||||||
mustWriteRecordShell(t, testDir)
|
mustWriteRecordShell(t, testDir)
|
||||||
testutil.AddFakeRecordDependencies(t, "script")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
|
|
||||||
err := testutil.WithWorkingDir(t, root, func() error {
|
err := testutil.WithWorkingDir(t, root, func() error {
|
||||||
target := filepath.Join(testDir, "a", "b", "c")
|
target := filepath.Join(testDir, "a", "b", "c")
|
||||||
testutil.MustMkdirAll(t, target)
|
testutil.MustMkdirAll(t, target)
|
||||||
return withRecordStreams(t, "n\n", func(rio recordIO) error {
|
return withRecordStreams(t, "exit\nn\n", func(rio recordIO) error {
|
||||||
return recordScenario(target, recordShellPath(testDir), rio, defaultSandboxConfig(), nil)
|
return recordScenario(target, recordShellPath(testDir), rio, defaultSandboxConfig(), nil)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -81,7 +82,6 @@ func TestRecordReturnsDiscardedErrorWhenOverwriteDeclined(t *testing.T) {
|
|||||||
target := filepath.Join(testDir, "a", "b", "c")
|
target := filepath.Join(testDir, "a", "b", "c")
|
||||||
testutil.MustMkdirAll(t, target)
|
testutil.MustMkdirAll(t, target)
|
||||||
mustWriteRecordShell(t, testDir)
|
mustWriteRecordShell(t, testDir)
|
||||||
testutil.AddFakeRecordDependencies(t, "script")
|
|
||||||
testutil.WriteFile(t, filepath.Join(target, "in"), "existing in\n")
|
testutil.WriteFile(t, filepath.Join(target, "in"), "existing in\n")
|
||||||
testutil.WriteFile(t, filepath.Join(target, "out"), "existing out\n")
|
testutil.WriteFile(t, filepath.Join(target, "out"), "existing out\n")
|
||||||
|
|
||||||
@@ -204,15 +204,12 @@ func TestRecordSessionEnvWithExtraIncludesAdditionalEntries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) {
|
func TestRunRecordSessionCapturesInputAndOutput(t *testing.T) {
|
||||||
testDir := filepath.Join(t.TempDir(), "e2e")
|
shellPath := filepath.Join(t.TempDir(), "shell.sh")
|
||||||
mustWriteRecordShell(t, testDir)
|
testutil.WriteFile(t, shellPath, "#!/bin/sh\nprintf 'ready\\n'\nread line\nprintf 'seen:%s\\n' \"$line\"\n")
|
||||||
testutil.AddFakeRecordDependencies(t, "script")
|
if err := os.Chmod(shellPath, 0o755); err != nil {
|
||||||
|
t.Fatalf("Chmod(%q) error = %v", shellPath, err)
|
||||||
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"))
|
sandbox, cleanup, err := newRecordSandboxForPathEnv(os.Getenv("PATH"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,57 +217,29 @@ func TestRunRecordSessionUsesSandboxedScriptCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
shellPath := recordShellPath(testDir)
|
rawIn := filepath.Join(t.TempDir(), "raw.in")
|
||||||
err = withRecordStreams(t, "", func(rio recordIO) error {
|
rawOut := filepath.Join(t.TempDir(), "raw.out")
|
||||||
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"})
|
err = withRecordStreams(t, "hello\n", func(rio recordIO) error {
|
||||||
|
return runRecordSession(t.TempDir(), rawIn, rawOut, shellPath, sandbox, rio, defaultSandboxConfig(), nil)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("runRecordSession() error = %v", err)
|
t.Fatalf("runRecordSession() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
args := strings.Split(strings.TrimSpace(testutil.ReadFile(t, argsPath)), "\n")
|
if got := testutil.ReadFile(t, rawIn); got != "hello\n" {
|
||||||
if len(args) != 9 {
|
t.Fatalf("raw in = %q, want %q", got, "hello\n")
|
||||||
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)
|
rawOutput := testutil.ReadFile(t, rawOut)
|
||||||
for _, want := range []string{
|
for _, want := range []string{"ready", "seen:hello"} {
|
||||||
"host_home=${MIRE_HOST_HOME:?}",
|
if !strings.Contains(rawOutput, want) {
|
||||||
"visible_home=${MIRE_VISIBLE_HOME:?}",
|
t.Fatalf("raw out = %q, want substring %q", rawOutput, want)
|
||||||
"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) {
|
func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) {
|
||||||
testutil.RequireCommands(t, "script", "bwrap", "bash")
|
testutil.RequireCommands(t, "bwrap", "bash")
|
||||||
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
testDir := filepath.Join(root, "e2e")
|
testDir := filepath.Join(root, "e2e")
|
||||||
@@ -283,49 +252,20 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
visibleHome := sandboxConfig["visible_home"]
|
visibleHome := sandboxConfig["visible_home"]
|
||||||
|
|
||||||
reader, writer, err := os.Pipe()
|
err := testutil.WithWorkingDir(t, root, func() error {
|
||||||
if err != nil {
|
return withPromptedRecordStreams(
|
||||||
t.Fatalf("os.Pipe() error = %v", err)
|
t,
|
||||||
}
|
"pwd\necho \"$HOME\"\necho \"$MIRE_KEY_WORD\"\nif [ -e \"$HOME/repo\" ]; then echo FOUND; else echo MISSING; fi\npwd\nexit\n",
|
||||||
t.Cleanup(func() {
|
"y\n",
|
||||||
if err := reader.Close(); err != nil {
|
func(rio recordIO) error {
|
||||||
t.Fatalf("close pipe reader: %v", err)
|
rio.out = ioDiscard{}
|
||||||
}
|
return recordScenario(target, recordShellPath(testDir), rio, sandboxConfig, nil)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
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 {
|
if err != nil {
|
||||||
t.Fatalf("recordScenario() error = %v", err)
|
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"))
|
recordedIn := testutil.ReadFile(t, filepath.Join(target, "in"))
|
||||||
if strings.Contains(recordedIn, "Script started on ") {
|
if strings.Contains(recordedIn, "Script started on ") {
|
||||||
@@ -359,7 +299,6 @@ func TestRecordFailsWhenRecorderShellMissing(t *testing.T) {
|
|||||||
testDir := filepath.Join(root, "e2e")
|
testDir := filepath.Join(root, "e2e")
|
||||||
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
|
testutil.WriteFile(t, filepath.Join(root, "mire.toml"), testutil.ValidConfigContent("e2e"))
|
||||||
testutil.MustMkdirAll(t, testDir)
|
testutil.MustMkdirAll(t, testDir)
|
||||||
testutil.AddFakeRecordDependencies(t, "script")
|
|
||||||
|
|
||||||
target := filepath.Join(testDir, "suite", "spec")
|
target := filepath.Join(testDir, "suite", "spec")
|
||||||
err := testutil.WithWorkingDir(t, root, func() error {
|
err := testutil.WithWorkingDir(t, root, func() error {
|
||||||
|
|||||||
+16
-5
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mire/internal/output"
|
"mire/internal/output"
|
||||||
|
"mire/internal/screen"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testIO struct {
|
type testIO struct {
|
||||||
@@ -242,18 +243,28 @@ func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxCo
|
|||||||
}
|
}
|
||||||
defer cleanupSandbox()
|
defer cleanupSandbox()
|
||||||
|
|
||||||
cmd := exec.Command("script", "-q", "-E", "always", "-O", rawOut, "-c", shellPath)
|
rawOutFile, err := os.Create(rawOut)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare replay output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(shellPath)
|
||||||
cmd.Dir = scenario.dir
|
cmd.Dir = scenario.dir
|
||||||
cmd.Env = recordSessionEnvWithExtra(sandbox, sandboxConfig, scenario.setupScripts, map[string]string{
|
cmd.Env = recordSessionEnvWithExtra(sandbox, sandboxConfig, scenario.setupScripts, map[string]string{
|
||||||
compareMarkerEnvName: compareMarkerEnabledValue,
|
compareMarkerEnvName: compareMarkerEnabledValue,
|
||||||
})
|
})
|
||||||
cmd.Stdin = bytes.NewReader(input)
|
|
||||||
cmd.Stdout = io.Discard
|
|
||||||
cmd.Stderr = io.Discard
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := screen.Replay(screen.ReplayRequest{
|
||||||
|
Cmd: cmd,
|
||||||
|
Input: input,
|
||||||
|
OutputLog: rawOutFile,
|
||||||
|
}); err != nil {
|
||||||
|
rawOutFile.Close()
|
||||||
return fmt.Errorf("replay failed: %v", err)
|
return fmt.Errorf("replay failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := rawOutFile.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close replay output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
got, err := loadRecordedOutput(rawOut)
|
got, err := loadRecordedOutput(rawOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package mire
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -90,17 +91,13 @@ func TestDiscoverTestScenariosRejectsMissingInFixture(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) {
|
func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T) {
|
||||||
testutil.AddFakeRecordDependencies(t, "script")
|
testutil.AddFakeRecordDependencies(t, "bwrap", "bash")
|
||||||
t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1")
|
|
||||||
|
|
||||||
testDir := filepath.Join(t.TempDir(), "e2e")
|
testDir := filepath.Join(t.TempDir(), "e2e")
|
||||||
shellPath := filepath.Join(testDir, "shell.sh")
|
shellPath := filepath.Join(testDir, "shell.sh")
|
||||||
mustWriteRecordShell(t, testDir)
|
mustWriteRecordShell(t, testDir)
|
||||||
scenarioDir := filepath.Join(testDir, "suite", "spec")
|
scenarioDir := filepath.Join(testDir, "suite", "spec")
|
||||||
testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\nexit\n")
|
testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\r\nexit\r\n")
|
||||||
|
|
||||||
capturedInput := filepath.Join(t.TempDir(), "stdin.capture")
|
|
||||||
t.Setenv("FAKE_SCRIPT_CAPTURE_STDIN_FILE", capturedInput)
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -118,9 +115,6 @@ func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T)
|
|||||||
t.Fatalf("replayScenario() error = %v", err)
|
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() != "" {
|
if stdout.String() != "" {
|
||||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
}
|
}
|
||||||
@@ -130,12 +124,12 @@ func TestReplayScenarioUsesRecordedInputAndKeepsReplayOutputQuiet(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) {
|
func TestReplayScenarioFailsWhenCompareMarkerMissing(t *testing.T) {
|
||||||
testutil.AddFakeRecordDependencies(t, "script")
|
|
||||||
t.Setenv("FAKE_SCRIPT_ECHO_STDIN", "1")
|
|
||||||
|
|
||||||
testDir := filepath.Join(t.TempDir(), "e2e")
|
testDir := filepath.Join(t.TempDir(), "e2e")
|
||||||
shellPath := filepath.Join(testDir, "shell.sh")
|
shellPath := filepath.Join(testDir, "shell.sh")
|
||||||
testutil.WriteFile(t, shellPath, "#!/bin/sh\n")
|
testutil.WriteFile(t, shellPath, "#!/bin/sh\n")
|
||||||
|
if err := os.Chmod(shellPath, 0o755); err != nil {
|
||||||
|
t.Fatalf("Chmod(%q) error = %v", shellPath, err)
|
||||||
|
}
|
||||||
scenarioDir := filepath.Join(testDir, "suite", "spec")
|
scenarioDir := filepath.Join(testDir, "suite", "spec")
|
||||||
testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\nexit\n")
|
testutil.WriteScenarioFixtures(t, scenarioDir, "echo replay\nexit\n", "echo replay\nexit\n")
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"mire/internal/testutil"
|
"mire/internal/testutil"
|
||||||
@@ -50,6 +51,56 @@ func withRecordStreams[T any](t *testing.T, input string, fn func(recordIO) T) T
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withPromptedRecordStreams[T any](t *testing.T, sessionInput, promptInput string, fn func(recordIO) T) T {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
reader, writer, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.Pipe() error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = reader.Close()
|
||||||
|
_ = writer.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
errWriter := &promptSignalBuffer{
|
||||||
|
marker: "Save recording?",
|
||||||
|
ready: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
writeDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(writeDone)
|
||||||
|
defer writer.Close()
|
||||||
|
|
||||||
|
if _, err := writer.Write([]byte(sessionInput)); err != nil {
|
||||||
|
writeDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
<-errWriter.ready
|
||||||
|
|
||||||
|
if _, err := writer.Write([]byte(promptInput)); err != nil {
|
||||||
|
writeDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeDone <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
result := fn(recordIO{
|
||||||
|
in: reader,
|
||||||
|
out: ioDiscard{},
|
||||||
|
err: errWriter,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := <-writeDone; err != nil {
|
||||||
|
t.Fatalf("write record input: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
type ioDiscard struct{}
|
type ioDiscard struct{}
|
||||||
|
|
||||||
func (ioDiscard) Write(p []byte) (int, error) {
|
func (ioDiscard) Write(p []byte) (int, error) {
|
||||||
@@ -90,3 +141,20 @@ func containsEnvKey(env []string, key string) bool {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type promptSignalBuffer struct {
|
||||||
|
bytes.Buffer
|
||||||
|
marker string
|
||||||
|
ready chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *promptSignalBuffer) Write(p []byte) (int, error) {
|
||||||
|
n, err := b.Buffer.Write(p)
|
||||||
|
if strings.Contains(b.Buffer.String(), b.marker) {
|
||||||
|
b.once.Do(func() {
|
||||||
|
close(b.ready)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package screen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultRows = 24
|
||||||
|
defaultCols = 80
|
||||||
|
terminalEOF = byte(0x04)
|
||||||
|
)
|
||||||
|
|
||||||
|
type RecordRequest struct {
|
||||||
|
Cmd *exec.Cmd
|
||||||
|
Input io.Reader
|
||||||
|
Output io.Writer
|
||||||
|
TTY *os.File
|
||||||
|
InputLog io.Writer
|
||||||
|
OutputLog io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplayRequest struct {
|
||||||
|
Cmd *exec.Cmd
|
||||||
|
Input []byte
|
||||||
|
OutputLog io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func Record(req RecordRequest) error {
|
||||||
|
if req.Cmd == nil {
|
||||||
|
return errors.New("record session command is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
ptmx, err := pty.StartWithSize(req.Cmd, sessionSize(req.TTY))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer ptmx.Close()
|
||||||
|
|
||||||
|
restoreTTY, err := makeRaw(req.TTY)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer restoreTTY()
|
||||||
|
|
||||||
|
stopResize := watchResize(req.TTY, ptmx)
|
||||||
|
defer stopResize()
|
||||||
|
|
||||||
|
input, closeInput, err := duplicateInput(req.Input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeInput()
|
||||||
|
|
||||||
|
outputDone := copyAsync(combineWriters(req.Output, req.OutputLog), ptmx)
|
||||||
|
inputDone, stopInput, err := copyInputAsync(combineWriters(ptmx, newInputLogWriter(req.InputLog)), input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stopInput()
|
||||||
|
|
||||||
|
waitErr := req.Cmd.Wait()
|
||||||
|
stopInput()
|
||||||
|
ptmx.Close()
|
||||||
|
|
||||||
|
outputErr := <-outputDone
|
||||||
|
inputErr := <-inputDone
|
||||||
|
|
||||||
|
return firstErr(waitErr, outputErr, inputErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Replay(req ReplayRequest) error {
|
||||||
|
if req.Cmd == nil {
|
||||||
|
return errors.New("replay session command is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
ptmx, err := pty.StartWithSize(req.Cmd, sessionSize(nil))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer ptmx.Close()
|
||||||
|
|
||||||
|
outputDone := copyAsync(combineWriters(req.OutputLog), ptmx)
|
||||||
|
inputDone := copyAsync(ptmx, bytes.NewReader(replayInput(req.Input)))
|
||||||
|
|
||||||
|
waitErr := req.Cmd.Wait()
|
||||||
|
ptmx.Close()
|
||||||
|
|
||||||
|
outputErr := <-outputDone
|
||||||
|
inputErr := <-inputDone
|
||||||
|
|
||||||
|
return firstErr(waitErr, outputErr, inputErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func combineWriters(writers ...io.Writer) io.Writer {
|
||||||
|
active := make([]io.Writer, 0, len(writers))
|
||||||
|
for _, writer := range writers {
|
||||||
|
if writer != nil {
|
||||||
|
active = append(active, writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(active) {
|
||||||
|
case 0:
|
||||||
|
return io.Discard
|
||||||
|
case 1:
|
||||||
|
return active[0]
|
||||||
|
default:
|
||||||
|
return io.MultiWriter(active...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyAsync(dst io.Writer, src io.Reader) <-chan error {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(dst, src)
|
||||||
|
done <- normalizeCopyError(err)
|
||||||
|
}()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
func replayInput(input []byte) []byte {
|
||||||
|
if len(input) > 0 && input[len(input)-1] == terminalEOF {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]byte, 0, len(input)+1)
|
||||||
|
data = append(data, input...)
|
||||||
|
data = append(data, terminalEOF)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyInputAsync(dst io.Writer, src io.Reader) (<-chan error, func(), error) {
|
||||||
|
file, ok := src.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
done := copyAsync(dst, src)
|
||||||
|
return done, func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stopReader, stopWriter, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer stopReader.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
fds := []unix.PollFd{
|
||||||
|
{Fd: int32(file.Fd()), Events: unix.POLLIN | unix.POLLHUP},
|
||||||
|
{Fd: int32(stopReader.Fd()), Events: unix.POLLIN | unix.POLLHUP},
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, err := unix.Poll(fds, -1)
|
||||||
|
if err != nil {
|
||||||
|
if err == unix.EINTR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fds[1].Revents != 0 {
|
||||||
|
done <- nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fds[0].Revents&(unix.POLLIN|unix.POLLHUP) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
n, readErr := file.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
|
||||||
|
done <- normalizeCopyError(writeErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
done <- normalizeCopyError(readErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop := func() {
|
||||||
|
_ = stopWriter.Close()
|
||||||
|
_ = file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return done, stop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCopyError(err error) error {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, os.ErrClosed):
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, syscall.EIO):
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstErr(errs ...error) error {
|
||||||
|
for _, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionSize(tty *os.File) *pty.Winsize {
|
||||||
|
if tty != nil && term.IsTerminal(int(tty.Fd())) {
|
||||||
|
if cols, rows, err := term.GetSize(int(tty.Fd())); err == nil {
|
||||||
|
return &pty.Winsize{
|
||||||
|
Rows: uint16(rows),
|
||||||
|
Cols: uint16(cols),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pty.Winsize{
|
||||||
|
Rows: defaultRows,
|
||||||
|
Cols: defaultCols,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeRaw(tty *os.File) (func(), error) {
|
||||||
|
if tty == nil || !term.IsTerminal(int(tty.Fd())) {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := term.MakeRaw(int(tty.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
_ = term.Restore(int(tty.Fd()), state)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchResize(tty *os.File, ptmx *os.File) func() {
|
||||||
|
if tty == nil || !term.IsTerminal(int(tty.Fd())) {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
applySize := func() {
|
||||||
|
_ = pty.Setsize(ptmx, sessionSize(tty))
|
||||||
|
}
|
||||||
|
applySize()
|
||||||
|
|
||||||
|
signals := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signals, syscall.SIGWINCH)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-signals:
|
||||||
|
applySize()
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
signal.Stop(signals)
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateInput(input io.Reader) (io.Reader, func(), error) {
|
||||||
|
if input == nil {
|
||||||
|
return bytes.NewReader(nil), func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, ok := input.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
return input, func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fd, err := syscall.Dup(int(file.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dup := os.NewFile(uintptr(fd), file.Name())
|
||||||
|
return dup, func() {
|
||||||
|
_ = dup.Close()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputLogWriter struct {
|
||||||
|
dst io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInputLogWriter(dst io.Writer) io.Writer {
|
||||||
|
if dst == nil {
|
||||||
|
return io.Discard
|
||||||
|
}
|
||||||
|
return inputLogWriter{dst: dst}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w inputLogWriter) Write(p []byte) (int, error) {
|
||||||
|
normalized := make([]byte, len(p))
|
||||||
|
for i, b := range p {
|
||||||
|
if b == '\r' {
|
||||||
|
normalized[i] = '\n'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized[i] = b
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.dst.Write(normalized); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package screen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecordCopiesInputAndOutput(t *testing.T) {
|
||||||
|
reader, writer, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.Pipe() error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = reader.Close()
|
||||||
|
_ = writer.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, _ = writer.Write([]byte("hello\n"))
|
||||||
|
_ = writer.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
cmd := exec.Command("sh", "-c", `printf 'ready\n'; read line; printf 'line:%s\n' "$line"`)
|
||||||
|
|
||||||
|
var live bytes.Buffer
|
||||||
|
var inputLog bytes.Buffer
|
||||||
|
var outputLog bytes.Buffer
|
||||||
|
if err := Record(RecordRequest{
|
||||||
|
Cmd: cmd,
|
||||||
|
Input: reader,
|
||||||
|
Output: &live,
|
||||||
|
InputLog: &inputLog,
|
||||||
|
OutputLog: &outputLog,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Record() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := inputLog.String(); got != "hello\n" {
|
||||||
|
t.Fatalf("input log = %q, want %q", got, "hello\n")
|
||||||
|
}
|
||||||
|
for _, got := range []string{live.String(), outputLog.String()} {
|
||||||
|
if !bytes.Contains([]byte(got), []byte("ready")) || !bytes.Contains([]byte(got), []byte("line:hello")) {
|
||||||
|
t.Fatalf("output = %q, want ready + line:hello", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayCapturesOutput(t *testing.T) {
|
||||||
|
cmd := exec.Command("sh", "-c", `read line; printf '__MIRE_E2E_BEGIN__\nline:%s\n' "$line"`)
|
||||||
|
|
||||||
|
var outputLog bytes.Buffer
|
||||||
|
if err := Replay(ReplayRequest{
|
||||||
|
Cmd: cmd,
|
||||||
|
Input: []byte("hello\n"),
|
||||||
|
OutputLog: &outputLog,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Replay() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := outputLog.String()
|
||||||
|
if !bytes.Contains([]byte(got), []byte("__MIRE_E2E_BEGIN__")) || !bytes.Contains([]byte(got), []byte("line:hello")) {
|
||||||
|
t.Fatalf("output log = %q, want marker + line:hello", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +55,113 @@ func CaptureOutput(t *testing.T, fn func()) (string, string) {
|
|||||||
return stdoutBuf.String(), stderrBuf.String()
|
return stdoutBuf.String(), stderrBuf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CapturePromptedOutput(t *testing.T, sessionInput, promptMarker, promptInput string, fn func()) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("NO_COLOR", "1")
|
||||||
|
|
||||||
|
stdinReader, stdinWriter, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.Pipe() stdin error = %v", err)
|
||||||
|
}
|
||||||
|
stdoutReader, stdoutWriter, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.Pipe() stdout error = %v", err)
|
||||||
|
}
|
||||||
|
stderrReader, stderrWriter, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.Pipe() stderr error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
oldStderr := os.Stderr
|
||||||
|
os.Stdin = stdinReader
|
||||||
|
os.Stdout = stdoutWriter
|
||||||
|
os.Stderr = stderrWriter
|
||||||
|
t.Cleanup(func() {
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
os.Stderr = oldStderr
|
||||||
|
})
|
||||||
|
|
||||||
|
var stdinOnce sync.Once
|
||||||
|
closeStdin := func() {
|
||||||
|
stdinOnce.Do(func() {
|
||||||
|
_ = stdinWriter.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Cleanup(closeStdin)
|
||||||
|
|
||||||
|
var stdoutBuf bytes.Buffer
|
||||||
|
stdoutDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(&stdoutBuf, stdoutReader)
|
||||||
|
stdoutDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
var stderrBuf bytes.Buffer
|
||||||
|
var promptOnce sync.Once
|
||||||
|
stderrDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, readErr := stderrReader.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, err := stderrBuf.Write(buf[:n]); err != nil {
|
||||||
|
stderrDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if promptMarker != "" && strings.Contains(stderrBuf.String(), promptMarker) {
|
||||||
|
promptOnce.Do(func() {
|
||||||
|
if _, err := stdinWriter.Write([]byte(promptInput)); err != nil {
|
||||||
|
stderrDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeStdin()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if readErr == io.EOF {
|
||||||
|
stderrDone <- nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stderrDone <- readErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if _, err := stdinWriter.Write([]byte(sessionInput)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if promptMarker == "" {
|
||||||
|
closeStdin()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
fn()
|
||||||
|
|
||||||
|
closeStdin()
|
||||||
|
if err := stdoutWriter.Close(); err != nil {
|
||||||
|
t.Fatalf("stdout close error = %v", err)
|
||||||
|
}
|
||||||
|
if err := stderrWriter.Close(); err != nil {
|
||||||
|
t.Fatalf("stderr close error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-stdoutDone; err != nil {
|
||||||
|
t.Fatalf("stdout copy error = %v", err)
|
||||||
|
}
|
||||||
|
if err := <-stderrDone; err != nil {
|
||||||
|
t.Fatalf("stderr copy error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdoutBuf.String(), stderrBuf.String()
|
||||||
|
}
|
||||||
|
|
||||||
func WriteFile(t *testing.T, path, content string) {
|
func WriteFile(t *testing.T, path, content string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -233,6 +342,61 @@ if [ -n "${FAKE_SCRIPT_EXIT_CODE:-}" ]; then
|
|||||||
exit "$FAKE_SCRIPT_EXIT_CODE"
|
exit "$FAKE_SCRIPT_EXIT_CODE"
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
|
`
|
||||||
|
}
|
||||||
|
if name == "bwrap" {
|
||||||
|
body = `#!/bin/sh
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ro-bind|--bind|--setenv)
|
||||||
|
shift 3
|
||||||
|
;;
|
||||||
|
--tmpfs|--chdir)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--dev|--proc)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--unshare-pid|--die-with-parent)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
`
|
||||||
|
}
|
||||||
|
if name == "bash" {
|
||||||
|
body = `#!/bin/sh
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--noprofile|--norc|-i)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--rcfile)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
/bin/stty -echo 2>/dev/null || true
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
printf '%s\n' "$line"
|
||||||
|
if [ "$line" = "exit" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
||||||
@@ -240,7 +404,12 @@ exit 0
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldPath := os.Getenv("PATH")
|
||||||
|
if oldPath == "" {
|
||||||
t.Setenv("PATH", binDir)
|
t.Setenv("PATH", binDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+oldPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RequireCommands(t *testing.T, names ...string) {
|
func RequireCommands(t *testing.T, names ...string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user