""" Offline emulator tests: queries answered, modes tracked, parsing survives chunk boundaries. Run with: python3 tests/test_emulator.py """ import _bootstrap # sets sys.path for the imports below from _bootstrap import FakeProcess from vterm.emulator import VtScreen, VtStream def make(columns=80, lines=24): proc = FakeProcess() screen = VtScreen(columns, lines, process=proc) stream = VtStream(screen) return proc, screen, stream def feed(stream, data, char_by_char=False): if char_by_char: for c in data: stream.feed(c) else: stream.feed(data) def run_case(char_by_char): tag = "char-by-char" if char_by_char else "chunked" # DA1 answered (Claude Code's untimed sentinel) proc, screen, stream = make() feed(stream, "\x1b[c", char_by_char) assert proc.written == "\x1b[?62;22c", (tag, proc.written) # CPR, plain and private form proc, screen, stream = make() feed(stream, "\x1b[5;10H\x1b[6n\x1b[?6n", char_by_char) assert proc.written == "\x1b[5;10R\x1b[?5;10R", (tag, proc.written) # XTVERSION then DECRQM 2026 (the Claude Code probe sequence) proc, screen, stream = make() feed(stream, "\x1b[>0q\x1b[c\x1b[?2026$p\x1b[c", char_by_char) assert proc.written == ( "\x1bP>|vterm 0.1\x1b\\" "\x1b[?62;22c" "\x1b[?2026;2$y" "\x1b[?62;22c"), (tag, proc.written) # DECRQM for DECAWM (7) and DECTCEM (25): both default-on, and the # wire-number list must answer them as set, not as unrecognized proc, screen, stream = make() feed(stream, "\x1b[?7$p\x1b[?25$p\x1b[?2026$p", char_by_char) assert proc.written == "\x1b[?7;1$y\x1b[?25;1$y\x1b[?2026;2$y", (tag, proc.written) # kitty probe: push ignored, query answered 0, DA1 fence answered proc, screen, stream = make() feed(stream, "\x1b[>7u\x1b[?u\x1b[c\x1b[4;2mhello", char_by_char) assert screen.modify_other_keys == 2, (tag, screen.modify_other_keys) assert screen.buffer[0][0].data == "h" and not screen.buffer[0][0].underscore, tag feed(stream, "\x1b[>4;0m", char_by_char) assert screen.modify_other_keys == 0, (tag, screen.modify_other_keys) # another resource, and the bare form, leave it alone feed(stream, "\x1b[>4;2m\x1b[>1;2m\x1b[>m", char_by_char) assert screen.modify_other_keys == 2, (tag, screen.modify_other_keys) # OSC 11 color query answered; OSC 0 sets title; OSC 133 dropped proc, screen, stream = make() screen.default_bg_color = "1a2b3c" feed(stream, "\x1b]0;mytitle\x07\x1b]11;?\x07\x1b]133;A\x07x", char_by_char) assert screen.title == "mytitle", (tag, screen.title) assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x07", (tag, proc.written) assert screen.buffer[0][0].data == "x", tag # OSC with ST terminator; a query asked with ST is answered with ST (meli) proc, screen, stream = make() screen.default_bg_color = "1a2b3c" feed(stream, "\x1b]2;st-title\x1b\\\x1b]11;?\x1b\\y", char_by_char) assert screen.title == "st-title", (tag, screen.title) assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written) assert screen.buffer[0][0].data == "y", tag # OSC 52 clipboard (base64 "hi") got = [] proc = FakeProcess() screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append) stream = VtStream(screen) feed(stream, "\x1b]52;c;aGk=\x07", char_by_char) assert got == ["hi"], (tag, got) # bracketed paste and sync modes tracked proc, screen, stream = make() feed(stream, "\x1b[?2004h\x1b[?2026h", char_by_char) assert screen.bracketed_paste_enabled(), tag assert screen.in_sync_frame(), tag feed(stream, "\x1b[?2026l\x1b[?2004l", char_by_char) assert not screen.in_sync_frame(), tag assert not screen.bracketed_paste_enabled(), tag # mouse modes proc, screen, stream = make() feed(stream, "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h", char_by_char) assert screen.mouse_reporting_enabled(), tag assert screen.focus_tracking_enabled(), tag # alt screen switch and restore proc, screen, stream = make() feed(stream, "primary\x1b[?1049h\x1b[2Jalt\x1b[?1049l", char_by_char) row0 = "".join(screen.buffer[0][i].data for i in range(7)) assert row0 == "primary", (tag, row0) assert not screen.alternate_buffer_mode, tag # truecolor SGR is quantized into the 256 palette; 256-color as hex; bold tracked proc, screen, stream = make() feed(stream, "\x1b[1;38;2;255;0;10mX\x1b[0m\x1b[38;5;196mY", char_by_char) assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg) # SGR 5/25 (blink): ignored, not a crash — Char has no blink field proc, screen, stream = make() feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char) assert screen.buffer[0][0].fg == "red", (tag, screen.buffer[0][0]) assert screen.buffer[0][1].fg == "red" and screen.buffer[0][1].data == "Y", tag # DECSTBM + SU/SD don't crash and scroll within margins proc, screen, stream = make(10, 6) feed(stream, "\x1b[2;5r\x1b[2S\x1b[1T\x1b[r", char_by_char) assert screen.margins is None, tag # DECSCUSR ignored, CSI t ignored, ?996n answered proc, screen, stream = make() feed(stream, "\x1b[2 q\x1b[16t\x1b[?996n", char_by_char) assert proc.written == "\x1b[?997;1n", (tag, proc.written) # scrollback: lines pushed to history when scrolling at bottom proc, screen, stream = make(10, 3) feed(stream, "a\r\nb\r\nc\r\nd", char_by_char) assert len(screen.history) == 1, (tag, len(screen.history)) assert screen.history[0][0].data == "a", tag # ED 3 clears history and fires callback cleared = [] proc = FakeProcess() screen = VtScreen(10, 3, process=proc, clear_callback=lambda: cleared.append(1)) stream = VtStream(screen) feed(stream, "a\r\nb\r\nc\r\nd\x1b[3J", char_by_char) assert len(screen.history) == 0 and cleared, tag # DCS/APC payloads are consumed, not drawn proc, screen, stream = make() feed(stream, "\x1bP1$rpayload\x1b\\Z\x1b_Gapc\x1b\\W", char_by_char) row0 = "".join(screen.buffer[0][i].data for i in range(2)) assert row0 == "ZW", (tag, row0) # DEC special graphics: xterm-256color's smacs is ESC(0, so ncurses apps # (mutt, dialog, less) draw borders with it when the locale is not UTF-8 proc, screen, stream = make(20, 3) feed(stream, "\x1b(0lqqqk\x1b(Bplain", char_by_char) row = "".join(screen.buffer[0][x].data for x in range(10)) assert row == "\u250c\u2500\u2500\u2500\u2510plain", (tag, repr(row)) # G1 designation plus SO/SI stays ignored in UTF-8 mode, so a stray 0x0e in # decoded text cannot silently switch the charset proc, screen, stream = make(20, 3) feed(stream, "\x1b)0\x0eq\x0f", char_by_char) assert screen.buffer[0][0].data == "q", (tag, screen.buffer[0][0].data) # multi-byte text is unaffected by a graphics designation proc, screen, stream = make(20, 3) feed(stream, "\x1b(0\u00e9\u2500", char_by_char) row = "".join(screen.buffer[0][x].data for x in range(2)) assert row == "\u00e9\u2500", (tag, repr(row)) # shift+enter esc-cr passthrough sanity: ESC CR fed back in draws nothing odd proc, screen, stream = make() feed(stream, "\x1b[?25l\x1b[?25h", char_by_char) assert not screen.cursor.hidden, tag # DECSCUSR tracked; missing parameter means 0; reset restores the default proc, screen, stream = make() feed(stream, "\x1b[2 q", char_by_char) assert screen.cursor_style == 2, (tag, screen.cursor_style) feed(stream, "\x1b[ q", char_by_char) assert screen.cursor_style == 0, (tag, screen.cursor_style) feed(stream, "\x1b[6 q", char_by_char) screen.reset() assert screen.cursor_style == 0, (tag, screen.cursor_style) # nothing leaked to the screen or the pty assert screen.buffer[0][0].data == " " and proc.written == "", tag print("all emulator tests passed ({})".format(tag)) def run_resize_cases(): """Shrinking must drop cells past the new width, in both buffers: the segmenter walks a line to its highest key, so leftovers keep rendering.""" proc, screen, stream = make(20, 3) feed(stream, "ABCDEFGHIJKLMNOPQRST", False) screen.resize(3, 10) assert max(screen.buffer[0]) < 10, sorted(screen.buffer[0]) # the buffer parked by the alt screen is trimmed too, or switching back # brings the stale cells with it proc, screen, stream = make(20, 3) feed(stream, "PRIMARYPRIMARYPRIMAR", False) feed(stream, "\x1b[?1049h", False) screen.resize(3, 8) feed(stream, "\x1b[?1049l", False) assert max(screen.buffer[0]) < 8, sorted(screen.buffer[0]) # a shrink clamps the cursor (pyte does it via restore_cursor); without # the clamp draw() writes to rows above or below the screen proc, screen, stream = make(10, 8) feed(stream, "\r\n".join("line{}".format(i) for i in range(8)), False) feed(stream, "\x1b[1;1H", False) # full screen, cursor at home screen.resize(4, 10) assert screen.cursor.y == 0, screen.cursor.y proc, screen, stream = make(10, 8) feed(stream, "top\x1b[7;1H", False) # cursor on a blank row below content screen.resize(4, 10) assert screen.cursor.y == 3, screen.cursor.y proc, screen, stream = make(20, 4) feed(stream, "x" * 15, False) screen.resize(4, 10) assert screen.cursor.x == 10, screen.cursor.x # mode 2031 is how a live theme change reaches the app proc, screen, stream = make() assert not screen.theme_notify_enabled() feed(stream, "\x1b[?2031h", False) assert screen.theme_notify_enabled() print("all resize tests passed") def run_private_csi_cases(): """No private CSI may raise: an exception here used to kill the renderer thread and freeze the tab. aerc probes XTSMGRAPHICS (ESC[?2;1;0S).""" for final in "ABCDEFGHJKLMPSTXZbcdfghlmnqrstu@": proc, screen, stream = make() stream.feed("\x1b[?2;1;0" + final) # multi-parameter private CSI stream.feed("\x1b[?1" + final) # single-parameter stream.feed("\x1b[?" + final) # bare # the ones we do support still work proc, screen, stream = make() feed(stream, "\x1b[?1000h\x1b[?1002h\x1b[?1006h", False) assert screen.mouse_reporting_enabled(), "private h must still set modes" feed(stream, "\x1b[?1000l\x1b[?1002l", False) assert not screen.mouse_reporting_enabled(), "private l must still reset modes" proc, screen, stream = make() feed(stream, "\x1b[5;10H\x1b[?6n", False) assert proc.written == "\x1b[?5;10R", proc.written # ...and an unsupported one is ignored rather than answered or dispatched proc, screen, stream = make() feed(stream, "\x1b[?2;1;0S", False) assert proc.written == "", "XTSMGRAPHICS must be ignored, not answered" print("all private CSI tests passed") def run_decscnm_cases(): """A DECSCNM flash (vim's visual bell) must not outlive itself. pyte points every line at the blank cell from inside set/reset_mode, so a line kept a reverse default and painted the columns the app never wrote — vim skips indentation with cursor-forward — as white blocks. """ from vterm.render import segment_buffer_line # a gutter, then the indent skipped with CSI 8C, then the text: vim's shape paint = "\x1b[1;1H\x1b[93m 47 \x1b[m\x1b[8C\x1b[96mreturn\x1b[m default" for order in (paint + "\x1b[?5h\x1b[?5l", "\x1b[?5h\x1b[?5l" + paint): proc, screen, stream = make(columns=60, lines=5) feed(stream, order, False) assert not screen.default_char.reverse, "the flash is over" assert not screen.buffer[0].default.reverse, "line kept the flash's blank cell" assert not screen.buffer[0][6].reverse, "untouched column left reverse" painted = [s for s in segment_buffer_line(screen.buffer[0]) if not s[0].strip() and s[3] == "reverse_default"] assert not painted, ("whitespace painted reverse", painted) # while the flash is on, the untouched columns invert too proc, screen, stream = make(columns=60, lines=5) feed(stream, paint + "\x1b[?5h", False) assert screen.buffer[0].default.reverse and screen.buffer[0][6].reverse, \ "DECSCNM must invert the whole screen, not only the written columns" print("all DECSCNM tests passed") # everything the two paths must agree on: text, SGR in each form, empty and # clamped params, cursor moves, private modes, prefixed CSI, DECRQM, OSC, # scroll regions, controls inside a CSI, a colon subparameter, wide chars GAUNTLET = ( "plain text\r\n" "\x1b[1;31mbold red\x1b[m normal \x1b[38;5;190mpalette\x1b[0m\r\n" "\x1b[38;2;10;200;30mtruecolor\x1b[49m\x1b[m\r\n" "\x1b[H\x1b[2Kover\twritten\x1b[3;5Hmoved\x1b[999999C clamp\x1b[m\r\n" "\x1b[?25l\x1b[?1049h\x1b[?25h\x1b[?1049l" "\x1b[>q\x1b[?2026$p\x1b[5n\x1b[6n" "\x1b]0;a title\x07\x1b]11;?\x07" "\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r" "\x1b[38:5:100mcolon\x1b[m\r\n" "\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n" "wide: 漢字 \x1b[7m漢\x1b[m\r\n" "\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n" ) def run_fast_path_cases(): """The feed() fast path must be indistinguishable from the parser.""" def state(screen): rows = [ tuple(screen.buffer[y][x] for x in range(screen.columns)) for y in range(screen.lines) ] c = screen.cursor return (rows, c.x, c.y, c.attrs, set(screen.mode), screen.title) def parser_only(blob): """The blob through the coroutine parser alone: pyte's original feed.""" proc, screen, stream = make(columns=30, lines=8) import pyte pyte.Stream.feed(stream, blob) return proc, screen ref_proc, ref_screen = parser_only(GAUNTLET) reference = state(ref_screen) # chunk sizes that split every sequence somewhere; 1 exercises pure carry for size in (1, 2, 3, 7, 64, len(GAUNTLET)): proc, screen, stream = make(columns=30, lines=8) for i in range(0, len(GAUNTLET), size): stream.feed(GAUNTLET[i : i + size]) assert state(screen) == reference, "fast path diverged at chunk size {}".format(size) assert proc.written == ref_proc.written, (size, proc.written, ref_proc.written) # a param run past the carry bound still parses, via the parser proc, screen, stream = make() stream.feed("\x1b[" + "1;" * 40) stream.feed("31mX") assert screen.buffer[0][0].fg == "red", screen.buffer[0][0] # a carried partial does not linger: nothing dispatches until it completes proc, screen, stream = make() stream.feed("A\x1b[3") assert screen.buffer[0][1].data == " ", "partial CSI must not be drawn" stream.feed("1mB") assert screen.buffer[0][1].fg == "red" and screen.buffer[0][1].data == "B" print("all fast-path tests passed") def run_malformed_csi_cases(): """A malformed CSI must cost one sequence, not the chunk. Excess params used to TypeError out of the dispatch; feed_data caught it and dropped up to 256K of output. Triggered by cat'ing a binary file or a corrupted stream. The SGR range guards the blink class the same way. """ for char_by_char in (False, True): # fast path / parser for final in (chr(c) for c in range(0x40, 0x7F)): for params in ("2;3", "0;0;0"): proc, screen, stream = make(columns=20, lines=6) feed(stream, "ab\x1b[{}{}cd".format(params, final), char_by_char) row = "".join(screen.buffer[y][x].data for y in range(6) for x in range(20)) assert "c" in row and "d" in row, \ ("output after a malformed CSI was lost", params, final, char_by_char) for n in range(121): proc, screen, stream = make(columns=20, lines=6) feed(stream, "a\x1b[{}mb".format(n), char_by_char) assert screen.buffer[0][1].data == "b", ("SGR {} ate the chunk".format(n), char_by_char) print("all malformed-CSI tests passed") def run_clear_cases(): """Screen wipe and post-clear history suppression (vterm_clear_screen).""" proc, screen, stream = make(10, 4) feed(stream, "aaa\r\nbbb\r\nccc\r\nddd\r\neee", False) assert len(screen.history) > 0 assert screen.buffer[0][0].data != " " screen.wipe() rows = ["".join(screen.buffer[y][x].data for x in range(screen.columns)).strip() for y in range(screen.lines)] assert rows == ["", "", "", ""], rows assert (screen.cursor.x, screen.cursor.y) == (0, 0) assert screen.dirty == set(range(4)), screen.dirty # the app's repaint right after a clear must not refill scrollback screen.history.clear() feed(stream, "one\r\ntwo\r\nthree\r\nfour\r\nfive", False) assert len(screen.history) == 0, len(screen.history) # ... but scrollback works again once the window expires screen._suppress_history_until = 0 feed(stream, "\r\nsix\r\nseven", False) assert len(screen.history) > 0, "history suppressed for too long" # ED 3 is app-driven: it clears scrollback and fires the clear callback, # but (as in xterm, unlike pyte) leaves the visible screen alone cleared = [] proc = FakeProcess() screen = VtScreen(10, 4, process=proc, clear_callback=lambda: cleared.append(1)) stream = VtStream(screen) feed(stream, "gone\r\nalso-gone\r\nmore\r\nlines\r\nx", False) assert len(screen.history) > 0 feed(stream, "\x1b[3J", False) assert len(screen.history) == 0 and cleared, (len(screen.history), cleared) assert screen.buffer[0][0].data == "a", screen.buffer[0][0] # with erase_scrollback off, ED 3 is a no-op: agents repaint with it and # would take the history and the view with them cleared = [] proc = FakeProcess() screen = VtScreen(10, 4, process=proc, erase_scrollback=False, clear_callback=lambda: cleared.append(1)) stream = VtStream(screen) feed(stream, "kept\r\nalso-kept\r\nmore\r\nlines\r\nx", False) kept = len(screen.history) assert kept > 0 del cleared[:] # the screen's own reset() fired one at construction feed(stream, "\x1b[3J", False) assert len(screen.history) == kept and not cleared, (len(screen.history), cleared) assert screen.buffer[0][0].data == "a", screen.buffer[0][0] print("all clear-screen tests passed") run_case(char_by_char=False) run_case(char_by_char=True) run_clear_cases() run_resize_cases() run_private_csi_cases() run_decscnm_cases() run_fast_path_cases() run_malformed_csi_cases()