package terminal import ( "strings" "testing" "time" "github.com/gdamore/tcell/v2" "rickub.com/turbo-editors/turbo-core/theme" "rickub.com/turbo-editors/turbo-core/ui" ) // newTestView starts a view on a bare shell, closed when the test ends. // newTestViewWith returns a view on a shell, wired to the given callbacks. // // They are passed in rather than assigned afterwards because NewView starts the // goroutine that calls them — assigning them later is the data race this whole // arrangement exists to prevent. func newTestViewWith(t *testing.T, width, height int, onChange, onExit func()) *View { t.Helper() skipWithoutPTY(t) v, err := NewView(ViewOptions{ Options: Options{Shell: "/bin/sh", Dir: t.TempDir(), Width: width, Height: height}, OnChange: onChange, OnExit: onExit, }) if err != nil { t.Fatalf("NewView() error = %v", err) } t.Cleanup(func() { v.Close() }) v.SetBounds(ui.Rect{W: width, H: height}) return v } func newTestView(t *testing.T, width, height int) *View { t.Helper() skipWithoutPTY(t) v, err := NewView(ViewOptions{ Options: Options{Shell: "/bin/sh", Dir: t.TempDir(), Width: width, Height: height}, }) if err != nil { t.Fatalf("NewView() error = %v", err) } t.Cleanup(func() { v.Close() }) v.SetBounds(ui.Rect{W: width, H: height}) return v } // newOfflineView returns a view with no shell behind it. // // Drawing needs no session, and a test about what reaches the screen must not // race the shell's own startup output for the cell it is looking at — which is // exactly the flake this helper replaced. func newOfflineView(t *testing.T, width, height int) *View { t.Helper() v := &View{ parser: NewParser(NewScreen(width, height)), dirty: make(chan struct{}, 1), closed: make(chan struct{}), } v.SetFocused(true) v.FocusBox.SetBounds(ui.Rect{W: width, H: height}) return v } // waitUntil waits for a condition on the view's screen, which is checked with // the lock held. func waitUntil(t *testing.T, v *View, description string, condition func() bool) { t.Helper() deadline := time.After(10 * time.Second) for { v.mu.Lock() met := condition() shown := screenText(v.parser.Screen()) v.mu.Unlock() if met { return } select { case <-deadline: t.Fatalf("the terminal never showed %s; it shows:\n%s", description, shown) case <-time.After(5 * time.Millisecond): } } } // waitForScreen waits until some row of the view's screen holds the text. func waitForScreen(t *testing.T, v *View, want string) { t.Helper() deadline := time.After(10 * time.Second) for { v.mu.Lock() found := screenContains(v.parser.Screen(), want) shown := screenText(v.parser.Screen()) v.mu.Unlock() if found { return } select { case <-deadline: t.Fatalf("the terminal never showed %q; it shows:\n%s", want, shown) case <-time.After(5 * time.Millisecond): } } } // drawView paints a view onto a simulated screen and returns what it shows. func drawView(t *testing.T, v *View) (tcell.SimulationScreen, []string) { t.Helper() screen := tcell.NewSimulationScreen("UTF-8") if err := screen.Init(); err != nil { t.Fatalf("initialising the simulation screen: %v", err) } t.Cleanup(screen.Fini) bounds := v.Bounds() screen.SetSize(bounds.W, bounds.H) v.Draw(ui.NewPainter(screen).Sub(bounds), theme.Default("")) screen.Show() cells, width, height := screen.GetContents() lines := make([]string, height) for row := range height { var text strings.Builder for col := range width { runes := cells[row*width+col].Runes if len(runes) == 0 { text.WriteRune(' ') continue } text.WriteRune(runes[0]) } lines[row] = strings.TrimRight(text.String(), " ") } return screen, lines } func TestAViewShowsWhatTheShellWrites(t *testing.T) { v := newTestView(t, 40, 8) v.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'e', tcell.ModNone)) typeInto(v, "cho drawn-here\r") waitForScreen(t, v, "drawn-here") _, lines := drawView(t, v) if !containsLine(lines, "drawn-here") { t.Errorf("the drawn view does not show the output:\n%s", strings.Join(lines, "\n")) } } func TestTheThemeFillsInColoursTheProgramDidNotChoose(t *testing.T) { v := newOfflineView(t, 20, 4) th := theme.Default("") screen, _ := drawView(t, v) cells, width, _ := screen.GetContents() // The second row, because the cursor sits on the first one and is drawn in // a colour of its own. wantForeground, wantBackground, _ := th.Style(theme.KeyTerminalText).Decompose() got, gotBackground, _ := cells[width].Style.Decompose() if got != wantForeground || gotBackground != wantBackground { t.Errorf("an untouched cell is %v on %v, want the theme's %v on %v", got, gotBackground, wantForeground, wantBackground) } } func TestTheCursorIsDrawnInTheThemesCursorColour(t *testing.T) { v := newOfflineView(t, 20, 4) th := theme.Default("") screen, _ := drawView(t, v) cells, _, _ := screen.GetContents() wantForeground, wantBackground, _ := th.Style(theme.KeyTerminalCursor).Decompose() got, gotBackground, _ := cells[0].Style.Decompose() if got != wantForeground || gotBackground != wantBackground { t.Errorf("the cursor cell is %v on %v, want the theme's %v on %v", got, gotBackground, wantForeground, wantBackground) } } func TestAnUnfocusedTerminalDrawsNoCursor(t *testing.T) { v := newOfflineView(t, 20, 4) v.SetFocused(false) th := theme.Default("") screen, _ := drawView(t, v) cells, _, _ := screen.GetContents() // Two terminals both showing a cursor would be two claims on the keyboard. wantForeground, wantBackground, _ := th.Style(theme.KeyTerminalText).Decompose() got, gotBackground, _ := cells[0].Style.Decompose() if got != wantForeground || gotBackground != wantBackground { t.Errorf("an unfocused terminal drew its cursor: %v on %v", got, gotBackground) } } func TestAProgramsChosenColoursAreLeftAlone(t *testing.T) { v := newOfflineView(t, 20, 4) // A program that named both halves must get both back untouched, however // little they resemble the theme. v.parser.Write([]byte("\x1b[38;5;196;48;5;21mX")) //nolint:errcheck // the parser never fails screen, _ := drawView(t, v) cells, _, _ := screen.GetContents() foreground, background, _ := cells[0].Style.Decompose() if foreground != tcell.PaletteColor(196) || background != tcell.PaletteColor(21) { t.Errorf("the cell is %v on %v, want the program's own 196 on 21", foreground, background) } } func TestAProgramsOwnColoursSurvive(t *testing.T) { v := newTestView(t, 40, 6) // Waiting for the word itself would prove nothing: the pseudo-terminal // echoes the command line, so "red" appears before the command has run. // The colour is what only the output can produce. typeInto(v, "printf '\\033[31mred\\033[0m\\n'\r") waitUntil(t, v, "a red cell", func() bool { return screenHasColour(v.parser.Screen(), tcell.ColorMaroon) }) } func TestTheCursorIsDrawnOnlyWhenTheViewHasTheFocus(t *testing.T) { v := newTestView(t, 20, 4) screen, _ := drawView(t, v) if _, _, visible := screen.GetCursor(); !visible { t.Error("a focused terminal did not place the cursor") } v.SetFocused(false) screen, _ = drawView(t, v) if _, _, visible := screen.GetCursor(); visible { t.Error("an unfocused terminal placed the cursor") } } func TestResizingTellsTheShell(t *testing.T) { v := newTestView(t, 40, 8) v.SetBounds(ui.Rect{W: 100, H: 30}) typeInto(v, "stty size\r") waitForScreen(t, v, "30 100") } func TestTheViewTitleIsTheShellUntilTheProgramSaysOtherwise(t *testing.T) { v := newTestView(t, 40, 6) if got := v.Title(); got != "sh" { t.Errorf("Title() = %q, want the shell's name", got) } typeInto(v, "printf '\\033]0;my title\\007'\r") waitUntil(t, v, "the title the program set", func() bool { return v.parser.Title() == "my title" }) } func TestScrollingBackThroughTheHistory(t *testing.T) { v := newTestView(t, 40, 6) typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r") waitForScreen(t, v, "line-9") if got := v.ScrollOffset(); got != 0 { t.Fatalf("ScrollOffset() = %d, want the live screen", got) } v.ScrollBy(4) if got := v.ScrollOffset(); got == 0 { t.Fatal("ScrollBy did not move back into the history") } _, lines := drawView(t, v) if containsLine(lines, "line-9") { t.Errorf("the view still shows the newest line after scrolling back:\n%s", strings.Join(lines, "\n")) } v.ScrollToBottom() _, lines = drawView(t, v) if !containsLine(lines, "line-9") { t.Errorf("the view did not come back to the live screen:\n%s", strings.Join(lines, "\n")) } } func TestScrollingStopsAtBothEnds(t *testing.T) { v := newTestView(t, 40, 6) v.ScrollBy(-100) if got := v.ScrollOffset(); got != 0 { t.Errorf("ScrollOffset() = %d, want it held at the live screen", got) } v.ScrollBy(10_000) v.mu.Lock() history := v.parser.Screen().ScrollbackLen() v.mu.Unlock() if got := v.ScrollOffset(); got > history { t.Errorf("ScrollOffset() = %d, want no more than the %d lines of history", got, history) } } func TestShiftPageUpReadsBackAndAKeyComesForward(t *testing.T) { v := newTestView(t, 40, 6) typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r") waitForScreen(t, v, "line-9") v.HandleKey(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModShift)) if v.ScrollOffset() == 0 { t.Fatal("Shift-PageUp did not read back through the history") } // Typing must bring the view back, or the user cannot see what they type. v.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone)) if got := v.ScrollOffset(); got != 0 { t.Errorf("ScrollOffset() = %d after a key press, want the live screen", got) } } func TestPlainPageUpGoesToTheShell(t *testing.T) { // Shift is what scrolls; a bare Page Up belongs to the program, which may // well be a pager. v := newTestView(t, 40, 6) typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r") waitForScreen(t, v, "line-9") v.HandleKey(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone)) if got := v.ScrollOffset(); got != 0 { t.Errorf("ScrollOffset() = %d, want a bare Page Up to have gone to the shell", got) } } func TestTheWheelReadsBackThroughTheHistory(t *testing.T) { v := newTestView(t, 40, 6) typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r") waitForScreen(t, v, "line-9") v.HandleMouse(tcell.NewEventMouse(1, 1, tcell.WheelUp, tcell.ModNone)) if got := v.ScrollOffset(); got != wheelStep { t.Errorf("ScrollOffset() = %d, want %d", got, wheelStep) } v.HandleMouse(tcell.NewEventMouse(1, 1, tcell.WheelDown, tcell.ModNone)) if got := v.ScrollOffset(); got != 0 { t.Errorf("ScrollOffset() = %d, want it back at the live screen", got) } } func TestAMouseEventThatMissesTheViewIsNotItsBusiness(t *testing.T) { v := newTestView(t, 10, 4) if v.HandleMouse(tcell.NewEventMouse(50, 50, tcell.WheelUp, tcell.ModNone)) { t.Error("the view claimed a wheel event from outside it") } } func TestAnUnfocusedViewIgnoresKeys(t *testing.T) { v := newTestView(t, 20, 4) v.SetFocused(false) if v.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone)) { t.Error("an unfocused terminal claimed a key") } } func TestOutputArrivingWhileReadingHistoryComesForward(t *testing.T) { v := newTestView(t, 40, 6) typeInto(v, "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i; done\r") waitForScreen(t, v, "line-9") v.ScrollBy(4) typeInto(v, "echo pulled-forward\r") waitForScreen(t, v, "pulled-forward") if got := v.ScrollOffset(); got != 0 { t.Errorf("ScrollOffset() = %d, want new output to have brought the view forward", got) } } func TestTheEditorIsToldWhenTheShellExits(t *testing.T) { gone := make(chan struct{}) v := newTestViewWith(t, 20, 4, nil, func() { close(gone) }) typeInto(v, "exit\r") select { case <-gone: case <-time.After(10 * time.Second): t.Error("OnExit was never called after the shell left") } } func TestTheEditorIsAskedToRedraw(t *testing.T) { drawn := make(chan struct{}, 64) v := newTestViewWith(t, 20, 4, func() { select { case drawn <- struct{}{}: default: } }, nil) typeInto(v, "echo something\r") select { case <-drawn: case <-time.After(10 * time.Second): t.Error("the editor was never asked to redraw") } } func TestNewViewFailsWhenTheShellIsNotThere(t *testing.T) { skipWithoutPTY(t) options := ViewOptions{Options: Options{Shell: "/nonexistent/shell", Width: 20, Height: 5}} if _, err := NewView(options); err == nil { t.Fatal("NewView() error = nil for a shell that does not exist") } } // typeInto sends a string to a view one key at a time, as typing would. func typeInto(v *View, text string) { for _, r := range text { key := tcell.KeyRune if r == '\r' { key = tcell.KeyEnter r = 0 } v.HandleKey(tcell.NewEventKey(key, r, tcell.ModNone)) } } // containsLine reports whether any of the lines holds the text. func containsLine(lines []string, want string) bool { for _, line := range lines { if strings.Contains(line, want) { return true } } return false } // screenHasColour reports whether any cell is painted in a colour. func screenHasColour(s *Screen, want tcell.Color) bool { _, height := s.Size() width, _ := s.Size() for row := range height { for col := range width { if foreground, _, _ := s.CellAt(row, col).Style.Decompose(); foreground == want { return true } } } return false } func TestTheNameIsUsedWhenTheProgramSetsNoTitle(t *testing.T) { // A command run through a shell would otherwise show as "sh", which says // nothing about what is in the window. v := newOfflineView(t, 20, 4) v.name = "go test ./..." if got := v.Title(); got != "go test ./..." { t.Errorf("Title() = %q, want the name the caller gave", got) } } func TestATitleTheProgramAsksForWinsOverTheName(t *testing.T) { // vim naming the file it has open is saying something the caller could not // have known. v := newOfflineView(t, 20, 4) v.name = "go run ." v.parser.Write([]byte("\x1b]0;vim main.go\x07")) //nolint:errcheck // the parser never fails if got := v.Title(); got != "vim main.go" { t.Errorf("Title() = %q, want the program's own title", got) } } func TestAFinishedTerminalStopsTakingKeys(t *testing.T) { // Otherwise the write to the dead shell fails silently, the key is consumed // anyway, and Ctrl-W can never close the window — leaving the mouse as the // only way out of a command that has finished. v := newTestView(t, 20, 4) typeInto(v, "exit\r") waitUntil(t, v, "the shell to have gone", func() bool { return v.Exited() }) if v.HandleKey(tcell.NewEventKey(tcell.KeyCtrlW, 0, tcell.ModCtrl)) { t.Error("a finished terminal consumed Ctrl-W") } if v.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone)) { t.Error("a finished terminal consumed a printable key") } } func TestAFinishedTerminalStillScrollsItsOutput(t *testing.T) { // Reading back through what a command printed is the whole reason the // window stays open after it exits. v := newTestView(t, 20, 4) typeInto(v, "exit\r") waitUntil(t, v, "the shell to have gone", func() bool { return v.Exited() }) if !v.HandleKey(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModShift)) { t.Error("a finished terminal will not scroll back through its output") } } func TestALiveTerminalIsNotExited(t *testing.T) { v := newTestView(t, 20, 4) if v.Exited() { t.Error("a running shell reports itself gone") } }