All verification complete. Final report: **Phase 170 — final verification pass (all 3 tasks were in `complete/`):** **Verified implemented:** - Editor renders inside the bubble: `bubble.replaceChildren(form)`, `display:none` rule deleted, `form.style.minWidth` seeded with pre-edit width, `restoreBubble()` (markdown + image re-prepend) before `closeEditor()` on Cancel/Escape, `closeEditor()` reordered before submit re-render - Label: `editSaveBtn.textContent = "Send"` (app.js L1241) **Gates (all 100% green):** - `uv run pytest` → 4066 passed - `uv run pytest --cov=app --cov-report=term-missing` → 99% on `app/` (gate >90%) - `uv run ruff check . && uv run pyright` → clean (0 errors, 0 warnings) - `uv run pytest tests/e2e/test_edit_bubble_sizing.py -v --no-cov` (in isolation) → 6/6 passed - Regression set in isolation: `test_edit_and_resend.py` 7✓, `test_retry_answer.py` 4✓, `test_failed_turn_retry.py` 4✓, `test_chat_persistence.py` 4✓ **Completion criteria:** in-bubble editor + width pin ✅ · "Send" label + cancel restore + trim re-ask + "Question too long" driver case ✅ · suite/coverage/lint ✅ · E2E + regression isolation ✅ · phase dir moved to `complete/` ✅ (atomic commit is the harness's to make — changes left in the working tree per protocol) **Documentation:** README.md reviewed in full — edit section already aligned to phase 170 (in-bubble editor + "Send"), pages table matches actual routes, all 29 `.env.example` vars documented, setup commands accurate; AGENTS.md reviewed — all referenced files/commands exist. Both verified consistent — no changes needed. **Notable decisions:** none — verification pass only, no code changes required. **Next pending phase:** `171_admin_settings_layout`
632 lines
29 KiB
Python
632 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), true)`
|
|
— 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), 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
|