Files
LLeMbas/tests/test_terminal_capture.py
T
Homer 3d51ba061e Tests that found things reading did not
The testing pass: 2140 tests to 2283, and four bugs that no amount of
reading had turned up. Three came from driving the JavaScript under a
Node DOM stub, which is the practice CLAUDE.md sets out and this is the
reason it does.

The terminal dropped every keystroke after a reconnect. `onclose` closed
over the module-level socket rather than its own, and close() queues its
event -- so the old socket's close arrived after a new one was assigned
and nulled the live one. Output kept coming, because onmessage is bound
to the object, while every send gates on the variable. It also announced
"Disconnected" about a shell that had just reconnected.

Two scripts were loaded twice on /messages, once by base.html and again
by the page. Each is an IIFE with its own state, so four keyboard
shortcuts toggled their panel twice and therefore did nothing, /help
opened two dialogs, and an @ mention attached its file twice. A sweep
refuses any template re-loading what base.html has.

The microphone had no guard while the permission prompt was up, so each
click opened another stream and only the last was ever stopped. And a
skill shared with you took its name out of your own library: create
checked uniqueness against what is *visible* rather than what is owned,
against a (owner_id, name) constraint, and told you to edit a row you
cannot edit.

--ink-faint failed the contrast minimum in both themes -- 3.85 and 3.19
against 4.5 -- so the smallest text on every screen was the hardest to
read. Measured in a headless browser rather than judged by eye.

And the suite runs on 3.11 and 3.12 now as well as 3.14. It had only ever
run on 3.14 while the image ships 3.12 and the packaging claimed 3.11:
the interpreter most people would run was the one nothing had tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:41:45 +02:00

417 lines
14 KiB
Python

