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>
This commit is contained in:
2026-08-07 14:41:45 +02:00
parent 32003bf8dd
commit 3d51ba061e
31 changed files with 2663 additions and 12 deletions
+268
View File
@@ -0,0 +1,268 @@
"""The terminal panel, checked without a runtime.
There is no JavaScript test runner here and hard rule 1 keeps Node out of the
project, so the behaviour is driven by hand under a DOM stub before committing.
Almost everything this file gets wrong is silent: `fit()` measures `offsetWidth`,
which is 0 inside a `[hidden]` ancestor, so fitting while closed does nothing at
all and leaves an 80-column shell in a 34rem panel; a window `resize` event does
not fire when the sidebar is toggled, which is the commonest way the panel
changes size; and a handshake against the app root fails into `onerror` and
blames the proxy for a connection the reader simply has not chosen yet.
What the suite can pin are the guards those three depend on, and the properties
around them whose absence looks exactly like working.
"""
from __future__ import annotations
from pathlib import Path
import lembas
from .conftest import js_code, js_function, js_says, script
SOURCE = script("terminal.js")
CODE = js_code(SOURCE)
TEMPLATES = Path(lembas.__file__).parent / "web/templates"
def _after(text: str, marker: str, span: int = 500) -> str:
"""The code just after a marker -- for a handler with no name to ask for."""
return text[text.index(marker) :][:span]
# --- Connecting ---------------------------------------------------------------
def test_connect_refuses_a_panel_with_no_target():
"""With no connection chosen `data-url` is empty, and the URL below then
becomes `ws://host?cols=80&rows=24` -- a WebSocket handshake against the
application root. That fails into `onerror`, whose message guesses at a
proxy not passing upgrades through: a confident, wrong explanation for
something the reader has simply not chosen yet.
The guard has to come before the socket, which is the whole assertion.
"""
connect = js_function(SOURCE, "connect")
assert "!panel.dataset.url" in connect, "the empty-target guard is gone"
assert connect.index("!panel.dataset.url") < connect.index("new WebSocket"), (
"a panel with no target still opens a socket"
)
# And says so, rather than leaving the panel silent.
assert "say(" in connect[: connect.index("new WebSocket")]
def test_the_socket_url_is_built_from_the_panel_s_own_path():
"""The path is server-rendered onto the panel, and `draft.js` rewrites it
before a chat exists. Assembling one here from a chat id would be a second
place for it to be got subtly differently, and a wrong one connects to
another machine's shell rather than failing."""
connect = js_function(SOURCE, "connect")
assert "location.host + panel.dataset.url" in connect
# A page served over TLS must not open a plain socket; browsers block it,
# which reads as the terminal never connecting.
assert js_says(connect, 'location.protocol === "https:"', '"wss://"', '"ws://"')
def test_a_deliberate_close_is_flagged_before_it_is_made():
"""Both reconnect paths close a socket on purpose -- the server disconnecting
a window that stopped reading, and the composer changing which machine the
panel points at. The flag is what stops "Disconnected. Close and reopen to
reconnect." being reported for a connection nobody lost, so it has to be set
before the close, not after it."""
for marker in ('payload.t === "behind"', "repointTerminal = function"):
window = _after(CODE, marker)
assert window.index("closedOnPurpose = true") < window.index("socket.close()"), marker
def test_a_malformed_control_frame_is_ignored():
"""Text frames carry JSON and PTY bytes do not, and the two share one
`onmessage`. An exception here kills the handler for the rest of the
session, so the shell keeps running and the panel stops showing it."""
control = js_function(SOURCE, "control")
assert "try {" in control
assert control.index("JSON.parse") < control.index("catch")
def test_bytes_go_through_to_xterm_undecoded():
"""xterm's decoder is stateful across calls, so a multi-byte character split
across two frames still lands correctly -- which is exactly why the server
never decodes either. Decoding here would break the same character in the
other direction."""
assert js_says(CODE, "opened.binaryType", '"arraybuffer"')
assert js_says(CODE, "term.write(", "new Uint8Array(event.data)")
assert "TextDecoder" not in CODE
# --- Sizing -------------------------------------------------------------------
def test_a_hidden_panel_is_never_fitted():
"""`fit()` measures `offsetWidth`, which is 0 inside a `[hidden]` ancestor.
Fitting while closed therefore succeeds, silently, at nothing -- and leaves
an 80-column terminal in a panel that is not 80 columns wide, which reads as
xterm being broken rather than as a call made too early."""
refit = js_function(SOURCE, "refit")
assert refit.index("visible()") < refit.index("fit.fit()")
visible = js_function(SOURCE, "visible")
assert 'hasAttribute("hidden")' in visible
assert "offsetWidth > 0" in visible, "an ancestor's `hidden` measures as zero, not as hidden"
def test_opening_waits_a_frame_before_fitting():
"""The panel has just had `hidden` removed and has no measured width yet, so
a fit in the same turn is the silent no-op above."""
opener = js_function(SOURCE, "open")
assert opener.index("requestAnimationFrame") < opener.index("refit()")
def test_the_panel_watches_its_own_size():
"""A window `resize` event does not fire when the sidebar is toggled or a
panel opens beside this one, and those are by far the commonest ways this
panel changes size. Without the observer the terminal keeps the width it had
when it was opened and wraps every line at the wrong column."""
build = js_function(SOURCE, "build")
assert "ResizeObserver" in build
assert "observe(panel)" in build
def test_a_resize_is_told_to_the_far_side():
"""Fitting changes what this end draws; the PTY on the other end keeps its
old window size until it is told, so a full-screen program like `vim` or
`htop` paints to a geometry that is no longer there."""
refit = js_function(SOURCE, "refit")
assert js_says(refit, 't: "resize"', "cols: term.cols", "rows: term.rows")
def test_closing_the_panel_leaves_the_session_alone():
"""`write()` is queued internally, so disposing mid-output drops it; keeping
the object is what makes reopening instant; and the shell on the far side
outlives this panel by design. Closing must therefore do nothing at all."""
toggle = CODE[CODE.index('"lembas:toggle"') : CODE.index('panel.addEventListener("click"')]
assert "open()" in toggle
assert "dispose" not in toggle
assert ".close(" not in toggle
assert "dispose" not in CODE, "nothing here may dispose the terminal"
# --- The theme ----------------------------------------------------------------
def test_the_theme_is_pushed_in_when_it_changes():
"""xterm holds colours as values, not as variables, so it never notices a
`data-theme` swap. Without this, switching to `shire` leaves a black
rectangle in a light interface."""
assert '"lembas:theme"' in CODE
assert js_says(CODE, "term.options.theme", "readTheme()")
def test_a_colour_slot_with_no_token_is_left_off_entirely():
"""xterm treats `undefined` as a colour and renders it black, so a missing
ANSI token would not fall back -- it would paint that slot black on a light
theme, which is one unreadable colour rather than a visible failure."""
read = js_function(SOURCE, "readTheme")
assert js_says(read, "if (value)", "theme[name]", "value")
# --- Handing a command to the chat --------------------------------------------
def test_a_command_line_is_never_rendered_as_markup():
"""Everything shown here came off somebody's machine: a command line, a
working directory, a message from the far side. Hard rule 6 again."""
assert "innerHTML" not in CODE
assert "textContent" in js_function(SOURCE, "say")
assert "textContent" in js_function(SOURCE, "showLast")
def test_the_button_puts_it_in_the_box_rather_than_sending_it():
"""What a machine printed is exactly the sort of text somebody should read
before a model does, and the box is where that happens. Only the `auto:
send` mode skips that, and only because it was chosen."""
send = js_function(SOURCE, "sendToChat")
assert "intoComposer" in send
assert "sendStraightToChat" not in send
def test_writing_into_the_composer_repaints_its_mirror():
"""The mirror is only repainted on `input` and after a swap, and this is
neither -- so without the call the highlighting sits over the previous text
until the next keystroke, shifted by however much was just inserted."""
into = js_function(SOURCE, "intoComposer")
assert "paintComposer" in into
assert "autosize" in into
def test_the_automatic_path_never_steals_focus():
"""It fires while somebody is typing in the terminal, and yanking the caret
out of a shell mid-command is the sort of thing that gets a feature switched
off for good."""
into = js_function(SOURCE, "intoComposer")
assert js_says(into, "!quiet", "input.focus()")
# The `command` frame is the automatic path, and it is the quiet one.
assert "capture(true)" in _after(CODE, 'payload.t === "command"')
def test_a_screen_scrape_says_that_is_what_it_is():
"""Without shell integration this is the last forty rows as they appeared,
wraps and all. Presenting that as a command and its output would put a
precise-looking block in front of a model that is not one."""
capture = js_function(SOURCE, "capture")
assert "scraped()" in capture
assert "say(" in capture[capture.index("scraped()") :]
def test_auto_is_disabled_rather_than_degraded():
"""Forty arbitrary lines attached to every message is worse than nothing
attached at all, so the control is switched off where it cannot work -- and
switched back off if it was already on when the markers went away."""
apply_ = js_function(SOURCE, "applyIntegration")
assert 'integration === "live"' in apply_
assert js_says(apply_, "auto.disabled", "!usable")
assert js_says(apply_, "!usable", 'autoMode !== "off"', 'setAuto("off")')
def test_auto_is_not_remembered_between_page_loads():
"""A switch that forwards every command you run to a model is not something
to inherit from last week."""
assert "localStorage" not in CODE
assert "sessionStorage" not in CODE
# --- Where it is loaded -------------------------------------------------------
def test_xterm_is_loaded_only_where_a_shell_can_be_opened():
"""xterm is 280KB, more than everything else vendored here together. Hard
rule 1 allows it exactly once, and only on a chat that can open a terminal
-- an unconditional script tag would put it on every page in the
application."""
page = (TEMPLATES / "chat/index.html").read_text(encoding="utf-8")
guarded = ""
at = 0
while (at := page.find("{% if terminal_enabled %}", at)) != -1:
guarded += page[at : page.index("{% endif %}", at)]
at += 1
for asset in ("vendor/xterm.js", "vendor/xterm-addon-fit.js", "js/terminal.js"):
assert page.count(asset) == 1, f"{asset} is referenced more than once"
assert asset in guarded, f"{asset} is loaded outside the terminal_enabled guard"
def test_a_handler_only_touches_the_connection_it_belongs_to():
"""`close()` queues its event rather than firing it, and both reconnect
paths close the old socket and immediately open a new one. So the old
socket's `close` arrives *after* the new one is assigned -- and a handler
reading the module-level `socket` reached past its own connection into the
live one and set it to null.
Output kept arriving, because `onmessage` is bound to the object. But
`send`, `term.onData` and the paste handler all gate on
`socket && readyState === OPEN`, so every keystroke was dropped and no
resize was ever sent again -- on a panel that looked perfectly healthy. And
`closedOnPurpose` had been cleared for the new connection, so it announced
"Disconnected" about a shell that had just reconnected successfully.
"""
code = js_code(script("terminal.js"))
assert "var opened = new WebSocket(url)" in code
# Every handler compares itself against the current connection first.
for handler in ("onmessage", "onclose", "onerror"):
body = code.split(f"opened.{handler} = function", 1)[1]
guard = body[: body.index("{", body.index(")")) + 200]
assert "socket !== opened" in guard, handler
assert "socket.onclose = function" not in code, "a handler bound to the shared variable"