"""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"