package terminal import ( "strings" "testing" "github.com/gdamore/tcell/v2" ) // feed writes a byte stream to a new parser and returns the screen it drew on. func feed(width, height int, stream string) *Screen { screen := NewScreen(width, height) NewParser(screen).Write([]byte(stream)) //nolint:errcheck // Write never fails return screen } func TestPlainTextIsWritten(t *testing.T) { s := feed(20, 3, "hello") wantLines(t, s, "hello") } func TestWriteAlwaysConsumesEverything(t *testing.T) { // A terminal has nothing useful to do with a byte it does not understand // except carry on, so Write can neither fail nor stop short. p := NewParser(NewScreen(10, 2)) stream := []byte("a\x1b[1mb\x1b]0;title\x07c\xff") n, err := p.Write(stream) if err != nil { t.Errorf("Write() error = %v, want nil", err) } if n != len(stream) { t.Errorf("Write() consumed %d of %d bytes", n, len(stream)) } } func TestControlCharacters(t *testing.T) { tests := []struct { name string stream string lines []string }{ {"carriage return", "abc\rX", []string{"Xbc"}}, {"line feed", "a\nb", []string{"a", " b"}}, {"backspace moves without erasing", "abc\bX", []string{"abX"}}, {"tab", "a\tb", []string{"a b"}}, {"vertical tab behaves as a line feed", "a\vb", []string{"a", " b"}}, {"form feed behaves as a line feed", "a\fb", []string{"a", " b"}}, {"delete prints nothing", "a\x7fb", []string{"ab"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { wantLines(t, feed(20, 4, tc.stream), tc.lines...) }) } } func TestTheBellIsReportedOnceThenForgotten(t *testing.T) { p := NewParser(NewScreen(10, 2)) p.Write([]byte("a\x07b")) //nolint:errcheck if !p.TakeBell() { t.Error("TakeBell() = false after the bell rang") } if p.TakeBell() { t.Error("TakeBell() = true a second time; it must be forgotten once taken") } wantLines(t, p.Screen(), "ab") } func TestCursorMovement(t *testing.T) { tests := []struct { name string stream string want Cursor }{ {"CUP to a row and column", "\x1b[3;5H", Cursor{Row: 2, Col: 4}}, {"CUP with no parameters goes home", "\x1b[H", Cursor{}}, {"HVP is the same as CUP", "\x1b[2;3f", Cursor{Row: 1, Col: 2}}, {"CUU up", "\x1b[5;5H\x1b[2A", Cursor{Row: 2, Col: 4}}, {"CUD down", "\x1b[1;1H\x1b[2B", Cursor{Row: 2, Col: 0}}, {"CUF forward", "\x1b[1;1H\x1b[3C", Cursor{Row: 0, Col: 3}}, {"CUB back", "\x1b[1;6H\x1b[3D", Cursor{Row: 0, Col: 2}}, {"a movement with no count moves one", "\x1b[3;3H\x1b[A", Cursor{Row: 1, Col: 2}}, {"CHA to a column", "\x1b[3;3H\x1b[7G", Cursor{Row: 2, Col: 6}}, {"VPA to a row", "\x1b[3;3H\x1b[6d", Cursor{Row: 5, Col: 2}}, {"CNL down and home", "\x1b[1;5H\x1b[2E", Cursor{Row: 2, Col: 0}}, {"CPL up and home", "\x1b[5;5H\x1b[2F", Cursor{Row: 2, Col: 0}}, {"movement is clamped", "\x1b[99;99H", Cursor{Row: 7, Col: 19}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := feed(20, 8, tc.stream).Cursor(); got != tc.want { t.Errorf("Cursor() = %+v, want %+v", got, tc.want) } }) } } func TestEraseSequences(t *testing.T) { const filled = "\x1b[1;1Haaaaa\x1b[2;1Hbbbbb\x1b[3;1Hccccc\x1b[2;3H" tests := []struct { name string stream string lines []string }{ {"ED to the end", filled + "\x1b[J", []string{"aaaaa", "bb", ""}}, {"ED to the start", filled + "\x1b[1J", []string{"", " bb", "ccccc"}}, {"ED all", filled + "\x1b[2J", []string{"", "", ""}}, {"ED with scrollback cleared", filled + "\x1b[3J", []string{"", "", ""}}, {"EL to the end", filled + "\x1b[K", []string{"aaaaa", "bb", "ccccc"}}, {"EL to the start", filled + "\x1b[1K", []string{"aaaaa", " bb", "ccccc"}}, {"EL all", filled + "\x1b[2K", []string{"aaaaa", "", "ccccc"}}, {"ECH blanks in place", filled + "\x1b[2X", []string{"aaaaa", "bb b", "ccccc"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { wantLines(t, feed(5, 3, tc.stream), tc.lines...) }) } } func TestLineAndCharacterEditing(t *testing.T) { const filled = "\x1b[1;1Hone\x1b[2;1Htwo\x1b[3;1Hsix\x1b[2;1H" tests := []struct { name string stream string lines []string }{ {"IL inserts a blank line", filled + "\x1b[L", []string{"one", "", "two"}}, {"DL removes a line", filled + "\x1b[M", []string{"one", "six", ""}}, {"ICH opens cells", filled + "\x1b[2@", []string{"one", " t"}}, {"DCH closes cells", filled + "\x1b[1P", []string{"one", "wo"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { wantLines(t, feed(3, 3, tc.stream), tc.lines...) }) } } func TestScrollSequences(t *testing.T) { const filled = "\x1b[1;1Ha\x1b[2;1Hb\x1b[3;1Hc" wantLines(t, feed(3, 3, filled+"\x1b[S"), "b", "c", "") wantLines(t, feed(3, 3, filled+"\x1b[T"), "", "a", "b") wantLines(t, feed(3, 3, filled+"\x1b[2S"), "c", "", "") } func TestScrollRegion(t *testing.T) { // Rows 2 to 3 scroll; row 1 is a status line that must stay put. s := feed(6, 4, "\x1b[1;1Hhead\x1b[2;4r\x1b[4;1H\x1b[2;1Ha\x1b[3;1Hb\x1b[4;1Hc\n") wantLines(t, s, "head", "b", "c", "") } func TestScrollRegionWithNoParametersOpensBackUp(t *testing.T) { s := feed(6, 4, "\x1b[2;3r\x1b[r\x1b[4;1H\n") // With the whole screen scrolling again, a line feed on the last row moves // everything up rather than only rows 2 and 3. if got := s.Cursor().Row; got != 3 { t.Errorf("Cursor().Row = %d, want the last row", got) } } func TestAnImpossibleScrollRegionOpensBackUp(t *testing.T) { s := feed(6, 4, "\x1b[3;2r") s.MoveTo(3, 0) s.LineFeed() if got := s.Cursor().Row; got != 3 { t.Errorf("Cursor().Row = %d, want the region to cover the whole screen", got) } } func TestSaveAndRestoreCursorSequences(t *testing.T) { for _, stream := range []string{ "\x1b[3;4H\x1b7\x1b[1;1H\x1b8", // DECSC and DECRC "\x1b[3;4H\x1b[s\x1b[1;1H\x1b[u", "\x1b[3;4H\x1b[?1048h\x1b[1;1H\x1b[?1048l", } { if got := feed(10, 5, stream).Cursor(); got != (Cursor{Row: 2, Col: 3}) { t.Errorf("%q left the cursor at %+v, want row 2 column 3", stream, got) } } } func TestIndexAndReverseIndex(t *testing.T) { // IND and RI move by a line and keep the column; only NEL returns to the // first column as well. wantLines(t, feed(4, 3, "a\x1bDb"), "a", " b") // IND, down wantLines(t, feed(4, 3, "\x1b[2;1Ha\x1bMb"), " b", "a") // RI, up wantLines(t, feed(4, 3, "ab\x1bEc"), "ab", "c") // NEL, down and home } func TestResetSequence(t *testing.T) { s := feed(10, 3, "text\x1b[1;31m\x1bc") wantLines(t, s, "") if got := s.Cursor(); got != (Cursor{}) { t.Errorf("Cursor() = %+v, want the top left", got) } if _, _, attrs := s.Style().Decompose(); attrs&tcell.AttrBold != 0 { t.Error("the style survived the reset") } } func TestPrivateModes(t *testing.T) { if feed(10, 3, "\x1b[?25l").CursorVisible() { t.Error("the cursor is visible after being hidden") } if !feed(10, 3, "\x1b[?25l\x1b[?25h").CursorVisible() { t.Error("the cursor stayed hidden after being shown") } if feed(10, 3, "\x1b[?7l").AutoWrap() { t.Error("wrapping is on after being turned off") } if !feed(10, 3, "\x1b[?1049h").Alternate() { t.Error("the alternate screen did not come up") } if feed(10, 3, "\x1b[?1049h\x1b[?1049l").Alternate() { t.Error("the alternate screen did not go away") } } func TestTheAlternateScreenKeepsWhatWasThere(t *testing.T) { s := feed(10, 3, "before\x1b[?1049hduring\x1b[?1049l") wantLines(t, s, "before") } func TestSeveralModesInOneSequence(t *testing.T) { s := feed(10, 3, "\x1b[?25;7l") if s.CursorVisible() || s.AutoWrap() { t.Error("a sequence naming two modes did not turn both off") } } func TestSGRColours(t *testing.T) { tests := []struct { name string stream string check func(*testing.T, tcell.Style) }{ { name: "a basic foreground", stream: "\x1b[31m", check: func(t *testing.T, style tcell.Style) { if fg, _, _ := style.Decompose(); fg != tcell.ColorMaroon { t.Errorf("foreground = %v, want maroon", fg) } }, }, { name: "a basic background", stream: "\x1b[44m", check: func(t *testing.T, style tcell.Style) { if _, bg, _ := style.Decompose(); bg != tcell.ColorNavy { t.Errorf("background = %v, want navy", bg) } }, }, { name: "a bright foreground", stream: "\x1b[92m", check: func(t *testing.T, style tcell.Style) { if fg, _, _ := style.Decompose(); fg != tcell.ColorLime { t.Errorf("foreground = %v, want lime", fg) } }, }, { name: "a 256-colour foreground", stream: "\x1b[38;5;200m", check: func(t *testing.T, style tcell.Style) { if fg, _, _ := style.Decompose(); fg != tcell.PaletteColor(200) { t.Errorf("foreground = %v, want palette colour 200", fg) } }, }, { name: "a 24-bit background", stream: "\x1b[48;2;10;20;30m", check: func(t *testing.T, style tcell.Style) { if _, bg, _ := style.Decompose(); bg.Hex() != 0x0a141e { t.Errorf("background = %#06x, want 0x0a141e", bg.Hex()) } }, }, { name: "a colour and an attribute together", stream: "\x1b[1;31m", check: func(t *testing.T, style tcell.Style) { fg, _, attrs := style.Decompose() if fg != tcell.ColorMaroon || attrs&tcell.AttrBold == 0 { t.Errorf("style = %v with attributes %v, want bold maroon", fg, attrs) } }, }, { name: "default colours", stream: "\x1b[31;44m\x1b[39;49m", check: func(t *testing.T, style tcell.Style) { fg, bg, _ := style.Decompose() if fg != tcell.ColorDefault || bg != tcell.ColorDefault { t.Errorf("style = %v on %v, want both back to the default", fg, bg) } }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tc.check(t, feed(10, 3, tc.stream).Style()) }) } } func TestSGRAttributes(t *testing.T) { tests := []struct { name string stream string attr tcell.AttrMask want bool }{ {"bold", "\x1b[1m", tcell.AttrBold, true}, {"bold off", "\x1b[1m\x1b[22m", tcell.AttrBold, false}, {"dim", "\x1b[2m", tcell.AttrDim, true}, {"italic", "\x1b[3m", tcell.AttrItalic, true}, {"italic off", "\x1b[3m\x1b[23m", tcell.AttrItalic, false}, {"underline", "\x1b[4m", tcell.AttrUnderline, true}, {"underline off", "\x1b[4m\x1b[24m", tcell.AttrUnderline, false}, {"blink", "\x1b[5m", tcell.AttrBlink, true}, {"reverse", "\x1b[7m", tcell.AttrReverse, true}, {"reverse off", "\x1b[7m\x1b[27m", tcell.AttrReverse, false}, {"strike through", "\x1b[9m", tcell.AttrStrikeThrough, true}, {"reset clears everything", "\x1b[1;4;7m\x1b[0m", tcell.AttrBold, false}, {"a bare m resets", "\x1b[1m\x1b[m", tcell.AttrBold, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, _, attrs := feed(10, 3, tc.stream).Style().Decompose() if got := attrs&tc.attr != 0; got != tc.want { t.Errorf("the attribute is %v, want %v", got, tc.want) } }) } } func TestAStyleAppliesToWhatIsWrittenAfterIt(t *testing.T) { s := feed(10, 2, "a\x1b[31mb") if fg, _, _ := s.CellAt(0, 0).Style.Decompose(); fg == tcell.ColorMaroon { t.Error("the character written before the colour took it") } if fg, _, _ := s.CellAt(0, 1).Style.Decompose(); fg != tcell.ColorMaroon { t.Error("the character written after the colour did not take it") } } func TestAnIncompleteExtendedColourIsIgnored(t *testing.T) { // Half a colour is not a colour, and must not be guessed at. for _, stream := range []string{"\x1b[38m", "\x1b[38;5m", "\x1b[38;2;1;2m"} { if fg, _, _ := feed(10, 2, stream).Style().Decompose(); fg != tcell.ColorDefault { t.Errorf("%q set the foreground to %v, want it left alone", stream, fg) } } } func TestUTF8SplitAcrossWrites(t *testing.T) { // A pseudo-terminal hands over whatever has arrived, which can be half a // character. screen := NewScreen(10, 2) p := NewParser(screen) encoded := []byte("é") for _, b := range encoded { p.Write([]byte{b}) //nolint:errcheck } wantLines(t, screen, "é") } func TestAnEscapeSequenceSplitAcrossWrites(t *testing.T) { screen := NewScreen(10, 3) p := NewParser(screen) for _, piece := range []string{"\x1b", "[3", ";2", "H", "x"} { p.Write([]byte(piece)) //nolint:errcheck } wantLines(t, screen, "", "", " x") } func TestInvalidBytesBecomeAReplacementCharacter(t *testing.T) { s := feed(10, 2, "a\xff\xff\xff\xff\xffb") if got := s.LineText(0); !strings.HasPrefix(got, "a") || !strings.HasSuffix(got, "b") { t.Errorf("row 0 = %q, want the valid characters kept around the invalid ones", got) } } func TestWindowTitle(t *testing.T) { tests := []struct { name string stream string want string }{ {"set with a bell terminator", "\x1b]0;my title\x07", "my title"}, {"set with a string terminator", "\x1b]2;other\x1b\\", "other"}, {"an icon-only command is ignored", "\x1b]1;icon\x07", ""}, {"never set", "plain text", ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { p := NewParser(NewScreen(20, 3)) p.Write([]byte(tc.stream)) //nolint:errcheck if got := p.Title(); got != tc.want { t.Errorf("Title() = %q, want %q", got, tc.want) } }) } } func TestAnOSCDoesNotPrintItsContents(t *testing.T) { s := feed(20, 2, "a\x1b]0;never printed\x07b") wantLines(t, s, "ab") } func TestAnEscapeInsideAnOSCThatIsNotATerminatorEndsIt(t *testing.T) { s := feed(20, 3, "\x1b]0;half\x1b[2;1Hx") wantLines(t, s, "", "x") } func TestAnUnterminatedOSCDoesNotGrowForever(t *testing.T) { p := NewParser(NewScreen(20, 3)) p.Write([]byte("\x1b]0;")) //nolint:errcheck p.Write([]byte(strings.Repeat("x", maxOSC+100))) //nolint:errcheck if len(p.osc) > maxOSC { t.Errorf("the buffer grew to %d bytes, want it capped at %d", len(p.osc), maxOSC) } } func TestAFloodOfParametersDoesNotGrowForever(t *testing.T) { p := NewParser(NewScreen(20, 3)) p.Write([]byte("\x1b[" + strings.Repeat("1;", maxParams+100) + "m")) //nolint:errcheck if len(p.params) > maxParams { t.Errorf("the parameters grew to %d, want them capped at %d", len(p.params), maxParams) } } func TestUnknownSequencesAreSwallowedNotPrinted(t *testing.T) { // A terminal that prints the sequences it does not implement is worse than // one that drops them: the display fills with rubbish. tests := []string{ "\x1b[6n", // a device status report, which needs a reply we do not send "\x1b[>c", // secondary device attributes "\x1b[?2004h", // bracketed paste "\x1b[?1000h", // mouse reporting "\x1b(B", // a character-set selector "\x1b[1 q", // a cursor-style request, with an intermediate byte "\x1bP1$r\x1b\\", // a device control string } for _, stream := range tests { t.Run(stream, func(t *testing.T) { s := feed(20, 3, "a"+stream+"b") wantLines(t, s, "ab") }) } } func TestAControlCharacterInsideASequenceIsActedOn(t *testing.T) { // A real terminal acts on a carriage return wherever it appears, rather // than swallowing it into the sequence being collected. s := feed(10, 3, "abc\x1b[1\r2H") if got := s.Cursor().Col; got != 0 { t.Errorf("Cursor().Col = %d, want the carriage return to have been acted on", got) } } func TestScreenReturnsTheScreenBeingDrawnOn(t *testing.T) { screen := NewScreen(10, 2) p := NewParser(screen) if p.Screen() != screen { t.Error("Screen() returned a different screen") } } func TestApplicationCursorKeys(t *testing.T) { // Without this, the arrow keys are encoded the wrong way inside vim and at // a readline prompt, which is the most visible thing an emulator can get // wrong. if feed(10, 3, "").ApplicationCursor() { t.Error("application cursor keys are on by default") } if !feed(10, 3, "\x1b[?1h").ApplicationCursor() { t.Error("ESC [ ? 1 h did not turn application cursor keys on") } if feed(10, 3, "\x1b[?1h\x1b[?1l").ApplicationCursor() { t.Error("ESC [ ? 1 l did not turn them off again") } if feed(10, 3, "\x1b[?1h\x1bc").ApplicationCursor() { t.Error("a reset left application cursor keys on") } }