initial import
This commit is contained in:
33
tests/_bootstrap.py
Normal file
33
tests/_bootstrap.py
Normal file
@@ -0,0 +1,33 @@
|
||||
""" sys.path setup and fakes shared by the test scripts
|
||||
|
||||
They run as plain scripts (see README), so each imports this first to get the
|
||||
sublime stubs and the Package Control libraries on the path.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ST_LIB = os.path.expanduser(
|
||||
"~/Library/Application Support/Sublime Text/Lib/python38")
|
||||
|
||||
for path in (ROOT, ST_LIB, os.path.join(ROOT, "tests", "stubs")):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
"""Captures what the emulator writes to the pty, one entry per write."""
|
||||
|
||||
def __init__(self):
|
||||
self.writes = []
|
||||
|
||||
def write(self, data):
|
||||
self.writes.append(data)
|
||||
|
||||
@property
|
||||
def written(self):
|
||||
return "".join(self.writes)
|
||||
|
||||
@written.setter
|
||||
def written(self, value):
|
||||
self.writes = [value] if value else []
|
||||
89
tests/harness_live.py
Normal file
89
tests/harness_live.py
Normal file
@@ -0,0 +1,89 @@
|
||||
""" Live harness: run a real command inside VtScreen/VtStream without Sublime.
|
||||
Usage: python3 tests/harness_live.py <cmd> [seconds] [--send <text> | --type <text>]
|
||||
--send writes the text in one go; --type writes a character per write, as typing does. """
|
||||
import codecs
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import _bootstrap # sets sys.path for the imports below
|
||||
|
||||
from vterm.emulator import VtScreen, VtStream
|
||||
from vterm.terminal import TerminalPtyProcess, agent_env
|
||||
|
||||
cmd = sys.argv[1:2] or ["/bin/zsh"]
|
||||
seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 3.0
|
||||
send = None
|
||||
if "--send" in sys.argv:
|
||||
send = sys.argv[sys.argv.index("--send") + 1]
|
||||
typed = None
|
||||
if "--type" in sys.argv:
|
||||
typed = sys.argv[sys.argv.index("--type") + 1]
|
||||
|
||||
# the same process and environment the plugin spawns
|
||||
proc = TerminalPtyProcess.spawn(
|
||||
cmd, cwd=os.getcwd(), env=agent_env(), dimensions=(30, 100))
|
||||
|
||||
replies = []
|
||||
|
||||
|
||||
class Screen(VtScreen):
|
||||
"""Records the query replies the emulator writes back to the app."""
|
||||
|
||||
def write_process_input(self, data):
|
||||
replies.append(data)
|
||||
super().write_process_input(data)
|
||||
|
||||
|
||||
screen = Screen(100, 30, process=proc)
|
||||
stream = VtStream(screen)
|
||||
|
||||
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
lock = threading.Lock()
|
||||
stop = [False]
|
||||
|
||||
|
||||
def reader():
|
||||
while not stop[0]:
|
||||
try:
|
||||
raw = proc.read(4096)
|
||||
except EOFError:
|
||||
break
|
||||
with lock:
|
||||
stream.feed(decoder.decode(raw))
|
||||
|
||||
|
||||
t = threading.Thread(target=reader, daemon=True)
|
||||
t.start()
|
||||
|
||||
if send is not None:
|
||||
time.sleep(2)
|
||||
proc.write(send)
|
||||
|
||||
if typed is not None:
|
||||
time.sleep(2)
|
||||
# one write per character: the app frames its input per read, so a whole
|
||||
# string in one write is not the same test as typing it
|
||||
for char in typed:
|
||||
proc.write(char)
|
||||
time.sleep(0.04)
|
||||
|
||||
time.sleep(seconds)
|
||||
stop[0] = True
|
||||
try:
|
||||
proc.terminate(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("=== queries answered ===")
|
||||
for r in replies:
|
||||
print(repr(r))
|
||||
print("=== screen (non-empty rows) ===")
|
||||
for y in range(screen.lines):
|
||||
row = "".join(screen.buffer[y][x].data for x in range(screen.columns)).rstrip()
|
||||
if row:
|
||||
print("{:2}| {}".format(y, row))
|
||||
print("=== modes: {} title: {!r} alt: {}".format(
|
||||
sorted(m >> 5 for m in screen.mode if m >= 32), screen.title,
|
||||
screen.alternate_buffer_mode))
|
||||
89
tests/stubs/sublime.py
Normal file
89
tests/stubs/sublime.py
Normal file
@@ -0,0 +1,89 @@
|
||||
""" Minimal sublime API stub for offline tests """
|
||||
|
||||
|
||||
class Region:
|
||||
def __init__(self, a, b=None):
|
||||
self.a = a
|
||||
self.b = a if b is None else b
|
||||
|
||||
def begin(self):
|
||||
return min(self.a, self.b)
|
||||
|
||||
def end(self):
|
||||
return max(self.a, self.b)
|
||||
|
||||
def empty(self):
|
||||
return self.a == self.b
|
||||
|
||||
|
||||
OP_EQUAL = 0
|
||||
OP_NOT_EQUAL = 1
|
||||
|
||||
_settings = {}
|
||||
_clipboard = [""]
|
||||
_timeouts = []
|
||||
|
||||
|
||||
class Settings(dict):
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, key, default)
|
||||
|
||||
def set(self, key, value):
|
||||
self[key] = value
|
||||
|
||||
def erase(self, key):
|
||||
self.pop(key, None)
|
||||
|
||||
def has(self, key):
|
||||
return key in self
|
||||
|
||||
def add_on_change(self, tag, callback):
|
||||
pass
|
||||
|
||||
def clear_on_change(self, tag):
|
||||
pass
|
||||
|
||||
|
||||
def load_settings(name):
|
||||
return _settings.setdefault(name, Settings())
|
||||
|
||||
|
||||
_saved = []
|
||||
|
||||
|
||||
def save_settings(name):
|
||||
_saved.append(name)
|
||||
|
||||
|
||||
def set_timeout(fn, delay=0):
|
||||
_timeouts.append(fn)
|
||||
|
||||
|
||||
def set_timeout_async(fn, delay=0):
|
||||
_timeouts.append(fn)
|
||||
|
||||
|
||||
def run_timeouts():
|
||||
while _timeouts:
|
||||
_timeouts.pop(0)()
|
||||
|
||||
|
||||
def set_clipboard(text):
|
||||
_clipboard[0] = text
|
||||
|
||||
|
||||
def get_clipboard():
|
||||
return _clipboard[0]
|
||||
|
||||
|
||||
def packages_path():
|
||||
import tempfile
|
||||
return tempfile.gettempdir()
|
||||
|
||||
|
||||
def expand_variables(value, variables):
|
||||
return value
|
||||
|
||||
|
||||
def version():
|
||||
return "4200"
|
||||
15
tests/stubs/sublime_plugin.py
Normal file
15
tests/stubs/sublime_plugin.py
Normal file
@@ -0,0 +1,15 @@
|
||||
""" Minimal sublime_plugin stub """
|
||||
|
||||
|
||||
class TextCommand:
|
||||
def __init__(self, view=None):
|
||||
self.view = view
|
||||
|
||||
|
||||
class WindowCommand:
|
||||
def __init__(self, window=None):
|
||||
self.window = window
|
||||
|
||||
|
||||
class EventListener:
|
||||
pass
|
||||
126
tests/test_commands.py
Normal file
126
tests/test_commands.py
Normal file
@@ -0,0 +1,126 @@
|
||||
""" Theme picker flows: preview, cancel, confirm.
|
||||
Run with: python3 tests/test_commands.py """
|
||||
import _bootstrap # sets sys.path for the imports below
|
||||
from _bootstrap import FakeProcess
|
||||
|
||||
import sublime
|
||||
from vterm.commands import VtermSelectThemeCommand
|
||||
from vterm.utils import SETTINGS_FILE
|
||||
from vterm.themes import THEMES
|
||||
|
||||
settings = sublime.load_settings(SETTINGS_FILE)
|
||||
panel = {}
|
||||
|
||||
|
||||
class FakeWindow:
|
||||
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
|
||||
panel.update(items=items, on_done=on_done,
|
||||
selected_index=selected_index, on_highlight=on_highlight)
|
||||
|
||||
|
||||
def open_picker(current):
|
||||
settings.set("theme", current)
|
||||
command = VtermSelectThemeCommand(FakeWindow())
|
||||
command.run()
|
||||
return command
|
||||
|
||||
|
||||
# every theme is offered, with "no theme" first
|
||||
command = open_picker(None)
|
||||
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
|
||||
assert panel["selected_index"] == 0
|
||||
|
||||
# the configured theme starts highlighted
|
||||
command = open_picker("pro")
|
||||
assert panel["selected_index"] == command.choices.index("pro")
|
||||
|
||||
# highlighting previews but must not persist
|
||||
saves = len(sublime._saved)
|
||||
panel["on_highlight"](command.choices.index("slate"))
|
||||
assert settings.get("theme") == "slate", settings.get("theme")
|
||||
assert len(sublime._saved) == saves, "preview must not save settings"
|
||||
|
||||
# cancelling restores the value from before the panel opened
|
||||
panel["on_done"](-1)
|
||||
assert settings.get("theme") == "pro", settings.get("theme")
|
||||
|
||||
# confirming persists
|
||||
command = open_picker("pro")
|
||||
panel["on_done"](command.choices.index("homebrew"))
|
||||
assert settings.get("theme") == "homebrew", settings.get("theme")
|
||||
assert sublime._saved[-1] == SETTINGS_FILE
|
||||
|
||||
# "no theme" persists as null rather than a name
|
||||
command = open_picker("slate")
|
||||
panel["on_done"](0)
|
||||
assert settings.get("theme") is None, settings.get("theme")
|
||||
|
||||
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
|
||||
|
||||
|
||||
# a palette change must reach terminals that are already running: the screen
|
||||
# answers OSC 10/11 and DSR 996 from these values
|
||||
from vterm.emulator import VtScreen, VtStream
|
||||
from vterm.scheme import scheme_manager
|
||||
from vterm.terminal import Terminal
|
||||
|
||||
|
||||
recorder = FakeProcess()
|
||||
# real __init__ (view unused here); process/screen stand in for start()
|
||||
terminal = Terminal(None)
|
||||
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
|
||||
bg_color="ffffff", is_dark=False)
|
||||
terminal.process = recorder
|
||||
stream = VtStream(terminal.screen)
|
||||
|
||||
scheme_manager.foreground = "#eeeeee"
|
||||
scheme_manager.background = "#111111"
|
||||
terminal.apply_scheme()
|
||||
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color
|
||||
assert terminal.screen.theme_is_dark is True
|
||||
|
||||
# without mode 2031 the app is not interrupted
|
||||
assert recorder.written == "", repr(recorder.written)
|
||||
|
||||
# with it, the dark/light report is sent so the agent can restyle itself
|
||||
stream.feed("\x1b[?2031h")
|
||||
recorder.written = ""
|
||||
terminal.apply_scheme()
|
||||
assert recorder.written == "\x1b[?997;1n", repr(recorder.written)
|
||||
|
||||
# and the emulator answers a colour query with the new palette
|
||||
recorder.written = ""
|
||||
stream.feed("\x1b]11;?\x07")
|
||||
assert recorder.written == "\x1b]11;rgb:1111/1111/1111\x07", repr(recorder.written)
|
||||
|
||||
print("live theme tests passed (running screen adopts palette, app notified)")
|
||||
|
||||
|
||||
# the scheme file must exist before any restored tab's color_scheme is loaded at
|
||||
# startup, and building twice must not duplicate the seeded rules
|
||||
import os
|
||||
from vterm.scheme import SCHEME_FILE_NAME
|
||||
|
||||
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
scheme_manager.ensure_file()
|
||||
assert os.path.exists(path), "ensure_file did not write the scheme"
|
||||
assert not scheme_manager._initialized, \
|
||||
"ensure_file must leave the palette open to refinement by initialize()"
|
||||
|
||||
# a second call is a no-op: same file, untouched
|
||||
stamp = os.path.getmtime(path)
|
||||
scheme_manager.ensure_file()
|
||||
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme"
|
||||
|
||||
# _build is idempotent, which is what makes the ensure_file + initialize pair safe
|
||||
with scheme_manager.lock:
|
||||
scheme_manager._build()
|
||||
first = len(scheme_manager.rules)
|
||||
scheme_manager._build()
|
||||
assert len(scheme_manager.rules) == first, \
|
||||
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
|
||||
|
||||
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))
|
||||
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()
|
||||
125
tests/test_imports.py
Normal file
125
tests/test_imports.py
Normal file
@@ -0,0 +1,125 @@
|
||||
""" Smoke test: every plugin module imports cleanly against the sublime stubs.
|
||||
Run with: python3 tests/test_imports.py """
|
||||
import _bootstrap # sets sys.path for the imports below
|
||||
|
||||
ROOT = _bootstrap.ROOT
|
||||
|
||||
import vterm.utils
|
||||
import vterm.keys
|
||||
import vterm.emulator
|
||||
import vterm.scheme
|
||||
import vterm.terminal
|
||||
import vterm.render
|
||||
import vterm.commands
|
||||
import vterm.listeners
|
||||
import vterm.mouse
|
||||
|
||||
# keymap and mousemap must parse the way sublime parses them: JSON with
|
||||
# // line comments
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def load_sublime_json(name):
|
||||
with open(os.path.join(ROOT, name), encoding="utf-8") as f:
|
||||
return json.loads(re.sub(r"^\s*//.*$", "", f.read(), flags=re.M))
|
||||
|
||||
|
||||
keymap = load_sublime_json("Default.sublime-keymap")
|
||||
assert len(keymap) > 100
|
||||
|
||||
# every printable must be bound, or sublime inserts it into the view and an app
|
||||
# that does not echo (vim in normal mode) leaves the character behind. Each
|
||||
# binding's key must also round-trip to that same character: the shift+<letter>
|
||||
# form silently yields lowercase, which is why uppercase is bound literally.
|
||||
import string # noqa
|
||||
from vterm.keys import get_key_code
|
||||
|
||||
bound = {}
|
||||
for entry in keymap:
|
||||
combo = entry["keys"][0]
|
||||
if len(combo) == 1 and entry["command"] == "vterm_keypress":
|
||||
assert combo not in bound, "{} bound twice".format(combo)
|
||||
bound[combo] = entry["args"]
|
||||
|
||||
for char in string.ascii_letters + string.digits + string.punctuation:
|
||||
assert char in bound, "printable {!r} is not bound".format(char)
|
||||
produced = get_key_code(**bound[char])
|
||||
assert produced == char, "{!r} would send {!r}".format(char, produced)
|
||||
|
||||
assert get_key_code(key="space") == " ", "space must send a space"
|
||||
load_sublime_json("Default (OSX).sublime-mousemap")
|
||||
|
||||
# scheme manager produces scopes and rules
|
||||
import vterm.scheme as scheme_module
|
||||
from vterm.scheme import scheme_manager
|
||||
scope = scheme_manager.scope("ff0000", "default", "b")
|
||||
assert scope == "vterm.ff0000.default.b", scope
|
||||
|
||||
# every command referenced by the palette and the keymap must exist, and the
|
||||
# flags commands set must be the ones the renderer reads (a rename here is
|
||||
# otherwise only caught at runtime)
|
||||
import vterm.commands as commands
|
||||
import vterm.render as render
|
||||
import vterm.terminal as terminal_module
|
||||
from vterm.terminal import Terminal
|
||||
|
||||
|
||||
def command_class_name(name):
|
||||
return "".join(part.title() for part in name.split("_")) + "Command"
|
||||
|
||||
|
||||
for entry in load_sublime_json("vterm.sublime-commands") + keymap:
|
||||
cls = command_class_name(entry["command"])
|
||||
assert hasattr(commands, cls) or hasattr(render, cls), \
|
||||
"{} -> missing {}".format(entry["command"], cls)
|
||||
|
||||
# every pending_* name mentioned in the modules must be a real Terminal
|
||||
# attribute, so a renamed flag cannot leave a writer pointing at nothing
|
||||
flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")}
|
||||
assert flags == {"pending_clear", "pending_clear_screen"}, flags
|
||||
for module in (commands, render, terminal_module):
|
||||
with open(module.__file__, encoding="utf-8") as f:
|
||||
# (?<!_) skips the private _pending_to_send_string write-queue flag
|
||||
for name in set(re.findall(r"(?<!_)pending_[a-z_]+", f.read())):
|
||||
assert name in flags, "{}: {} is not a Terminal attribute".format(
|
||||
os.path.basename(module.__file__), name)
|
||||
|
||||
# a theme is data, so a typo'd colour name would only show up as a silently
|
||||
# unstyled scope at runtime
|
||||
from vterm.emulator import ANSI_COLORS
|
||||
from vterm.themes import THEMES
|
||||
|
||||
for name, theme in THEMES.items():
|
||||
missing = (set(ANSI_COLORS) | {"foreground", "background"}) - set(theme)
|
||||
assert not missing, "theme {}: missing {}".format(name, sorted(missing))
|
||||
assert not set(theme) - (set(ANSI_COLORS) | {"foreground", "background"}), name
|
||||
for key, value in theme.items():
|
||||
assert re.fullmatch(r"#[0-9a-f]{6}", value), \
|
||||
"theme {}: {} = {!r}".format(name, key, value)
|
||||
|
||||
configured = load_sublime_json("vterm.sublime-settings").get("theme")
|
||||
assert configured is None or configured in THEMES, configured
|
||||
|
||||
# plugin_loaded must create the colour scheme: sublime loads a restored tab's
|
||||
# color_scheme during startup, before any terminal opens
|
||||
# import main.py the way sublime does - as a module of the package directory -
|
||||
# so its relative imports and plugin_loaded body are actually executed
|
||||
pkg = types.ModuleType("vterm")
|
||||
pkg.__path__ = [ROOT]
|
||||
sys.modules["vterm"] = pkg
|
||||
main = importlib.import_module("vterm.main")
|
||||
|
||||
scheme_file = os.path.join(main.scheme_manager._directory(), scheme_module.SCHEME_FILE_NAME)
|
||||
if os.path.exists(scheme_file):
|
||||
os.remove(scheme_file)
|
||||
main.plugin_loaded()
|
||||
assert os.path.exists(scheme_file), \
|
||||
"plugin_loaded must leave the colour scheme on disk"
|
||||
|
||||
print("import smoke test passed ({} keymap bindings, {} pending flags, "
|
||||
"{} themes)".format(len(keymap), len(flags), len(THEMES)))
|
||||
509
tests/test_render.py
Normal file
509
tests/test_render.py
Normal file
@@ -0,0 +1,509 @@
|
||||
""" Render-layer tests against a fake view.
|
||||
|
||||
The live harness exercises the emulator only, so regressions in render.py used
|
||||
to reach Sublime unnoticed. Run with: python3 tests/test_render.py
|
||||
"""
|
||||
import _bootstrap # sets sys.path for the imports below
|
||||
from _bootstrap import FakeProcess
|
||||
|
||||
import sublime
|
||||
from vterm.emulator import VtScreen, VtStream
|
||||
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
|
||||
from vterm.render import (
|
||||
CHUNK,
|
||||
VtermRenderCommand,
|
||||
focus_cursor,
|
||||
)
|
||||
from vterm.terminal import Terminal
|
||||
|
||||
|
||||
class FakeSelection(list):
|
||||
def clear(self):
|
||||
del self[:]
|
||||
|
||||
def add(self, region):
|
||||
self.append(region)
|
||||
|
||||
|
||||
class FakeView:
|
||||
"""Enough of the view API for the trim paths, with real line arithmetic."""
|
||||
|
||||
def __init__(self, text=""):
|
||||
self.text = text
|
||||
self.selection = FakeSelection()
|
||||
self.regions = {}
|
||||
self.commands = []
|
||||
self.folds = []
|
||||
self._name = ""
|
||||
|
||||
def size(self):
|
||||
return len(self.text)
|
||||
|
||||
def rowcol(self, point):
|
||||
head = self.text[:point]
|
||||
row = head.count("\n")
|
||||
return (row, point - (head.rfind("\n") + 1))
|
||||
|
||||
def text_point(self, row, col):
|
||||
lines = self.text.split("\n")
|
||||
start = sum(len(l) + 1 for l in lines[:row])
|
||||
return start + min(col, len(lines[row]) if row < len(lines) else 0)
|
||||
|
||||
def line(self, point):
|
||||
start = self.text.rfind("\n", 0, point) + 1
|
||||
end = self.text.find("\n", point)
|
||||
return sublime.Region(start, len(self.text) if end == -1 else end)
|
||||
|
||||
def full_line(self, point):
|
||||
region = self.line(point)
|
||||
return sublime.Region(region.begin(),
|
||||
min(region.end() + 1, len(self.text)))
|
||||
|
||||
def lines(self, region):
|
||||
out = []
|
||||
point = region.begin()
|
||||
while point <= region.end():
|
||||
line = self.line(point)
|
||||
out.append(line)
|
||||
point = line.end() + 1
|
||||
return out
|
||||
|
||||
def substr(self, region):
|
||||
return self.text[region.begin():region.end()]
|
||||
|
||||
def erase(self, edit, region):
|
||||
self.text = self.text[:region.begin()] + self.text[region.end():]
|
||||
|
||||
def replace(self, edit, region, value):
|
||||
self.text = self.text[:region.begin()] + value + self.text[region.end():]
|
||||
# sublime maps a caret inside the replaced span onto the new text,
|
||||
# which is where the artefact selection came from
|
||||
for i, sel in enumerate(list(self.selection)):
|
||||
if sel.empty() and region.begin() <= sel.begin() <= region.end():
|
||||
self.selection[i] = sublime.Region(region.begin(),
|
||||
region.begin() + len(value))
|
||||
|
||||
def insert(self, edit, point, value):
|
||||
self.text = self.text[:point] + value + self.text[point:]
|
||||
return len(value)
|
||||
|
||||
def fold(self, region):
|
||||
self.folds.append((region.begin(), region.end()))
|
||||
return True
|
||||
|
||||
def unfold(self, region):
|
||||
unfolded = self.folds
|
||||
self.folds = []
|
||||
return unfolded
|
||||
|
||||
def folded_regions(self):
|
||||
return [sublime.Region(b, e) for b, e in self.folds]
|
||||
|
||||
def erase_regions(self, key):
|
||||
self.regions.pop(key, None)
|
||||
|
||||
def add_regions(self, key, regions, scope):
|
||||
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
|
||||
|
||||
def get_regions(self, key):
|
||||
_, pairs = self.regions.get(key, ("", []))
|
||||
return [sublime.Region(a, b) for a, b in pairs]
|
||||
|
||||
def settings(self):
|
||||
return sublime.load_settings("fake")
|
||||
|
||||
def viewport_position(self):
|
||||
return (0, 0)
|
||||
|
||||
def line_height(self):
|
||||
return 20
|
||||
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
def set_name(self, name):
|
||||
self._name = name
|
||||
|
||||
def run_command(self, name, args=None):
|
||||
self.commands.append(name)
|
||||
|
||||
def sel(self):
|
||||
return self.selection
|
||||
|
||||
|
||||
def make_terminal(rows=10, cols=20, scrollback=None):
|
||||
# real __init__ for the plain attributes; process/screen stand in for start()
|
||||
terminal = Terminal(FakeView())
|
||||
if scrollback is not None:
|
||||
terminal.scrollback_size = scrollback
|
||||
terminal.process = FakeProcess()
|
||||
terminal.screen = VtScreen(cols, rows, process=terminal.process)
|
||||
return terminal, VtStream(terminal.screen)
|
||||
|
||||
|
||||
def make_command(view):
|
||||
# the real __init__, so the fake cannot drift from the command's attributes
|
||||
return VtermRenderCommand(view)
|
||||
|
||||
|
||||
# an app on the alternate screen must not have its view trimmed away: the
|
||||
# emulator's history deque is maxlen=0 there, so it cannot be the row cap
|
||||
terminal, stream = make_terminal(rows=10)
|
||||
stream.feed("\x1b[?1049h")
|
||||
assert terminal.screen.alternate_buffer_mode
|
||||
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
|
||||
|
||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10)))
|
||||
command = make_command(terminal.view)
|
||||
before = terminal.view.text
|
||||
command.trim_history(None, terminal)
|
||||
assert terminal.view.text == before, \
|
||||
"alt screen view was trimmed: {!r}".format(terminal.view.text)
|
||||
|
||||
# the configured cap still applies on the primary screen
|
||||
terminal, stream = make_terminal(rows=5, scrollback=20)
|
||||
terminal.offset = 40
|
||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
|
||||
command = make_command(terminal.view)
|
||||
command.trim_history(None, terminal)
|
||||
rows_left = terminal.view.text.count("\n") + 1
|
||||
assert rows_left <= 20, rows_left
|
||||
assert "row 39" in terminal.view.text, "newest rows must survive"
|
||||
assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
|
||||
|
||||
# below the cap nothing is dropped
|
||||
terminal, stream = make_terminal(rows=5, scrollback=5000)
|
||||
terminal.offset = 3
|
||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8)))
|
||||
command = make_command(terminal.view)
|
||||
command.trim_history(None, terminal)
|
||||
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
||||
terminal.view.text
|
||||
|
||||
print("render tests passed (alt screen keeps its view, cap honoured)")
|
||||
|
||||
|
||||
# a selection the user made must survive app output: focus_cursor runs on every
|
||||
# frame, and clearing it there made click-drag selection impossible to hold
|
||||
terminal, stream = make_terminal(rows=5, cols=20)
|
||||
terminal.view = FakeView("hello world\nsecond line\n")
|
||||
stream.feed("x")
|
||||
selection = terminal.view.sel()
|
||||
selection.add(sublime.Region(0, 5)) # user selected "hello"
|
||||
focus_cursor(terminal.view, None, terminal)
|
||||
assert len(selection) == 1 and selection[0].begin() == 0 and selection[0].end() == 5, \
|
||||
"user selection was clobbered by a render"
|
||||
|
||||
# an empty selection (a plain click, or none at all) still gets the caret placed
|
||||
# at the terminal cursor
|
||||
selection.clear()
|
||||
focus_cursor(terminal.view, None, terminal)
|
||||
assert len(selection) == 1 and selection[0].empty(), \
|
||||
"caret must be placed when nothing is selected"
|
||||
|
||||
print("selection tests passed (user selection survives renders, caret still tracked)")
|
||||
|
||||
|
||||
# an app that hides the cursor (pi hides it for good) still needs the caret:
|
||||
# sublime drops a space keypress in a view that has no caret at all
|
||||
selection.clear()
|
||||
stream.feed("\x1b[?25l")
|
||||
focus_cursor(terminal.view, None, terminal)
|
||||
assert len(selection) == 1 and selection[0].empty(), \
|
||||
"caret must survive DECTCEM, or the space key stops working"
|
||||
|
||||
print("cursor visibility tests passed (hidden cursor keeps the caret)")
|
||||
|
||||
|
||||
# colorize_line positions regions by arithmetic from the line start now, and
|
||||
# batches the runs that share a scope into one add_regions call
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal.view)
|
||||
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
|
||||
command.update_lines(None, terminal)
|
||||
|
||||
painted = terminal.view.regions
|
||||
scopes = sorted(scope for scope, _ in painted.values())
|
||||
assert scopes == ["vterm.green.default", "vterm.red.default"], scopes
|
||||
red = next(spans for scope, spans in painted.values() if scope.endswith("red.default"))
|
||||
assert red == [(0, 3), (5, 8)], "two red runs must share one key: {}".format(red)
|
||||
green = next(spans for scope, spans in painted.values() if scope.endswith("green.default"))
|
||||
assert green == [(10, 15)], green
|
||||
assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text)
|
||||
|
||||
# the default-on-default run between them gets no region at all
|
||||
assert len(painted) == 2, painted
|
||||
|
||||
# keys are unique per view and recorded so decolorize_line can erase them
|
||||
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \
|
||||
"colored_lines must track exactly the keys that were added"
|
||||
command.decolorize_line(0)
|
||||
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
||||
|
||||
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
||||
|
||||
|
||||
# rows that leave the screen have their colours merged onto shared per-(scope,
|
||||
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
||||
# the whole session, and merging every frame would re-send the whole chunk
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal.view)
|
||||
for i in range(300):
|
||||
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
|
||||
command.update_lines(None, terminal)
|
||||
terminal.screen.dirty.clear()
|
||||
|
||||
regions = terminal.view.regions
|
||||
chunk = "vterm@0@vterm.red.default"
|
||||
assert chunk in regions, sorted(regions)
|
||||
_, spans = regions[chunk]
|
||||
assert len(spans) == CHUNK, "the closed chunk merges its rows: {}".format(len(spans))
|
||||
assert "vterm@1@vterm.red.default" not in regions, "an open chunk must not be sent yet"
|
||||
row_keys = [k for k in regions if k.startswith("vterm#")]
|
||||
assert len(row_keys) == 300 - CHUNK, \
|
||||
"merged rows drop their per-row keys, the open chunk keeps them: {}".format(len(row_keys))
|
||||
|
||||
# trimming settles the open chunk, then erases the keys of chunks above the cut
|
||||
terminal, stream = make_terminal(rows=3, scrollback=30)
|
||||
terminal.view = FakeView()
|
||||
command = make_command(terminal.view)
|
||||
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
||||
command.update_lines(None, terminal)
|
||||
terminal.screen.dirty.clear()
|
||||
assert set(command._archived_chunks) == {0}, command._archived_chunks
|
||||
command.trim_history(None, terminal)
|
||||
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
||||
assert 0 not in command._archived_chunks
|
||||
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
|
||||
|
||||
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
|
||||
|
||||
|
||||
# a fresh terminal must not be left with the prompt selected: our own replace
|
||||
# expands the caret across the inserted text, and sublime paints that with the
|
||||
# selection colour (its default is pure blue)
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
terminal.view = FakeView()
|
||||
terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
|
||||
Terminal._terminals[id(terminal)] = terminal
|
||||
terminal.view.id = lambda: id(terminal)
|
||||
terminal.default_title = "zsh"
|
||||
command = make_command(terminal.view)
|
||||
stream.feed("[vterm] %")
|
||||
command.run(None)
|
||||
assert all(r.empty() for r in terminal.view.sel()), \
|
||||
"prompt was left selected: {}".format([(r.begin(), r.end()) for r in terminal.view.sel()])
|
||||
|
||||
# but a selection the user already had is still respected
|
||||
terminal.view.sel().clear()
|
||||
terminal.view.sel().add(sublime.Region(2, 8))
|
||||
stream.feed("\x1b[2Kmore output")
|
||||
command.run(None)
|
||||
kept = [(r.begin(), r.end()) for r in terminal.view.sel()]
|
||||
assert any(b != e for b, e in kept), "user selection was dropped: {}".format(kept)
|
||||
|
||||
# pyte's dirty is a repaint hint: rows whose content did not change must not
|
||||
# be rewritten (each rewrite is a cross-process call and an on_modified)
|
||||
rewritten = []
|
||||
_replace = terminal.view.replace
|
||||
terminal.view.replace = lambda edit, region, value: (rewritten.append(value), _replace(edit, region, value))[1]
|
||||
terminal.screen.dirty.update(range(terminal.screen.lines))
|
||||
command.run(None)
|
||||
terminal.view.replace = _replace
|
||||
# blank tail rows still churn via trim_trailing_spaces; content rows must not
|
||||
assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}".format(rewritten)
|
||||
print("render diff tests passed (unchanged dirty rows skipped)")
|
||||
|
||||
# the generated scheme must define a selection colour, or sublime uses blue
|
||||
from vterm.scheme import blend, scheme_manager
|
||||
scheme_manager.background = "#303841"
|
||||
scheme_manager.foreground = "#d8dee9"
|
||||
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
|
||||
|
||||
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
|
||||
|
||||
|
||||
# DECSCUSR drives the caret shape through view settings; before any app asks,
|
||||
# and for 0, the caret is the terminal default: a blinking block
|
||||
terminal, stream = make_terminal(rows=3, cols=20)
|
||||
command = make_command(terminal.view)
|
||||
settings = terminal.view.settings()
|
||||
|
||||
command.sync_cursor_style(terminal)
|
||||
assert settings.get("block_caret") is True, settings.get("block_caret")
|
||||
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
||||
|
||||
stream.feed("\x1b[2 q")
|
||||
command.sync_cursor_style(terminal)
|
||||
assert settings.get("block_caret") is True, settings.get("block_caret")
|
||||
assert settings.get("caret_style") == "solid", settings.get("caret_style")
|
||||
|
||||
stream.feed("\x1b[5 q")
|
||||
command.sync_cursor_style(terminal)
|
||||
assert settings.get("block_caret") is False, settings.get("block_caret")
|
||||
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
||||
|
||||
stream.feed("\x1b[0 q")
|
||||
command.sync_cursor_style(terminal)
|
||||
assert settings.get("block_caret") is True, settings.get("block_caret")
|
||||
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
||||
|
||||
# a hidden cursor outranks DECSCUSR: the caret stays for typing, as a block on
|
||||
# the cell the app draws its own cursor over
|
||||
stream.feed("\x1b[5 q\x1b[?25l")
|
||||
command.sync_cursor_style(terminal)
|
||||
assert settings.get("block_caret") is True, settings.get("block_caret")
|
||||
assert settings.get("caret_style") == "solid", settings.get("caret_style")
|
||||
|
||||
stream.feed("\x1b[?25h")
|
||||
command.sync_cursor_style(terminal)
|
||||
assert settings.get("block_caret") is False, settings.get("block_caret")
|
||||
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
||||
|
||||
print("cursor style tests passed (DECSCUSR maps to caret settings, 0 is the default block)")
|
||||
|
||||
|
||||
# wheel over an alt-screen app that does not track the mouse must fall back to
|
||||
# arrow keys (alternate scroll); with tracking on, SGR reports as before
|
||||
terminal, stream = make_terminal(rows=5, cols=20)
|
||||
Terminal._terminals[id(terminal)] = terminal
|
||||
terminal.view.id = lambda: id(terminal)
|
||||
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
|
||||
scroll.view = terminal.view
|
||||
|
||||
stream.feed("\x1b[?1049h")
|
||||
scroll.run(None, up=True)
|
||||
assert terminal.process.written == "\x1b[A" * 3, repr(terminal.process.written)
|
||||
|
||||
# application cursor mode switches the arrow encoding
|
||||
terminal.process.written = ""
|
||||
stream.feed("\x1b[?1h")
|
||||
scroll.run(None, up=False)
|
||||
assert terminal.process.written == "\x1bOB" * 3, repr(terminal.process.written)
|
||||
|
||||
# with mouse tracking on, the wheel is reported, not translated: one report per
|
||||
# line, aimed mid-screen when there is no event to place it
|
||||
terminal.process.written = ""
|
||||
stream.feed("\x1b[?1002h\x1b[?1006h")
|
||||
scroll.run(None, up=True)
|
||||
assert terminal.process.writes == ["\x1b[<64;1;3M"] * 3, terminal.process.writes
|
||||
|
||||
# on the primary screen without tracking the command stays inert
|
||||
terminal.process.written = ""
|
||||
stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049l")
|
||||
scroll.run(None, up=True)
|
||||
assert terminal.process.written == "", repr(terminal.process.written)
|
||||
|
||||
print("alternate scroll tests passed (3 lines per tick: arrows on alt screen, SGR when tracking, inert otherwise)")
|
||||
|
||||
|
||||
# a click in an unfocused group must focus the tab instead of reporting: we
|
||||
# replaced drag_select, which is what sublime focuses the group from
|
||||
class FakeWindow:
|
||||
def __init__(self, active):
|
||||
self.active = active
|
||||
|
||||
def active_view(self):
|
||||
return self.active
|
||||
|
||||
def focus_view(self, view):
|
||||
self.active = view
|
||||
|
||||
|
||||
terminal, stream = make_terminal(rows=5, cols=20)
|
||||
Terminal._terminals[id(terminal)] = terminal
|
||||
terminal.view.id = lambda: id(terminal)
|
||||
terminal.view.window_to_text = lambda xy: 0
|
||||
window = FakeWindow(active=object())
|
||||
terminal.view.window = lambda: window
|
||||
stream.feed("\x1b[?1002h\x1b[?1006h")
|
||||
click = VtermMouseClickCommand.__new__(VtermMouseClickCommand)
|
||||
click.view = terminal.view
|
||||
event = {"x": 0, "y": 0, "button": 1}
|
||||
|
||||
click.run(None, event)
|
||||
assert window.active_view() is terminal.view, "click did not focus the tab"
|
||||
assert terminal.process.written == "", repr(terminal.process.written)
|
||||
|
||||
# once focused, the same click reports as before
|
||||
click.run(None, event)
|
||||
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written)
|
||||
|
||||
print("click focus tests passed (first click focuses only, then reports)")
|
||||
|
||||
|
||||
# entering the alt screen folds the scrollback away (kitty hides it); leaving
|
||||
# unfolds it, and the fold resets the follow anchor so the gate re-engages.
|
||||
# the fold ends one character before the screen's first row, out of reach of
|
||||
# the per-frame replaces of that row
|
||||
terminal, stream = make_terminal(rows=5, cols=20)
|
||||
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
|
||||
terminal.offset = 3
|
||||
terminal.viewport_y = 400
|
||||
command = make_command(terminal.view)
|
||||
fold_end = terminal.view.text_point(3, 0) - 1
|
||||
|
||||
terminal.view.settings().set("scroll_past_end", True)
|
||||
|
||||
stream.feed("\x1b[?1049h")
|
||||
command.sync_alt_fold(terminal)
|
||||
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
|
||||
assert terminal.viewport_y == 0, terminal.viewport_y
|
||||
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
|
||||
|
||||
# a second render leaves the fold alone
|
||||
command.sync_alt_fold(terminal)
|
||||
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
|
||||
|
||||
# a disturbed fold is re-asserted on the next render
|
||||
terminal.view.folds = [(0, fold_end - 2)]
|
||||
command.sync_alt_fold(terminal)
|
||||
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
|
||||
|
||||
stream.feed("\x1b[?1049l")
|
||||
command.sync_alt_fold(terminal)
|
||||
assert terminal.view.folds == [], terminal.view.folds
|
||||
assert terminal.view.settings().get("scroll_past_end") is True, "primary screen scrolls past end again"
|
||||
|
||||
# without scrollback there is nothing to fold, but the scroll lock still applies
|
||||
terminal, stream = make_terminal(rows=5, cols=20)
|
||||
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
|
||||
command = make_command(terminal.view)
|
||||
stream.feed("\x1b[?1049h")
|
||||
command.sync_alt_fold(terminal)
|
||||
assert terminal.view.folds == [], terminal.view.folds
|
||||
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
|
||||
|
||||
print("alt fold tests passed (scrollback folded on entry, restored on exit)")
|
||||
|
||||
|
||||
# a row whose first cells were never written: they read back as default cells
|
||||
# (StaticDefaultDict), so they form their own uncoloured run and the coloured
|
||||
# run that follows keeps the right column
|
||||
from pyte.screens import StaticDefaultDict
|
||||
from vterm.emulator import Char
|
||||
from vterm.render import segment_buffer_line
|
||||
|
||||
line = StaticDefaultDict(Char(" "))
|
||||
line[3] = Char("X", "red", "default")
|
||||
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
||||
assert runs == [(" ", 0, 3, "default", "default", ""),
|
||||
("X", 3, 4, "red", "default", "")], runs
|
||||
|
||||
# wide characters consume two columns but one character of text
|
||||
line = StaticDefaultDict(Char(" "))
|
||||
line[0] = Char("漢", "red", "default")
|
||||
line[1] = Char("", "red", "default")
|
||||
line[2] = Char("x", "blue", "default")
|
||||
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
||||
assert runs == [("漢", 0, 1, "red", "default", ""), ("x", 1, 2, "blue", "default", "")], runs
|
||||
|
||||
# reverse video swaps the pair, and default becomes reverse_default
|
||||
line = StaticDefaultDict(Char(" "))
|
||||
line[0] = Char("r", "red", "default", False, False, False, False, True)
|
||||
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
||||
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
|
||||
|
||||
print("segmenter tests passed (leading gap, wide char, reverse)")
|
||||
Reference in New Issue
Block a user