initial import
This commit is contained in:
420
tests/test_emulator.py
Normal file
420
tests/test_emulator.py
Normal file
@@ -0,0 +1,420 @@
|
||||
""" 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[<u", char_by_char)
|
||||
assert proc.written == "\x1b[?0u\x1b[?62;22c", (tag, proc.written)
|
||||
# and nothing leaked to the screen
|
||||
assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0])
|
||||
|
||||
# modifyOtherKeys must not corrupt SGR state
|
||||
proc, screen, stream = make()
|
||||
feed(stream, "\x1b[>4;2mhello", char_by_char)
|
||||
assert screen.buffer[0][0].data == "h" and not screen.buffer[0][0].underscore, tag
|
||||
|
||||
# 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])
|
||||
|
||||
# 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,
|
||||
# and (as in pyte/Terminus) also blanks the screen. Both agents pair it
|
||||
# with 2J and repaint, so the render side must not wipe or nudge again.
|
||||
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)
|
||||
|
||||
# with erase_scrollback off, ED 3 still blanks the screen but the history
|
||||
# and the view survive: agents repaint with it and would take both
|
||||
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 == " ", 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()
|
||||
Reference in New Issue
Block a user