package terminal import ( "bytes" "cmp" "strings" "sync" "testing" "time" "github.com/lixenwraith/color" ) // fakeBackend records output and replays input chunks; an empty chunk, or // none left, is a poll timing out, and pause blocks a read as wasm's does type fakeBackend struct { mu sync.Mutex out bytes.Buffer chunks []string pause time.Duration } func (f *fakeBackend) Init() error { return nil } func (f *fakeBackend) Fini() {} func (f *fakeBackend) Size() (int, int) { return 1, 1 } func (f *fakeBackend) SetResizeHandler(func(width, height int)) {} func (f *fakeBackend) Write(p []byte) error { f.mu.Lock() defer f.mu.Unlock() f.out.Write(p) return nil } func (f *fakeBackend) Read(stop <-chan struct{}) ([]byte, error) { f.mu.Lock() if len(f.chunks) > 0 { c := f.chunks[0] f.chunks = f.chunks[1:] f.mu.Unlock() if c == "" { time.Sleep(5 * time.Millisecond) // a poll timing out } time.Sleep(f.pause) return []byte(c), nil } f.mu.Unlock() select { case <-stop: case <-time.After(5 * time.Millisecond): } return nil, nil } func (f *fakeBackend) written() string { f.mu.Lock() defer f.mu.Unlock() return f.out.String() } // Each color mode writes each kind of color as such a terminal reads it: // 16 colors as SGR 30-37/90-97 and 40-47/100-107, restated from a reset // since a console's bright colors set bold, a palette index by its shade, // the terminal's own as 39/49 in every mode, no color at all in // ColorModeNone, and elsewhere only what changed from one cell to the next func TestColorModesWriteWhatTheTerminalReads(t *testing.T) { red, yellow := color.RGB{R: 205}, color.RGB{R: 255, G: 255} for _, c := range []struct { mode ColorMode cells []Cell want string }{ {ColorMode16, []Cell{{Rune: 'x', Fg: red, Bg: yellow}}, "\x1b[0;31;103mx"}, {ColorMode16, []Cell{{Rune: 'x', Fg: color.RGB{R: 9}, Bg: color.RGB{R: 196}, Attrs: AttrFg256 | AttrBg256}}, "\x1b[0;91;101mx"}, {ColorMode16, []Cell{{Rune: 'x', Fg: color.RGB{R: 244}, Attrs: AttrFg256 | AttrBgDefault | AttrBold}}, "\x1b[0;1;90;49mx"}, {ColorMode16, []Cell{{Rune: 'x', Fg: color.RGB{R: 9}, Attrs: AttrFg256}, {Rune: 'y', Fg: color.RGB{R: 1}, Attrs: AttrFg256}}, "\x1b[0;91;40mx\x1b[0;31;40my"}, {ColorMode256, []Cell{{Rune: 'x', Attrs: AttrFgDefault | AttrBgDefault}}, "\x1b[0;39;49mx"}, {ColorMode256, []Cell{{Rune: 'x', Fg: color.RGB{R: 1}, Attrs: AttrFg256}, {Rune: 'y', Fg: color.RGB{R: 1}, Bg: color.RGB{R: 2}, Attrs: AttrFg256 | AttrBg256}, {Rune: 'z', Fg: color.RGB{R: 3}, Bg: color.RGB{R: 4}, Attrs: AttrFg256 | AttrBg256}}, "\x1b[0;38;5;1;48;5;16mx\x1b[48;5;2my\x1b[38;5;3;48;5;4mz"}, {ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"}, {ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"}, } { f := &fakeBackend{} o := newOutputBuffer(f, c.mode) o.flush(c.cells, len(c.cells), 1) if got, want := f.written(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want { t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want) } } } // A cell is drawn as one glyph: a control character, C0 or C1, that a // pasted value carried in, or no rune at all, is drawn as a space, never // sent to the terminal func TestCellsSendNoControlCharacters(t *testing.T) { f := &fakeBackend{} o := newOutputBuffer(f, ColorMode256) o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 'é'}}, 5, 1) if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want { t.Fatalf("%q, want %q", got, want) } } // A paste arrives as one event, whatever reads split it into: an escape // inside it is text even when a poll times out on it, and a paste the // terminal never ends ends when input pauses, whether reads time out or // block. With paste mode off, a paste marker is no paste. func TestPasteArrivesWhole(t *testing.T) { defer func(d time.Duration) { pasteIdle = d }(pasteIdle) paste := func(s string) Event { return Event{Type: EventPaste, Text: s} } expectEvents(t, map[string]input{ "split": {chunks: []string{"a\x1b[20", "0~", "\x1b", "", "[A one\ntwo\x1b[2", "01~b"}, want: []Event{key('a'), paste("\x1b[A one\ntwo"), key('b')}}, "unended": {idle: 20 * time.Millisecond, chunks: []string{"\x1b[200~ab\x1b[2", "", "", "", "", "", "", "", "", "", "q"}, want: []Event{paste("ab\x1b[2"), key('q')}}, "blocking": {idle: 20 * time.Millisecond, pause: 40 * time.Millisecond, chunks: []string{"\x1b[200~ab", "q"}, want: []Event{paste("ab"), key('q')}}, "mode off": {off: true, chunks: []string{"\x1b[200~q"}, want: []Event{key('q')}}, }) } // ESC ESC is Escape before a paste, in one read or split after the second // ESC; before another sequence it is Alt and that key (rxvt's Alt+arrows); // alone, once input pauses, Alt+Escape func TestDoubleEscape(t *testing.T) { esc := Event{Type: EventKey, Key: KeyEscape} altEsc := Event{Type: EventKey, Key: KeyEscape, Modifiers: ModAlt} pasted := Event{Type: EventPaste, Text: "hi"} expectEvents(t, map[string]input{ "one read": {chunks: []string{"\x1b\x1b[200~hi\x1b[201~"}, want: []Event{esc, pasted}}, "split": {chunks: []string{"\x1b\x1b", "[200~hi\x1b[201~"}, want: []Event{esc, pasted}}, "alt+up": {chunks: []string{"\x1b\x1b[A"}, want: []Event{{Type: EventKey, Key: KeyUp, Modifiers: ModAlt}}}, "alone": {chunks: []string{"\x1b\x1b"}, want: []Event{altEsc}}, }) } func key(r rune) Event { return Event{Type: EventKey, Key: KeyRune, Rune: r} } // input is what a reader is given, in chunks, and the events it must send type input struct { off bool idle, pause time.Duration chunks []string want []Event } func expectEvents(t *testing.T, cases map[string]input) { t.Helper() for name, c := range cases { pasteIdle = cmp.Or(c.idle, time.Second) r := newInputReader(&fakeBackend{chunks: c.chunks, pause: c.pause}) r.pasteMode.Store(!c.off) r.start() for _, w := range c.want { select { case ev := <-r.events(): if ev != w { t.Errorf("%s: got %+v, want %+v", name, ev, w) } case <-time.After(2 * time.Second): t.Errorf("%s: no event, want %+v", name, w) } } r.stop() } } // Paste mode set on the terminal is what its reader parses by func TestPasteModeReachesTheReader(t *testing.T) { f := &fakeBackend{} term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)} term.output = newOutputBuffer(f, ColorMode256) if err := term.Init(); err != nil { t.Fatal(err) } defer term.Fini() term.SetPasteMode(true) f.mu.Lock() f.chunks = append(f.chunks, "\x1b[200~x\x1b[201~") f.mu.Unlock() got := make(chan Event, 1) go func() { got <- term.PollEvent() }() select { case ev := <-got: if ev.Type != EventPaste || ev.Text != "x" { t.Fatalf("%+v", ev) } case <-time.After(2 * time.Second): t.Fatal("no event") } } // Paste mode ends with the session, and Init clears to the terminal's own // background rather than painting it black func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) { f := &fakeBackend{} term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)} term.output = newOutputBuffer(f, ColorModeTrueColor) if err := term.Init(); err != nil { t.Fatal(err) } if err := term.SetPasteMode(true); err != nil { t.Fatal(err) } term.Fini() got := f.written() if !strings.Contains(got, "\x1b[0m\x1b[49m\x1b[2J") || !strings.Contains(got, "\x1b[?2004h") || !strings.Contains(got[strings.Index(got, "\x1b[?2004h"):], "\x1b[?2004l") { t.Fatalf("%q", got) } }