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:
@@ -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"
|
||||
Reference in New Issue
Block a user