From 5b221887fa00ae561b736c0d6e89da0cc189c48c Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Wed, 25 Dec 2024 15:22:06 -0500 Subject: [PATCH] test: Generalize with-term (#533) Fixes a flaky test in commit_split while at it. --- internal/termtest/with_term.go | 161 ++------------------ internal/ui/uitest/script.go | 243 +++++++++++++++++++++++++++++++ testdata/script/commit_split.txt | 1 + 3 files changed, 256 insertions(+), 149 deletions(-) create mode 100644 internal/ui/uitest/script.go diff --git a/internal/termtest/with_term.go b/internal/termtest/with_term.go index 62d54452..105195fd 100644 --- a/internal/termtest/with_term.go +++ b/internal/termtest/with_term.go @@ -2,8 +2,6 @@ package termtest import ( - "bufio" - "bytes" "errors" "flag" "fmt" @@ -11,9 +9,6 @@ import ( "log" "os" "os/exec" - "slices" - "strconv" - "strings" "time" "github.com/creack/pty" @@ -34,22 +29,7 @@ import ( // feed Foo\r // snapshot // -// The following commands are supported. -// -// - await [txt]: -// Wait up to 1 second for the given text to become visible on the screen. -// If [txt] is absent, wait until contents of the screen change -// compared to the last captured snapshot or last await empty. -// - clear: -// Ignore current screen contents when awaiting text. -// - snapshot [name]: -// Take a picture of the screen as it is right now, and print it to stdout. -// If name is provided, the output will include that as a header. -// - feed txt: -// Feed the given string into the terminal. -// Go string-style escape codes are permitted without quotes. -// Examples: \r, \x1b[B -// +// See [uitest.Script] for more details on the script format. // The following options may be provided before the script file. // // - -cols int: terminal width (default 80) @@ -75,18 +55,12 @@ func WithTerm() (exitCode int) { return 1 } - instructions, args := args[0], args[1:] - instructionFile, err := os.Open(instructions) + scriptPath, args := args[0], args[1:] + script, err := os.ReadFile(scriptPath) if err != nil { log.Printf("cannot open instructions: %v", err) return 1 } - defer func() { - if err := instructionFile.Close(); err != nil { - log.Printf("cannot close instructions: %v", err) - exitCode = 1 - } - }() if args[0] == "--" { args = args[1:] @@ -121,130 +95,19 @@ func WithTerm() (exitCode int) { if len(*finalSnapshot) > 0 { fmt.Printf("### %s ###\n", *finalSnapshot) } - for _, line := range emu.Snapshot() { + for _, line := range emu.Rows() { fmt.Println(line) } } }() - var ( - // lastSnapshot is the last snapshot taken. - lastSnapshot []string - - // lastMatchPrefix holds the contents of the screen - // up to and including the last 'await txt' match. - awaitStripPrefix []string - ) - scan := bufio.NewScanner(instructionFile) - for scan.Scan() { - line := bytes.TrimSpace(scan.Bytes()) - if len(line) == 0 { - continue - } - - cmd, rest, _ := strings.Cut(string(line), " ") - switch cmd { - case "clear": - awaitStripPrefix = emu.Snapshot() - - case "await": - timeout := 3 * time.Second - start := time.Now() - - var match func([]string) bool - switch { - case len(rest) > 0: - want := rest - match = func(snap []string) bool { - // Strip prefix if "clear" was called. - if len(awaitStripPrefix) > 0 && len(snap) >= len(awaitStripPrefix) { - for i := 0; i < len(awaitStripPrefix); i++ { - if snap[i] != awaitStripPrefix[i] { - awaitStripPrefix = nil - break - } - } - - if len(awaitStripPrefix) > 0 { - snap = snap[len(awaitStripPrefix):] - } - } - - for _, line := range snap { - if strings.Contains(line, want) { - return true - } - } - return false - } - case len(lastSnapshot) > 0: - want := lastSnapshot - lastSnapshot = nil - match = func(snap []string) bool { - return !slices.Equal(snap, want) - } - - default: - log.Printf("await: argument is required if no snapshots were captured") - continue - } - - var ( - last []string - matched bool - ) - for time.Since(start) < timeout { - last = emu.Snapshot() - if match(last) { - matched = true - break - } - time.Sleep(50 * time.Millisecond) - } - - if !matched { - if len(rest) > 0 { - log.Printf("await: %q not found", rest) - exitCode = 1 - } else { - log.Printf("await: screen did not change") - exitCode = 1 - } - - log.Printf("### screen ###") - for _, line := range last { - log.Printf("%s", line) - } - } - - // If 'await' was given without an argument, - // save the match as the last snapshot. - if len(rest) == 0 { - lastSnapshot = last - } - - case "snapshot": - lastSnapshot = emu.Snapshot() - if len(rest) > 0 { - fmt.Printf("### %s ###\n", rest) - } - for _, line := range lastSnapshot { - fmt.Println(line) - } - - case "feed": - s := strings.ReplaceAll(rest, `"`, `\"`) - s = `"` + s + `"` - keys, err := strconv.Unquote(s) - if err != nil { - log.Printf("cannot unquote: %v", rest) - return 1 - } - - if err := emu.FeedKeys(keys); err != nil { - log.Printf("error feeding keys: %v", err) - } - } + err = uitest.Script(emu, script, &uitest.ScriptOptions{ + Logf: log.Printf, + Output: os.Stdout, + }) + if err != nil { + log.Printf("script error: %v", err) + return 1 } return exitCode @@ -329,6 +192,6 @@ func (m *terminalEmulator) FeedKeys(s string) error { return err } -func (m *terminalEmulator) Snapshot() []string { +func (m *terminalEmulator) Rows() []string { return m.emu.Rows() } diff --git a/internal/ui/uitest/script.go b/internal/ui/uitest/script.go new file mode 100644 index 00000000..e111eea3 --- /dev/null +++ b/internal/ui/uitest/script.go @@ -0,0 +1,243 @@ +package uitest + +import ( + "bufio" + "bytes" + "cmp" + "errors" + "fmt" + "io" + "slices" + "strconv" + "strings" + "time" +) + +// Emulator is a terminal emulator that receives input +// and allows querying the terminal state. +type Emulator interface { + FeedKeys(string) error + Rows() []string +} + +var _ Emulator = (*EmulatorView)(nil) + +// ScriptOptions are options for running a script against +type ScriptOptions struct { + // Logf is a function that logs messages. + // + // No logging is done if Logf is nil. + Logf func(string, ...any) + + // Output is the writer to which snapshots are written. + Output io.Writer +} + +// Script runs a UI script defining terminal interactions +// against a terminal emulator. +// +// UI scripts take the form of newline-separated commands. +// +// await Enter a name +// feed Foo\r +// snapshot +// +// The following commands are supported. +// +// - await [txt]: +// Wait up to 1 second for the given text to become visible on the screen. +// If [txt] is absent, wait until contents of the screen change +// compared to the last captured snapshot or last await empty. +// - clear: +// Ignore current screen contents when awaiting text. +// - snapshot [name]: +// Take a picture of the screen as it is right now, and print it to Output. +// If name is provided, the output will include that as a header. +// - feed txt: +// Feed the given string into the terminal. +// Go string-style escape codes are permitted without quotes. +// Examples: \r, \x1b[B +// +// Snapshots are written to [ScriptOptions.Output] if provided. +func Script(emu Emulator, script []byte, opts *ScriptOptions) error { + opts = cmp.Or(opts, &ScriptOptions{}) + + stateOpts := &scriptStateOptions{ + Logf: opts.Logf, + } + if opts.Output != nil { + stateOpts.Output = func() io.Writer { + return opts.Output + } + } + + state := newScriptState(emu, stateOpts) + scan := bufio.NewScanner(bytes.NewReader(script)) + for scan.Scan() { + line := bytes.TrimSpace(scan.Bytes()) + if len(line) == 0 { + continue + } + + if line[0] == '#' { + continue // ignore comments + } + + cmd, rest, _ := strings.Cut(string(line), " ") + switch cmd { + case "clear": + state.Clear() + + case "await": + if err := state.Await(rest); err != nil { + return fmt.Errorf("await: %w", err) + } + + case "snapshot": + state.Snapshot(rest) + + case "feed": + s := strings.ReplaceAll(rest, `"`, `\"`) + s = `"` + s + `"` + keys, err := strconv.Unquote(s) + if err != nil { + return fmt.Errorf("cannot unquote: %v", rest) + } + + if err := state.Feed(keys); err != nil { + return fmt.Errorf("feed: %w", err) + } + } + } + + return nil +} + +type scriptStateOptions struct { + Logf func(string, ...any) + Output func() io.Writer +} + +type scriptState struct { + emu Emulator + lastSnapshot []string + awaitStripPrefix []string + + logf func(string, ...any) + output func() io.Writer +} + +func newScriptState(emu Emulator, opts *scriptStateOptions) *scriptState { + opts = cmp.Or(opts, &scriptStateOptions{}) + logf := opts.Logf + if logf == nil { + logf = func(string, ...any) {} + } + + output := opts.Output + if output == nil { + output = func() io.Writer { return io.Discard } + } + + return &scriptState{ + emu: emu, + logf: logf, + output: output, + } +} + +func (s *scriptState) Clear() { + s.awaitStripPrefix = s.emu.Rows() +} + +func (s *scriptState) Await(want string) error { + timeout := 3 * time.Second + start := time.Now() + + var match func([]string) bool + switch { + case len(want) > 0: + match = func(snap []string) bool { + // Strip prefix if "clear" was called. + if len(s.awaitStripPrefix) > 0 && len(snap) >= len(s.awaitStripPrefix) { + for i := 0; i < len(s.awaitStripPrefix); i++ { + if snap[i] != s.awaitStripPrefix[i] { + s.awaitStripPrefix = nil + break + } + } + + if len(s.awaitStripPrefix) > 0 { + snap = snap[len(s.awaitStripPrefix):] + } + } + + for _, line := range snap { + if strings.Contains(line, want) { + return true + } + } + return false + } + case len(s.lastSnapshot) > 0: + want := s.lastSnapshot + s.lastSnapshot = nil + match = func(snap []string) bool { + return !slices.Equal(snap, want) + } + + default: + return errors.New("argument is required if no snapshots were captured") + } + + var ( + last []string + matched bool + ) + for time.Since(start) < timeout { + last = s.emu.Rows() + if match(last) { + matched = true + break + } + time.Sleep(50 * time.Millisecond) + } + + if !matched { + s.logf("### screen ###") + for _, line := range last { + s.logf("%s", line) + } + + if len(want) > 0 { + return fmt.Errorf("%q not found", want) + } + return errors.New("screen did not change") + } + + // If 'await' was given without an argument, + // save the match as the last snapshot. + if len(want) == 0 { + s.lastSnapshot = last + } + + return nil +} + +func (s *scriptState) Snapshot(title string) { + output := s.output() + s.lastSnapshot = s.emu.Rows() + if len(title) > 0 { + fmt.Fprintf(output, "### %s ###\n", title) + } + for _, line := range s.lastSnapshot { + fmt.Fprintln(output, line) + } +} + +func (s *scriptState) Feed(keys string) error { + if err := s.emu.FeedKeys(keys); err != nil { + return fmt.Errorf("feed keys %q: %w", keys, err) + } + return nil +} diff --git a/testdata/script/commit_split.txt b/testdata/script/commit_split.txt index cef46519..99aafbd4 100644 --- a/testdata/script/commit_split.txt +++ b/testdata/script/commit_split.txt @@ -42,6 +42,7 @@ feature 3 await feature1 snapshot first feed y\r +clear await feature2 snapshot second feed q\r