"""Where one command ends and the next begins, and what is kept of it.
Three layers, tested separately because they fail separately: the byte scanner,
the bounding, and a real PTY emitting real markers.
The scanner tests need no server and no event loop -- it is a byte state
machine. That is most of the argument for parsing server-side rather than in
xterm: the thing that has to be right is testable without a browser.
"""
from __future__ import annotations
import pytest
from lembas.services.agent import capture, shell_marks
# Stands up something real -- see the `slow` marker in pyproject.toml.
pytestmark = pytest.mark.slow
asyncssh = pytest.importorskip("asyncssh")
def _osc(body: str) -> bytes:
return f"\033]{body}\007".encode()
def _seen(chunks) -> list[tuple[str, str]]:
marks: list[tuple[str, str]] = []
scanner = shell_marks.Marks(lambda kind, value: marks.append((kind, value)))
for chunk in chunks:
scanner.feed(chunk)
return marks
# --- The scanner -------------------------------------------------------------
def test_a_marker_is_read_out_of_a_stream():
marks = _seen([b"hello" + _osc("133;A") + b"$ "])
assert marks == [("A", "")]
def test_a_marker_split_across_writes_is_still_seen():
"""The test that justifies parsing server-side being safe at all.
The pump is handed 64KB at a time on no particular boundary, so the ESC and
the `]` land in different frames often enough to matter. Fed one byte at a
time, which is the worst case and the cheapest way to prove it.
"""
stream = b"before" + _osc("633;E;make -j8") + b"after"
marks = _seen([stream[i : i + 1] for i in range(len(stream))])
assert marks == [("E", "make -j8")]
def test_a_marker_terminated_with_st_rather_than_bel_is_seen():
"""Both terminators are legal and shells in the wild use both."""
marks = _seen([b"\033]133;D;2\033\\"])
assert marks == [("D", "2")]
def test_a_lone_escape_bracket_in_binary_output_does_not_swallow_the_session():
"""`cat` of a binary file produces stray ESC ] regularly. Without the bound
one of them would buffer the rest of the session and never emit."""
junk = b"\033]" + b"x" * (shell_marks.MAX_MARKER_BYTES + 10)
marks = _seen([junk, _osc("133;A")])
assert marks == [("A", "")]
def test_an_unterminated_marker_does_not_eat_the_next_one():
marks = _seen([b"\033]133;A" + _osc("133;C")])
assert ("C", "") in marks
def test_an_escape_that_is_not_an_osc_is_ignored():
"""A colour change is `ESC [`, not `ESC ]`, and there is a lot of it."""
assert _seen([b"\033[31mred\033[0m"]) == []
def test_a_semicolon_in_a_command_survives_the_escaping():
"""The payload may contain no raw `;` or the fields split, so the shell
escapes it and this puts it back."""
marks = _seen([_osc("633;E;cd /tmp \\x3b ls")])
assert marks == [("E", "cd /tmp \\x3b ls")]
assert shell_marks.unescape(marks[0][1]) == "cd /tmp ; ls"
def test_an_unrelated_osc_is_not_ours():
"""Setting the window title is OSC 0 and happens constantly."""
assert _seen([_osc("0;some title")]) == []
# --- The bounding ------------------------------------------------------------
def test_a_flood_keeps_the_head_and_the_tail_and_says_what_it_dropped():
"""Either half alone is the wrong half: a build that fails ten megabytes in
has the invocation at the top and the error at the bottom."""
found = capture.Capture(command="make")
found.absorb(b"START\n")
found.absorb(b"x" * (capture.CAPTURE_HEAD_BYTES * 2))
found.absorb(b"END\n")
output = found.output()
assert output.startswith("START")
assert output.rstrip().endswith("END")
assert "dropped" in output
def test_short_output_is_kept_whole():
found = capture.Capture(command="ls")
found.absorb(b"one\ntwo\n")
assert found.output() == "one\ntwo"
def test_a_progress_bar_collapses_to_its_last_state():
"""The highest-value transform here. Only the last state of a line was ever
on screen, and keeping every one turns two megabytes of `pip install` into
two megabytes of spinner in somebody's prompt."""
found = capture.Capture(command="pip install")
found.absorb(b"".join(f"\r{n}%".encode() for n in range(500)) + b"\ndone\n")
output = found.output()
assert output == "499%\ndone"
def test_escape_sequences_are_stripped():
found = capture.Capture(command="ls --color")
found.absorb(b"\033[31mred\033[0m\n")
assert found.output() == "red"
def test_a_split_multibyte_character_degrades_rather_than_raising():
"""Head/tail slicing splits UTF-8 at will, so the decode has to replace."""
found = capture.Capture(command="cat")
found.absorb("héllo".encode()[:3])
assert isinstance(found.output(), str)
def test_a_fence_cannot_be_ended_early_by_the_output():
"""A real injection route: output containing three backticks would close
the block, and everything after it would read to the model as prose rather
than as what a machine printed."""
block = capture.fenced("here are ``` three")
assert block.startswith("````")
assert block.rstrip().endswith("````")
def test_the_attribution_sits_outside_the_fence():
"""So nothing the far side printed can forge it. The `$ ` line is
synthesised here too -- what the shell echoed carries readline's editing
escapes and is not the command."""
found = capture.Capture(command="pytest -q", cwd="/srv/work", exit_status=1, ended=1.0)
found.absorb(b"1 failed\n")
text = found.as_text(label="Container")
assert text.startswith("Ran in the terminal on Container, in /srv/work — exit 1")
assert "```console\n$ pytest -q" in text
def test_output_still_running_is_capturable():
""""Copy the last command" while `make` is going should give what has been
printed so far, marked as running -- not "nothing yet"."""
found = capture.Capture(command="make")
found.absorb(b"compiling\n")
assert found.running
assert "still running" in found.as_text(label="Box")
assert "compiling" in found.as_text(label="Box")
def test_a_very_long_line_is_cut():
found = capture.Capture(command="cat bundle.min.js")
found.absorb(b"z" * (capture.MAX_LINE_CHARS * 3) + b"\n")
assert len(found.output()) <= capture.MAX_LINE_CHARS + 1
# --- The command string ------------------------------------------------------
def test_the_command_branches_on_the_shell_name():
command = shell_marks.command_for("/srv/work")
assert "case ${SHELL##*/} in" in command
assert "bash --rcfile" in command
assert "ZDOTDIR=" in command
def test_a_single_quote_in_the_directory_cannot_end_the_quoting():
command = shell_marks.command_for("/tmp/it's here; rm -rf /")
assert command.startswith("cd '/tmp/it'\\''s here; rm -rf /'")
def test_switching_integration_off_gives_back_exactly_what_was_there_before():
assert shell_marks.command_for("", integrate=False) is None
assert (
shell_marks.command_for("/srv/work", integrate=False)
== "cd '/srv/work' 2>/dev/null; exec ${SHELL:-/bin/sh} -l"
)
def test_the_integration_writes_nothing_to_the_terminals_input_side():
"""Which is the whole reason this mechanism was chosen over feeding
`source …` in as keystrokes. Nothing is echoed because nothing is typed, so
there is no setup to hide from the scrollback and no fan-out gate."""
command = shell_marks.command_for("/srv/work")
assert "printf %s" in command # written to a file
assert "$__L/rc" in command
# --- Against a real PTY ------------------------------------------------------
# A fake shell that speaks the markers rather than a real bash: what is under
# test here is the session's bookkeeping, and a real bash would make every
# assertion depend on whoever's dotfiles the machine happens to carry. The rc
# files themselves are verified by hand, once, per shell.
import asyncio # noqa: E402
from lembas.services.agent import terminal as terminal_service # noqa: E402
class _Server(asyncssh.SSHServer):
def begin_auth(self, username: str) -> bool:
return False
@pytest.fixture
async def marking_host():
seen: dict = {}
def osc(body: str) -> str:
return f"\033]{body}\007"
async def handler(process):
seen["command"] = process.command
process.stdout.write(osc("633;LEMBAS;bash;1"))
process.stdout.write(osc("633;P;Cwd=/srv/work") + osc("133;A") + "$ ")
while True:
try:
line = (await process.stdin.readline()).rstrip("\n")
except asyncssh.TerminalSizeChanged:
continue
except Exception: # noqa: BLE001
break
if not line or line == "exit":
break
process.stdout.write(osc("633;E;" + line.replace(";", "\\x3b")))
process.stdout.write(osc("133;C"))
status = 0
if line.startswith("fail"):
process.stdout.write("boom\n")
status = 3
elif line.startswith("spin"):
for n in range(200):
process.stdout.write(f"\r{n}%")
process.stdout.write("\ndone\n")
else:
process.stdout.write(f"out:{line}\n")
process.stdout.write(osc(f"133;D;{status}"))
process.stdout.write(osc("633;P;Cwd=/srv/work") + osc("133;A") + "$ ")
process.exit(0)
server = await asyncssh.create_server(
_Server,
"127.0.0.1",
0,
server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
process_factory=handler,
)
port = next(iter(server.sockets)).getsockname()[1]
from lembas.services.agent import ssh as ssh_service
line, _fingerprint = await ssh_service.capture_host_key("127.0.0.1", port)
try:
yield {"port": port, "host_key": line, "seen": seen}
finally:
await terminal_service.shutdown()
server.close()
await server.wait_closed()
def _spec(host) -> dict:
return {
"host": "127.0.0.1",
"port": host["port"],
"username": "tester",
"auth": "password",
"password": "",
"private_key": "",
"key_passphrase": "",
"host_key": host["host_key"],
"connect_timeout": 10,
}
async def _session(host, **kwargs):
return await terminal_service.open_session(
"chat-1",
owner_id="user-1",
profile_id="profile-1",
label="Container",
spec=_spec(host),
**kwargs,
)
async def _settle(session, want, *, timeout=5.0):
"""Wait until `want(session)` holds, or give up."""
async with asyncio.timeout(timeout):
while not want(session):
await asyncio.sleep(0.02)
async def test_the_shell_says_it_loaded_the_integration(marking_host):
session = await _session(marking_host)
session.attach()
await _settle(session, lambda s: s.integration == terminal_service.INTEGRATION_LIVE)
assert session.shell == "bash"
await session.close()
async def test_a_command_and_its_output_are_captured_between_the_markers(marking_host):
session = await _session(marking_host)
session.attach()
await _settle(session, lambda s: s.integration == terminal_service.INTEGRATION_LIVE)
await session.send(b"ls -la\n")
await _settle(session, lambda s: s.last is not None)
assert session.last.command == "ls -la"
assert "out:ls -la" in session.last.output()
assert session.last.cwd == "/srv/work"
await session.close()
async def test_the_exit_code_comes_back_with_the_command(marking_host):
session = await _session(marking_host)
session.attach()
await _settle(session, lambda s: s.integration == terminal_service.INTEGRATION_LIVE)
await session.send(b"fail now\n")
await _settle(session, lambda s: s.last is not None)
assert session.last.exit_status == 3
assert not session.last.running
await session.close()
async def test_a_semicolon_in_the_command_survives_the_round_trip(marking_host):
session = await _session(marking_host)
session.attach()
await _settle(session, lambda s: s.integration == terminal_service.INTEGRATION_LIVE)
await session.send(b"cd /tmp ; ls\n")
await _settle(session, lambda s: s.last is not None)
assert session.last.command == "cd /tmp ; ls"
await session.close()
async def test_a_progress_bar_is_collapsed_before_it_reaches_a_prompt(marking_host):
session = await _session(marking_host)
session.attach()
await _settle(session, lambda s: s.integration == terminal_service.INTEGRATION_LIVE)
await session.send(b"spin\n")
await _settle(session, lambda s: s.last is not None)
output = session.last.output()
assert output.count("%") == 1
assert output.rstrip().endswith("done")
await session.close()
async def test_the_markers_still_reach_the_browser(marking_host):
"""Fanned out unchanged. xterm consumes an OSC it has no handler for and
never draws it, and rewriting frames on the hot path would break the
"nothing decodes, so nothing splits" property the pump depends on."""
session = await _session(marking_host)
viewer = session.attach()
await _settle(session, lambda s: s.integration == terminal_service.INTEGRATION_LIVE)
seen = b""
while not viewer.queue.empty():
chunk = viewer.queue.get_nowait()
if isinstance(chunk, bytes):
seen += chunk
assert b"\033]133;A" in seen
await session.close()
async def test_a_shell_that_never_marks_ends_up_reported_as_none(marking_host, monkeypatch):
"""The fallback path's own test, and the one that matters most: without it
the buttons would sit greyed out forever with no explanation. This is what
catches a `.bashrc` that ends in `exec tmux`."""
monkeypatch.setattr(terminal_service, "INTEGRATION_GRACE", 0.0)
session = await _session(marking_host)
session.integration = terminal_service.INTEGRATION_LOADING
session._marks = shell_marks.Marks(lambda kind, value: None) # deaf on purpose
session.attach()
await session.send(b"anything\n")
await _settle(session, lambda s: s.integration == terminal_service.INTEGRATION_NONE)
await session.close()