Files
brain-of-reese/tests/unit/test_no_empty_answer_bubble.py
ducoterra b34b8ca672
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 182_verbatim_show_text
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**

- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
2026-10-15 08:33:12 -04:00

637 lines
29 KiB
Python

"""Unit: the empty answer bubble never paints (phase 166, task 01,
``TODO.md`` L4 — "There's an empty box where the LLM response will go
that shows up before the LLM responds. It looks ugly." (owner's
``image.png``: Thinking / Tool calls (2) / an expanded Thinking run,
then a bordered, padded, EMPTY rounded box)).
Why the box exists: the first ``thinking``/``tool`` frame of a turn
calls ``addMessage("brain", "")`` (app.js ~4063 / ~4164 / ~4269) — the
EMPTY bubble is created ON PURPOSE as the DOM anchor for
``ensureReasoningWrap`` (``body.insertBefore(cont,
body.querySelector(".bubble"))`` — the phase-158/161 per-run timeline
sits ABOVE the answer bubble). ``.bubble`` carries its own padding +
border + background + box-shadow, so an empty one paints the reported
~40px box (and ``.msg-body``'s flex gap adds the separation).
The fix is CSS-only (LOCKED A1 — owner 2026-07-10: "hide until the
first token", NOT "restructure the anchor"):
``.msg .bubble:empty { display: none; }`` in the Messages block.
``:empty`` matches only a child-less element — exactly
``innerHTML === ""`` — and the first delta writes content, so the box
appears AT the first token; ``display: none`` also drops the flex item
so the gap collapses. Because ``renderMarkdown("")`` returns ``""``
(node-executed below), the restore path and the shared page (same
``.msg/.msg-body/.bubble`` structure, same stylesheet — locked A3) are
covered by the same rule. The typing indicator is never ``:empty``
(three dot ``<span>``s), so the loading state never disappears (locked
A2 — nothing replaces the box).
Browser behavior is E2E-covered by ``tests/e2e/test_no_empty_box.py``
(task 03); here we pin the source-level contract in the house
"read-the-source" style (test_thinking_scroll.py /
test_markdown_tables.py): the exact declaration exists ONCE in the
Messages block, ``.bubble`` keeps its paint (the rule HIDES, never
restyles), the typing-dot rules are untouched, the JS anchors survive
unchanged (D2 — this is the pin that stops a future "cleanup" from
moving the fix into JS and breaking the timeline), and the empty-input
renderer behavior is executed under node.
AUDIT NOTE (phase 166 task 02 — the §7.4 "never stale" inventory; the
record the next agent reads instead of re-deriving it). With the empty
bubble suppressed, EVERY mid-turn state still has a visible
affordance, in order:
• idle / pre-thinking → ``#typing-indicator``: the ``role="status"``
bubble labelled ``TYPING_LABEL()`` with its three dot ``<span>``s
(never ``:empty``, so the rule can never hide it);
• long tool call / frameless silence → the ``thinkingClock`` still
paints the ``Ns`` ``.typing-elapsed`` suffix INTO that same bubble
(a child ⇒ the bubble is never ``:empty`` ⇒ the clock keeps
painting);
• thinking / tool → the ``details.reasoning`` container
(``ensureReasoningWrap`` sets ``cont.open = true`` — visible while
live) with its live status span: "Thinking…" / "Calling <name>…"
/ the settle "N steps";
• first token → the answer bubble APPEARS: the first ``delta``
writes ``innerHTML``, so ``:empty`` stops matching on the SAME
frame the box returns — and ``#messages`` (the ONE conversation
``aria-live="polite"`` region) announces as content lands, at
which point the bubble is visible again anyway;
• terminal → the settle copy: answer / deflection (.maybe-try chips)
/ stopped (.stopped-note) / failed (.failed-note) + the
``#send-status`` / ``#send-label`` composer signals.
Nothing measures the hidden box (``display: none`` yields zero
rects), so no scroll or pin can shift: ``scrollReveal`` receives the
``.msg`` WRAP (never the bubble), the phase-161 window pins take
``.reasoning-body``, and ``.bubble`` is only ever read for CONTENT
(``textContent`` / ``innerHTML`` / ``querySelector`` / ``classList`` /
``prepend`` / ``setAttribute``) or used as an ``insertBefore`` anchor.
The ``.msg-meta`` rows (chips, figures, actions, notes) append to
``.msg-body`` as SIBLINGS of the bubble (phase 130), so hiding an
empty one never hides them. The restore
(``addMessage("brain", renderMarkdown(m.text), true)``) and the shared
page (``shared.js`` builds the identical ``.msg/.msg-body/.bubble``
structure on the same stylesheet) land in the same ``.msg `` scope —
no ``shared.js`` change needed (locked A3).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
APP_JS = ASSETS / "app.js"
MARKDOWN_JS = ASSETS / "markdown.js"
SHARED_JS = ASSETS / "shared.js"
STYLES_CSS = ASSETS / "styles.css"
INDEX_HTML = FRONTEND / "index.html"
RULE = ".msg .bubble:empty { display: none; }"
HAVE_NODE = shutil.which("node") is not None
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _css() -> str:
return _text(STYLES_CSS)
def _js() -> str:
return _text(APP_JS)
def _bubble_rule(css: str) -> str:
"""Body of the bare `.bubble { ... }` rule (the answer bubble)."""
rule = re.search(r"\n\.bubble \{([\s\S]*?)\n\}", css)
assert rule, "styles.css must style .bubble"
return rule.group(1)
def _function_body(js: str, signature: str) -> str:
"""Source of one top-level function, from its signature to the
first column-0 `}` (the house indentation makes that the function
end). Used by the task-02 audit pins."""
start = js.find(signature)
assert start != -1, f"missing function: {signature}"
end = js.find("\n}", start)
assert end != -1, f"unterminated function: {signature}"
return js[start:end]
def _strip_comments(src: str) -> str:
"""Crude-but-sufficient comment stripper for the audit greps: block
comments then line comments (the house files carry no `//` inside
string literals — no-CDN pins keep URLs out)."""
src = re.sub(r"/\*[\s\S]*?\*/", "", src)
return re.sub(r"//[^\n]*", "", src)
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
# ---------- styles.css: the one rule ----------
def test_suppression_rule_exists_exactly_once() -> None:
"""The exact declaration ``.msg .bubble:empty { display: none; }``
exists in styles.css EXACTLY once — one rule, no duplicates, no
near-miss variant (a `.bubble:empty` without the `.msg` scope would
leak past the Messages section)."""
css = _css()
assert css.count(RULE) == 1, "the suppression rule must exist exactly once"
assert css.count(".bubble:empty") == 1, "no unscoped or extra :empty variant"
def test_rule_sits_in_the_messages_block() -> None:
"""Provenance ordering (the test_date_editor.py css.find idiom):
the rule lives in the Messages block, directly after the
``.bubble p`` content rule and before ``.bubble pre`` — i.e. after
``.bubble {`` — so the fix reads next to the bubble it hides."""
css = _css()
assert css.find("---------- Messages ----------") < css.find(RULE), (
"the rule must sit in the Messages block"
)
assert css.find(".bubble {") < css.find(".bubble p {") < css.find(RULE), (
"the rule must follow the .bubble rules it suppresses"
)
assert css.find(RULE) < css.find(".bubble pre {"), (
"the rule must stay between .bubble p and .bubble pre"
)
def test_rule_is_scoped_to_msg_bubbles() -> None:
"""The selector is `.msg .bubble:empty` — the chat/shared-page
message bubbles ONLY. The document viewer's .doc-* blocks, the
composer, and every other :empty-carrying element never match."""
css = _css()
assert RULE in css
# Every `.bubble:empty` occurrence carries the `.msg ` prefix — no
# bare variant escaping the Messages scope — and there is no
# broader `.msg:empty` (which would hide the whole message wrap).
for m in re.finditer(r"\.bubble:empty", css):
assert css[: m.start()].endswith(".msg "), (
"the :empty rule must stay scoped to .msg .bubble"
)
assert ".msg:empty" not in css
def test_bubble_keeps_its_paint_the_rule_hides_never_restyles() -> None:
"""The bare `.bubble` rule still declares padding, border-radius,
background, border and box-shadow (LOCKED A1: the fix HIDES the
empty box, it never restyles a non-empty bubble) — and no
`display: none` crept into the base rule itself."""
body = _bubble_rule(_css())
for decl in ("padding", "border-radius", "background", "border", "box-shadow"):
assert decl in body, f".bubble must keep its {decl} (hide, not restyle)"
assert "display: none" not in body, "the base .bubble rule must stay visible"
def test_suppression_carries_phase_166_provenance() -> None:
"""The provenance comment directly above the rule names the defect
(TODO L4 + the owner's image.png), the JS anchors it preserves
(addMessage("brain","") / ensureReasoningWrap), and why the typing
indicator is unaffected."""
css = _css()
idx = css.find(RULE)
comment = css[:idx]
start = comment.rindex("/*")
note = css[start:idx]
assert "Phase 166" in note
assert "TODO L4" in note, "the comment must name the TODO line"
assert "image.png" in note, "the comment must name the owner's screenshot"
assert 'addMessage("brain","")' in note or 'addMessage("brain", "")' in note, (
"the comment must name the empty-bubble creation it hides"
)
assert "ensureReasoningWrap" in note, "the comment must name the preserved anchor"
assert "typing" in note, "the comment must explain why the indicator is safe"
def test_typing_indicator_rules_are_untouched() -> None:
"""The loading affordance stays painted: the `.typing` bubble rule
(inline-flex + gap) and the three-dot animation rules
(`.typing span`, the two animation-delays, @keyframes typing) are
all still present — the indicator bubble carries the dot children,
so the new :empty rule can never hide it (locked A2)."""
css = _css()
assert ".typing { display: inline-flex" in css
assert "animation: typing 1.2s infinite ease-in-out;" in css
assert ".typing span:nth-child(2) { animation-delay: 0.15s; }" in css
assert ".typing span:nth-child(3) { animation-delay: 0.3s; }" in css
assert "@keyframes typing {" in css
# ---------- app.js: the CSS-only contract (D2) ----------
def test_addtyping_bubble_is_never_empty() -> None:
"""`addTyping()` still builds the bubble with its three dot
`<span>` children — `:empty` matches only a CHILD-LESS element, so
the typing indicator (role=status) is provably never hidden by the
new rule: the loading state cannot disappear."""
js = _js()
start = js.find("function addTyping() {")
assert start != -1, "app.js must keep addTyping()"
body = js[start : js.find("\n}", start)]
assert '<div class="bubble typing"' in body
assert "<span></span><span></span><span></span>" in body, (
"the three dot spans are the non-:empty guarantee"
)
def test_the_three_empty_bubble_anchors_survive() -> None:
"""D2 pin: app.js is NOT modified by this phase — all three
`addMessage("brain", "")` call sites (thinking ~4063 / tool ~4164 /
first delta ~4269) still create the empty wrap. A future "cleanup"
that moves the fix into JS would re-own the phase-158/161 anchors
for zero user-visible gain over the CSS rule — this pin stops it."""
js = _js()
assert js.count('addMessage("brain", "")') == 3, (
"the thinking/tool/first-delta empty-bubble anchors must all stay"
)
def test_ensure_reasoning_wrap_still_anchors_before_the_bubble() -> None:
"""`ensureReasoningWrap` still does `body.insertBefore(cont,
body.querySelector(".bubble"))` — the hidden bubble remains the
DOM anchor the per-run timeline inserts BEFORE (the CSS rule hides
the box; it never removes the element)."""
js = _js()
start = js.find("function ensureReasoningWrap(wrap) {")
assert start != -1, "app.js must keep ensureReasoningWrap"
body = js[start : js.find("\n}", start)]
assert 'body.insertBefore(cont, body.querySelector(".bubble"))' in body
# ---------- markdown.js: WHY one rule covers restore + shared (A3) ----------
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_rendermarkdown_empty_inputs_yield_empty_string() -> None:
"""Executed under node (the phase-44 driver idiom):
`renderMarkdown("")` and `renderMarkdown(" ")` BOTH return `""`
— so a persisted answer with empty/whitespace text lands as a truly
child-less `<div class="bubble"></div>` in the restore AND the
shared page, and the same `:empty` rule covers them (locked A3)."""
js = _text(MARKDOWN_JS)
driver = (
js
+ "\nconst out = { empty: renderMarkdown(\"\"), blank: renderMarkdown(\" \") };\n"
+ "console.log(JSON.stringify(out));"
)
results = json.loads(_run_node(driver))
assert results["empty"] == "", f'renderMarkdown("") must be "", got {results["empty"]!r}'
assert results["blank"] == "", f'renderMarkdown(" ") must be "", got {results["blank"]!r}'
# ---------- no-CDN (AGENTS.md rule 6) ----------
def test_no_cdn_introduced() -> None:
"""The touched assets stay CDN-free (AGENTS.md rule 6): no remote
url()/@import in styles.css, no remote <script src> in app.js."""
css = _css()
assert "url(http" not in css and "@import url(" not in css, (
"no CDN (AGENTS.md rule 6)"
)
js = _js()
assert "src=\"http" not in js and "src='http" not in js
# ---------- §7.4 feedback inventory (task 02) ----------
#
# Each test names the mid-turn state whose affordance it guarantees.
# If one of these pins fails, a turn has become feedback-less — the
# exact regression phase 166 must not ship (PLAN §7.4, AGENTS.md 10).
def test_pre_thinking_state_typing_indicator_is_a_labeled_status() -> None:
"""STATE: idle → pre-thinking. `addTyping()` still builds the
#typing-indicator bubble as a `role="status"` live element with the
accessible label `aria-label="${TYPING_LABEL()}"` and the three
`<span></span>` dots — the dots are both the visible pulse and the
non-`:empty` guarantee (task 01's pin), so this affordance survives
the suppression rule untouched (locked A2)."""
body = _function_body(_js(), "function addTyping() {")
assert 'wrap.id = "typing-indicator"' in body
assert 'role="status"' in body
assert 'aria-label="${TYPING_LABEL()}"' in body
assert body.count("<span></span>") == 3, (
"the three dot spans are the visible pre-thinking affordance"
)
def test_pre_thinking_state_setuistate_arms_typing_and_clock() -> None:
"""STATE: idle → thinking. `setUiState` still arms BOTH composer-
side and chat-side feedback on the thinking transition —
`addTyping()` + `startThinkingClock()` — and the persistent turn
loader stays owned by the single `turnLoader.hidden = !inFlight`
line (phase 109 D16). The suppression rule lives nowhere near
this state machine."""
body = _function_body(_js(), 'export function setUiState(state, errorDetail = "", opts = {}) {')
assert "addTyping();" in body
assert "startThinkingClock();" in body
assert "turnLoader.hidden = !inFlight;" in body
assert 'sendBtn.classList.toggle("is-stop", inFlight);' in body
def test_long_tool_call_state_clock_paints_the_elapsed_suffix() -> None:
"""STATE: long tool call (frameless silence ≥ 10s). `thinkingClock`
still writes the `Ns` suffix into `#typing-indicator .bubble` as a
`.typing-elapsed` child (`el.textContent = secs + "s"`) and keeps
the aria channel byte-identical (`is still thinking (${secs}s)`).
NOTE: the suffix APPENDS a child to the typing bubble — that bubble
is therefore NEVER `:empty` (dots from creation, the suffix on top)
— so the clock keeps painting for the whole turn; the suppression
rule cannot starve this affordance."""
body = _function_body(_js(), "function startThinkingClock() {")
assert 'document.querySelector("#typing-indicator .bubble")' in body
assert 'el.className = "typing-elapsed"' in body
assert 'el.textContent = secs + "s"' in body
assert "is still thinking (${secs}s)" in body
assert "bubble.appendChild(el)" in body
def test_thinking_tool_state_reasoning_status_literals_stay_wired() -> None:
"""STATE: thinking / tool / the settle. The D6 status literals are
still wired at their call sites: `setReasoningStatus("Thinking…")`
on every thinking frame (1 site), `setReasoningStatus(
`Calling ${name}…`)` on every tool frame (1 site), and the settle
`${segRuns.length} steps` at its THREE settle sites (done, the
user stop, the failed-turn funnel in finalizeFailedTurn). The live
status span is the text signal of the thinking/tool state while
the answer box is still hidden."""
js = _js()
assert js.count('setReasoningStatus("Thinking…")') == 1
assert js.count("setReasoningStatus(`Calling ${name}…`)") == 1
assert js.count("setReasoningStatus(`${segRuns.length} steps`)") == 3, (
"done + stop + failed-settle must all keep the N-steps literal"
)
def test_thinking_tool_state_reasoning_container_is_visible_while_live() -> None:
"""STATE: thinking / tool. `ensureReasoningWrap` still sets
`cont.open = true` — the per-run timeline container is OPEN (i.e.
actually VISIBLE) from the first thinking/tool frame until
settleReasoning folds it (phase 161 D1/D2). With the answer box
hidden, this container is the turn's main brain-side surface; a
silently-closed container would break §7.4 even though the box
fix itself is CSS-only."""
body = _function_body(_js(), "function ensureReasoningWrap(wrap) {")
assert "cont.open = true;" in body
def test_composer_side_state_copy_is_untouched() -> None:
"""STATE: every non-idle state, composer side. The `#send-label`
("Send" ⇄ "Stop") and the `#send-status` live region
(`SEND_STATUS[state]` — "… is thinking" / "… is answering" /
"The last question failed — try again") are still written by
`setUiState` exactly as before — the phase-166 change lives in one
CSS rule and never touches the composer-side signal (locked A2:
nothing replaces the box because the existing states already
carry the feedback)."""
js = _js()
start = js.find("const SEND_STATUS = Object.freeze({")
assert start != -1
block = js[start : js.find("});", start)]
assert "is thinking" in block and "is answering" in block
assert "The last question failed — try again" in block
body = _function_body(js, 'export function setUiState(state, errorDetail = "", opts = {}) {')
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send";' in body
assert 'sendStatus.textContent = SEND_STATUS[state]?.() ?? "";' in body
def test_messages_stays_the_one_conversation_live_region() -> None:
"""STATE: first token → the announcement channel. `#messages`
keeps `aria-live="polite"` (frontend/index.html — the ONE
conversation live region; the composer-side #send-status and the
per-feature announcers are separate surfaces). An aria-live
announcement fires when the answer CONTENT lands in the region —
and by construction that is the first delta, the same frame the
bubble gains children and becomes visible again: the suppression
rule can therefore never hide content from the a11y channel."""
html = _text(INDEX_HTML)
m = re.search(r'<section class="messages"[^>]*>', html)
assert m, "index.html must keep the #messages section"
tag = m.group(0)
assert 'id="messages"' in tag
assert 'aria-live="polite"' in tag, (
"the conversation region stays the live region (PLAN §7.4)"
)
# ---------- nothing measures the hidden box (task 02) ----------
MEASURE_PROPS = (
"getBoundingClientRect",
"offsetHeight",
"offsetWidth",
"clientHeight",
"clientWidth",
"scrollHeight",
"scrollTop",
"scrollLeft",
"offsetTop",
"offsetLeft",
)
def test_scroll_reveal_receives_the_msg_wrap_not_the_bubble() -> None:
"""FINDING: `addMessage` calls `if (scroll) scrollReveal(wrap);`
with the `.msg` WRAP — never the `.bubble` — and `scrollReveal`
measures NOTHING of its argument (`void wrap;` then a document-
bottom `window.scrollTo`). `display: none` on the bubble therefore
cannot shift a scroll: the only page scroll paths are the two
user-intent call sites, and none of them touches the hidden box."""
add = _function_body(_js(), "function addMessage(who, html, scroll = false, image = null) {")
assert "if (scroll) scrollReveal(wrap);" in add
assert 'wrap.className = `msg ${who}`' in add, "wrap is the .msg element"
assert 'querySelector(".bubble")' not in add.split("scrollReveal")[-1], (
"the scroll call must not hand the bubble to scrollReveal"
)
reveal = _function_body(_js(), "function scrollReveal(wrap, behavior = SCROLL) {")
assert "void wrap;" in reveal
assert "window.scrollTo({ top: document.documentElement.scrollHeight, behavior });" in reveal
assert ".bubble" not in reveal
def test_reasoning_window_pins_never_take_a_bubble() -> None:
"""FINDING: the phase-161 window pins operate on `.reasoning-body`
handles (`reasoningWrapEl?.querySelector(".reasoning-body")`, both
frame sites) — `isReasoningPinned` / `scrollReasoningToBottom`
take the container window `el`, NEVER a `.bubble`. The pinned-tail
follow is therefore blind to the bubble's box: hiding it cannot
move a pin."""
js = _js()
pinned = _function_body(js, "function isReasoningPinned(el) {")
scroll = _function_body(js, "function scrollReasoningToBottom(el) {")
assert ".bubble" not in pinned and ".bubble" not in scroll
assert js.count('reasoningWrapEl?.querySelector(".reasoning-body")') == 2, (
"the thinking and tool frame sites capture the CONTAINER window"
)
assert "isReasoningPinned(rbody)" in js and "isReasoningPinned(tbody)" in js
assert "scrollReasoningToBottom(rbody)" in js and "scrollReasoningToBottom(tbody)" in js
def test_no_line_measures_a_bubble_box() -> None:
"""FINDING (grep audit): NO line of app.js or shared.js pairs a
geometry property (getBoundingClientRect / offsetHeight /
clientHeight / scroll* / offset*) with `bubble`. The only geometry
reads that exist today are pinned to their OWN surfaces: the
thinking-text window (`textEl`), the container window (`el`, the
two pins above), the composer textarea autosize (`input`), and the
document-bottom scroll (documentElement) — so `display: none` on
an empty `.bubble` cannot shift a scroll or a pin ANYWHERE."""
for path in (APP_JS, SHARED_JS):
for line in _text(path).splitlines():
if any(prop in line for prop in MEASURE_PROPS):
assert "bubble" not in line.lower(), (
f"{path.name} measures a box on a bubble line: {line.strip()}"
)
def test_bubble_selector_is_only_lookups_and_anchors() -> None:
"""FINDING: every code line (comments stripped) that references the
`.bubble` SELECTOR is a `querySelector` lookup or an
`insertBefore` anchor — the two operations `display: none` does
not disturb (the element stays in the DOM; locked A1/D2). No
measurement ever hangs off the selector itself."""
for path in (APP_JS, SHARED_JS):
code = _strip_comments(_text(path))
selector_lines = [
ln.strip()
for ln in code.splitlines()
if re.search(r"\.bubble(?=[\"'])", ln)
]
assert selector_lines, f"{path.name} must still reference .bubble"
for ln in selector_lines:
assert "querySelector" in ln, (
f"the .bubble selector may only appear in lookups/anchors: {ln}"
)
def test_bubble_variable_is_only_touched_for_content() -> None:
"""FINDING: the `bubble` variable (whatever a lookup bound) is
only ever touched for CONTENT — textContent, innerHTML,
classList, querySelector, prepend, appendChild, setAttribute,
replaceChildren — in BOTH app.js and shared.js. A geometry
property on the handle would read ZERO once the box is hidden,
so the allowlist failing here means some future code silently
depends on the painted box — exactly what this audit forbids.
(replaceChildren joined phase 170: the editor form IS the
bubble's content — a content swap, not a geometry read.)"""
content_props = {
"textContent",
"innerHTML",
"classList",
"querySelector",
"prepend",
"appendChild",
"setAttribute",
"replaceChildren",
}
for path in (APP_JS, SHARED_JS):
code = _strip_comments(_text(path))
touched = set(re.findall(r"(?<![.\w])bubble\??\.\s*([A-Za-z_$][\w$]*)", code))
assert touched <= content_props, (
f"{path.name} touches non-content props on a bubble: "
f"{sorted(touched - content_props)}"
)
# The documented content reads exist (a vacuous allowlist is not
# the contract — these are the sites the audit enumerated):
js = _strip_comments(_js())
assert 'bubble.textContent' in js
assert 'bubble.querySelector(".msg-image")' in js
assert 'bubble.classList.remove("is-editing")' in js
assert 'wrap.querySelector(".bubble").innerHTML = renderMarkdown' in _js()
# ---------- .msg-meta rows stay OUTSIDE the bubble (task 02) ----------
def test_meta_rows_append_to_msg_body_never_into_the_bubble() -> None:
"""Phase-130 pin: the chips row, the images row, the answer figure
and the actions row all append to `.msg-body` — SIBLINGS of
`.bubble` — and none of the appender bodies even mentions
`.bubble`. Hiding an empty bubble therefore never hides a chip,
an action, or a figure; a future refactor that moves them INTO the
bubble would fail this pin (and the owner's screenshot defect
would return multiplied)."""
js = _js()
actions = _function_body(js, "function getActionsRow(wrap) {")
assert 'row.className = "msg-meta msg-actions";' in actions
assert "body.appendChild(row);" in actions
assert ".bubble" not in actions
sources = _function_body(js, "function appendSources(wrap, sources) {")
assert 'const body = wrap.querySelector(".msg-body");' in sources
assert 'meta.className = "msg-meta";' in sources
assert "body.appendChild(meta);" in sources, "chips row → .msg-body"
assert "body.appendChild(images);" in sources, "images row → .msg-body"
assert ".bubble" not in sources
figure = _function_body(js, "function appendAnswerImage(wrap, ref) {")
assert 'const body = wrap.querySelector(".msg-body");' in figure
assert "body.appendChild(fig);" in figure, "answer figure → .msg-body"
assert ".bubble" not in figure
for name, cls in (("appendStoppedNote(wrap)", "stopped-note"),
("appendFailedNote(wrap, detail)", "failed-note")):
note = _function_body(js, f"function {name} {{")
assert "const meta = getActionsRow(wrap);" in note, (
f".{cls} joins the actions row (itself a .msg-body child)"
)
assert f'note.className = "{cls}";' in note
assert "meta.appendChild(note);" in note
assert ".bubble" not in note
# ---------- the restore and shared corners (task 02) ----------
def test_restore_renders_brain_text_through_the_pinned_call_shape() -> None:
"""CORNER: the restore. The phase-14 restore renders every brain
record through `addMessage("brain", renderMarkdown(m.text, { verbatim:
true }), true)` (the option is phase 182's verbatim opt-in — it changes
which FENCES render, never whether the text does) — with `m.text === ""`
the node-pinned `renderMarkdown("") === ""`
` (pinned above) makes the bubble CHILD-LESS ⇒ `:empty` ⇒ hidden:
an empty persisted answer paints no box, through the SAME rule
(locked A3) — no restore-path branch was added."""
js = _js()
assert (
js.count('addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)')
== 1
)
def test_shared_page_builds_the_identical_msg_structure_no_change_needed() -> None:
"""CORNER: the shared page. `addSharedMessage` builds the same
`wrap.className = `msg ${who}`` wrap and the identical
`<div class="bubble">${html}</div>` structure (shared.js ~113) as
`addMessage` — so the rule's `.msg ` scope prefix matches BOTH
pages (shared.html loads the same styles.css). shared.js carries
NO `:empty` rule of its own: one stylesheet, one rule, no
shared.js change needed (locked A3)."""
shared = _text(SHARED_JS)
body = _function_body(shared, "function addSharedMessage(who, html) {")
assert "wrap.className = `msg ${who}`" in body
assert '<div class="bubble">${html}</div>' in body
# The chat page builds the byte-identical bubble fragment:
assert '<div class="bubble">${html}</div>' in _js()
# The rule's scope prefix is `.msg ` — the class both pages put
# on the wrap — and shared.js adds no second suppression rule.
assert RULE.startswith(".msg .bubble:empty")
assert ":empty" not in shared
# shared.html is on the same stylesheet (one rule covers it).
shared_html = _text(FRONTEND / "shared.html")
assert 'href="/assets/styles.css"' in shared_html