Files
LLeMbas/tests/test_composer_js.py
T
Homer 0514568df0 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

288 lines
13 KiB
Python

"""The composer's typing affordances, 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.
That is not a nicety, and this file is the reason it is written down: `refresh()`
wrote to `list` *before* anything had built it, so the first `/` or `@` ever
typed threw on a null and took the whole handler with it. The menu never
appeared, in any browser, for the entire life of the feature, and `node --check`
parses that file happily.
What the suite can pin are the properties that failure depended on, and the ones
beside it whose absence is equally silent: a listener registered in a phase the
event does not reach, a repaint bound to the htmx event that fires too early,
and a mirror sized differently from the box it sits behind. Every one of those
looks exactly like working.
"""
from __future__ import annotations
import re
from pathlib import Path
import lembas
from .conftest import js_code, js_function, js_says, script
SOURCE = script("composer.js")
CODE = js_code(SOURCE)
CSS = (Path(lembas.__file__).parent / "web/static/css/chat.css").read_text(encoding="utf-8")
def _rule(css: str, opening: str) -> str:
"""One declaration block, by the text that opens it.
The match has to *start* a rule: `.composer__mirror .tok-command {` appears
twice, once as the tail of a grouped selector, and reading the group's block
when the single one was asked for is a test that answers a question nobody
put.
"""
at = -1
while True:
at = css.index(opening, at + 1)
if not css[:at].rstrip().endswith(","):
return css[at : css.index("}", at)]
# --- The menu, and the null it used to throw on -------------------------------
def test_the_menu_is_built_before_anything_writes_to_it():
"""The bug this module exists for. `refresh()` sets `list.innerHTML` and
appends to it, and `list` is null until `build()` has run -- so with the
build left to `show()`, which is called *after* those writes, the first
keystroke threw and the menu never opened for anybody.
Asserted as the order inside `refresh`, with comments stripped: the file
explains this at length in prose, and prose satisfies a substring test.
"""
refresh = js_function(SOURCE, "refresh")
assert "build()" in refresh, "refresh no longer builds the menu"
assert refresh.index("build()") < refresh.index("list"), (
"something reads or writes `list` before the menu exists"
)
def test_building_it_twice_costs_nothing():
"""Which is what lets `refresh` and `show` both call it unconditionally.
Without the guard, every keystroke would insert another menu."""
assert js_function(SOURCE, "build").index("if (menu) return;") < 40
def test_the_late_mention_reply_checks_the_menu_is_still_there():
"""A mention list is a round trip late, and the caret may have moved off the
token -- or the menu may have been dismissed, which drops `list`. Writing
into it anyway is the same null a second time, on a slower path."""
load = js_function(SOURCE, "loadMentions")
assert load.index("!list") < load.index("list.innerHTML")
# And the kind, or a reply for `@` lands in a menu now showing commands.
assert 'kind !== "@"' in load
def test_choosing_with_the_mouse_holds_the_caret():
"""`mousedown` is cancelled inside the menu. Without it the composer loses
focus before the click lands, and the caret position the choice is about to
be written at is already gone -- so the token is replaced at the wrong
offset, or not at all."""
build = js_function(SOURCE, "build")
assert '"mousedown"' in build
assert build.index('"mousedown"') < build.index('"click"')
assert "preventDefault" in build
# --- Listeners, and the phases they must be in --------------------------------
def test_the_key_listener_is_registered_in_the_capture_phase():
"""app.js already has a document-level Enter handler that submits the form,
and listeners on the same element in the same phase fire in registration
order -- app.js loads first. In the bubble phase this one would never get to
say "that Enter chose a menu item, it did not send the message", so choosing
from the menu would send the half-typed line as well.
The property rather than the markup, for the reason `tests/test_ui_js.py`
gives: a trigger bound where the event does not go is silent.
"""
found = re.search(r'"keydown",[\s\S]*?\n\s*(true|false)\n\s*\);', SOURCE)
assert found, "the keydown listener is gone"
assert found.group(1) == "true"
def test_the_scroll_listener_is_too():
"""`scroll` does not bubble. Registered without the third argument this is
never called, and the mirror stops following the textarea the moment the box
grows past `data-max-height` -- the rectangles then sit over the wrong
words, which is the one failure the mirror exists to avoid."""
found = re.search(r'"scroll",[\s\S]*?\n\s*(true|false)\n\s*\);', SOURCE)
assert found, "the scroll listener is gone"
assert found.group(1) == "true"
def test_the_mirror_repaints_after_the_request_as_well_as_after_the_swap():
"""htmx fires afterSwap and afterSettle *before* afterRequest, and the
composer empties itself from `hx-on::after-request` -- so every repaint
bound to the first two ran while the box still held the message, and the
highlighting sat over an empty field until the next keystroke.
Both late events go through the same deferred helper, and that is the whole
assertion: `paint` bound directly to `reset` reads the value in the same
turn the event fires, which is *before* a form's fields are actually
cleared, so it paints the text that is about to vanish.
"""
after = re.search(r'addEventListener\("htmx:afterRequest",\s*(\w+)\)', CODE)
reset = re.search(r'addEventListener\("reset",\s*(\w+)\)', CODE)
assert after and reset, "the late repaints are gone"
assert after.group(1) == reset.group(1), "the two late events repaint differently"
assert after.group(1) != "paint", "a repaint in the same turn paints the old value"
deferred = js_function(SOURCE, after.group(1))
assert "requestAnimationFrame" in deferred
assert "paint" in deferred
# The early ones stay: a swap that brings a new composer in has to paint it.
for event in ("htmx:afterSwap", "htmx:afterSettle"):
assert re.search(rf'addEventListener\("{event}",\s*paint\)', CODE), event
# --- What may become markup ---------------------------------------------------
def test_nothing_typed_into_the_box_can_become_markup():
"""The mirror holds the one string a person controls exactly, character for
character. Hard rule 6 covers model output; this is the same rule pointed
the other way, and it is the only place in the application where somebody's
raw keystrokes are re-rendered as they type.
`list.innerHTML` in the mention path is deliberately not covered: that is a
server-rendered fragment, escaped where it was built.
"""
paint = js_function(SOURCE, "paint")
assert "innerHTML" not in paint
assert "replaceChildren" in paint
assert "createTextNode" in paint
assert "textContent" in js_function(SOURCE, "token")
# --- The mirror and the box are one shape -------------------------------------
METRICS = (
"font-family",
"font-size",
"line-height",
"letter-spacing",
"padding",
"white-space",
"overflow-wrap",
"word-break",
"width",
)
def test_the_mirror_and_the_box_are_sized_by_one_rule():
"""A textarea cannot style its own contents, so the mirror sits behind it
holding the same text with every character transparent. Every property that
decides where a character lands has to be declared once, for both: a single
difference and the rectangles slide off the words they are marking, and the
drift grows with the length of the line rather than showing up as an obvious
misalignment at the start.
So the shared rule is asserted to carry all of them, and neither of the
per-element rules to redeclare any -- which is the version of this that
breaks silently, because a second declaration looks like a local tweak.
"""
shared = _rule(CSS, ".composer__input,\n.composer__mirror {")
for property_ in METRICS:
assert f"{property_}:" in shared, f"{property_} is not shared"
for selector in ("\n.composer__input {", "\n.composer__mirror {"):
own = _rule(CSS, selector)
for property_ in METRICS:
assert f"{property_}:" not in own, f"{selector.strip()} redeclares {property_}"
def test_a_token_restates_what_it_would_otherwise_inherit():
"""`color: transparent` on the mirror is an inherited value, and any colour
a span declares itself beats it. The transcript's rule used to do exactly
that from across the file, painting the token in accent-coloured monospace
at 0.95em on top of the textarea's own text: doubled, and shifted from that
point on because the metrics differ."""
tokens = _rule(CSS, ".composer__mirror .tok-mention,\n.composer__mirror .tok-command {")
assert "color: transparent" in tokens
assert "font: inherit" in tokens
def test_a_token_bleeds_with_a_shadow_rather_than_with_padding():
"""The rectangle has to be a little wider than the text it sits behind.
Padding and a negative margin do that by *moving the glyph*, which is the
one thing nothing in the mirror is allowed to do -- a shadow cannot."""
tokens = _rule(CSS, ".composer__mirror .tok-mention,\n.composer__mirror .tok-command {")
assert "padding: 0" in tokens
assert "margin: 0" in tokens
for selector in (".composer__mirror .tok-mention {", ".composer__mirror .tok-command {"):
rule = _rule(CSS, selector)
assert "box-shadow" in rule, f"{selector} does not bleed"
assert "padding" not in rule, f"{selector} moves the glyph"
assert "margin" not in rule, f"{selector} moves the glyph"
def test_every_token_style_names_where_it_applies():
"""A rule written for one context matches every context. `.tok-mention`
unscoped was written for the transcript and also hit the mirror's spans,
which is the bug above. Both places qualify their selectors now, and an
unqualified one added later would do it again with nothing to notice."""
for line in CSS.splitlines():
if ".tok-" not in line:
continue
selector = line.strip()
assert selector.startswith((".msg ", ".composer__mirror ")), selector
# --- Slash commands, from the composer's side ---------------------------------
def test_an_unrecognised_slash_is_never_swallowed():
"""Eating somebody's message because it began with a slash is a far worse
failure than an unknown command. Enter only cancels the send when the value
resolves to a command -- so the `preventDefault` has to sit *inside* that
test, not beside it."""
window = CODE[CODE.index('event.key === "Enter" && !event.shiftKey') :][:600]
assert window.index("commandIn(") < window.index("preventDefault")
assert "if (command) {" in window
def test_choosing_a_command_clears_the_box_before_running_it():
"""A command is the whole message, never part of one. `/help` left sitting
in the box after running means the next Enter runs it again -- which is what
driving this under a DOM stub found, along with Tab not completing."""
choose = js_function(SOURCE, "choose")
assert js_says(choose, "input.value", '""', "runCommand")
def test_tab_completes_and_enter_runs():
"""The difference matters for a command that takes an argument: Tab writes
`/mode ` and leaves the caret after it, Enter runs what is there. Both are
one call, so the key is what decides -- and `choose` has to branch on it
before it reaches the run."""
assert js_says(CODE, "choose(all[active]", 'event.key === "Tab"')
choose = js_function(SOURCE, "choose")
assert choose.index("if (complete)") < choose.index("runCommand")
def test_the_at_menu_works_on_a_page_without_commands_js():
"""`@` and `/` share one menu but not one dependency. commands.js is absent
from some pages and may fail to parse on any of them, and either would
otherwise take the mention picker down with it."""
fallback = js_function(SOURCE, "commands")
assert "window.lembasCommands ||" in fallback
for method in ("list:", "find:", "run:"):
assert method in fallback, f"the fallback has no {method}"
# Read at call time rather than captured once, or a page whose commands.js
# loads second gets the fallback forever.
for helper in ("commandList", "commandIn", "runCommand"):
assert "commands()." in js_function(SOURCE, helper)
def test_the_directory_is_read_from_the_field_that_is_submitted():
"""The chip beside the connection shows the directory's own name so it stops
eating the composer's one row; the whole path lives in the hidden field. A
mention picker reading the label would ask the server about `worker` rather
than about `/srv/projects/…/worker` -- shortening a label must never shorten
a value, on this path either."""
context = js_function(SOURCE, "context")
assert "[data-dir-value]" in context
assert "data-dir-label" not in context