The phase-87 ".typing-elapsed" dot-geometry reset (specificity 0,1,0) lost every shared declaration to the ".typing span" dot rule (0,1,1): the hint rendered as an 8x8px bouncing dot and the "Ns" text wrapped one character per line below the bubble (overflow-wrap: anywhere on .bubble). Phase 87's e2e checked text values only, so the squish shipped unseen. - retarget the reset at ".typing span.typing-elapsed" (0,2,1) so it actually wins; center the dots while the hint line is taller - unit: pin the reset's selector context (specificity regression guard) - e2e: layout pin on the live hint — no dot animation, not an 8px box, horizontal single-line bounding box - before/after verification screenshots in .agents/reports/87_big_read_progress/ Verified: unit 41 passed, phase-87 e2e 4 passed (isolated), ruff + pyright clean.
345 lines
16 KiB
Python
345 lines
16 KiB
Python
"""Unit: the phase-87 big-read progress contract (source-level pins).
|
||
|
||
Phase 87 (TODO.md L5 — "Need indication that prompt processing is
|
||
happening during a big read, it can look frozen."): during any
|
||
frameless gap of an in-flight turn the UI must show, to sighted AND
|
||
screen-reader users, that processing is ongoing — a ticking
|
||
elapsed-seconds suffix on the latest tool line (after 5s of silence)
|
||
and a VISIBLE elapsed hint on the typing indicator (the existing 10s
|
||
pre-token clock promoted from aria-only to visible text). Both settle
|
||
the instant content resumes; persisted/restored turns never show
|
||
timers (A6 — the arming call lives only in the live frame branches).
|
||
|
||
Locked decisions (phase overview): frontend-only (A4 — the server, the
|
||
SSE event set, and the 120s guard are byte-identical), named
|
||
thresholds owned by the state machine (A5 — ``TOOL_LINE_ELAPSED_AFTER_MS``),
|
||
and the exact tool-line template literals stay byte-identical (the
|
||
suffix is a separate element the clock appends; ``appendToolLine``
|
||
renders exactly as before, which is also what makes A6 fall out for
|
||
free on restore).
|
||
|
||
This module reads ``frontend/assets/app.js`` + ``styles.css`` as text
|
||
(no browser — house pattern, cf. test_frontend_feedback.py); the live
|
||
behavior is E2E-gated by ``tests/e2e/test_big_read_progress.py``
|
||
(task 03). Task 01 pins the visible typing-indicator hint below;
|
||
task 02 extends the module with the tool-line clock pins.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
APP_JS = FRONTEND / "assets" / "app.js"
|
||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||
|
||
|
||
def _js() -> str:
|
||
return APP_JS.read_text(encoding="utf-8")
|
||
|
||
|
||
def _css() -> str:
|
||
return STYLES_CSS.read_text(encoding="utf-8")
|
||
|
||
|
||
# ---------- task 01: the visible typing-indicator elapsed hint ----------
|
||
|
||
|
||
def test_typing_elapsed_hint_is_built_with_createelement_and_textcontent() -> None:
|
||
"""Phase 87 task 01: after 10s of pre-token silence the typing
|
||
bubble gains a visible ``.typing-elapsed`` "Ns" hint — ensured
|
||
(created at most once, appended as the bubble's LAST child, after
|
||
the three dot spans) and written with ``textContent`` only. No
|
||
``innerHTML`` anywhere on the typing bubble: the ``addTyping``
|
||
template writes ``wrap.innerHTML`` (the static skeleton), and the
|
||
clock must never rewrite turn data into the DOM as HTML."""
|
||
js = _js()
|
||
assert ".typing-elapsed" in js, "the hint class must exist in app.js"
|
||
# The ensure pattern: find-or-create, then append to the bubble.
|
||
assert 'bubble.querySelector(".typing-elapsed")' in js
|
||
assert 'el.className = "typing-elapsed"' in js
|
||
assert "document.createElement(\"span\")" in js
|
||
assert "bubble.appendChild(el)" in js, "the hint is the bubble's last child"
|
||
# textContent-only write (the bubble is role="status" — announced).
|
||
assert 'el.textContent = secs + "s"' in js, (
|
||
"the hint text is the plain ticking \"Ns\" (A5 — no added wording)"
|
||
)
|
||
# No innerHTML on the typing bubble — the skeleton template
|
||
# (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML
|
||
# write, and it must not gain a second one.
|
||
assert js.count("bubble.innerHTML") == 0, (
|
||
"the clock must never assign innerHTML to the typing bubble"
|
||
)
|
||
|
||
|
||
def test_typing_elapsed_hint_lives_in_start_thinking_clock() -> None:
|
||
"""The hint belongs to the EXISTING 10s pre-token clock (A5 — the
|
||
typing hint reuses its ``secs < 10`` gate): the ensure/write logic
|
||
sits inside ``startThinkingClock``'s 1s interval, right after the
|
||
aria-label update. ``addTyping``/``removeTyping`` stay untouched —
|
||
the hint lives and dies with the indicator the state machine owns."""
|
||
js = _js()
|
||
fn = js.find("function startThinkingClock")
|
||
assert fn != -1, "startThinkingClock must exist"
|
||
body = js[fn : js.find("\n}\n", fn)]
|
||
assert ".typing-elapsed" in body, "the hint is ensured inside the clock"
|
||
assert 'el.textContent = secs + "s"' in body
|
||
# The 10s gate is kept (aria + visible share it).
|
||
assert "secs < 10" in body
|
||
# addTyping/removeTyping never touch the hint (state-machine-owned).
|
||
for name in ("function addTyping", "function removeTyping"):
|
||
f = js.find(name)
|
||
assert f != -1, f"{name} must exist"
|
||
b = js[f : js.find("\n}\n", f)]
|
||
assert ".typing-elapsed" not in b, f"{name} must stay untouched"
|
||
|
||
|
||
def test_typing_aria_label_contract_stays_byte_identical() -> None:
|
||
"""The pinned aria channel survives the promotion to visible text —
|
||
both users, same clock (mirrors the phase-39 brand pin so this
|
||
module is self-documenting): the exact template literal, built at
|
||
call time via brand(), after the 10s gate."""
|
||
js = _js()
|
||
assert "`${brand()} is still thinking (${secs}s)`" in js
|
||
assert "secs < 10" in js, "the hint must only appear after 10s of silence"
|
||
assert "role=\"status\"" in js, "the typing bubble stays role=status"
|
||
|
||
|
||
def test_typing_elapsed_css_rule_is_the_aa_pairing() -> None:
|
||
"""The ``.typing-elapsed`` rule: small mono in ink-soft on the
|
||
bubble's --surface (the documented ≥4.5:1 AA pairing), the same
|
||
language as every status line. Plain text — ``animation: none`` +
|
||
no background, and the dot-geometry reset (the span is a sibling of
|
||
the dots inside the .typing bubble, so without the reset it would
|
||
render as an 8px bouncing dot, not a hint).
|
||
|
||
The reset's SELECTOR is pinned too (phase-87 regression, 2026-09):
|
||
a bare ``.typing-elapsed`` (specificity 0,1,0) loses every shared
|
||
declaration to the dot rule ``.typing span`` (0,1,1) — the hint
|
||
then rendered as an 8×8px bouncing dot with the "Ns" text wrapping
|
||
one character per line below the bubble. The reset must carry the
|
||
span context (``.typing span.typing-elapsed``, 0,2,1) to win."""
|
||
css = _css()
|
||
assert ".typing span.typing-elapsed {" in css, (
|
||
"the dot-geometry reset must target the span in .typing context "
|
||
"(bare .typing-elapsed loses the specificity war to .typing span)"
|
||
)
|
||
block = re.search(r"\.typing span\.typing-elapsed \{([\s\S]*?)\n\}", css)
|
||
assert block, "styles.css must style .typing span.typing-elapsed"
|
||
body = block.group(1)
|
||
for prop in (
|
||
"font-family: var(--mono)",
|
||
"font-size: 0.75rem",
|
||
"color: var(--ink-soft)",
|
||
"margin-left: 0.5rem",
|
||
# the dot-geometry reset (see the rule's comment):
|
||
"width: auto",
|
||
"background: none",
|
||
"opacity: 1",
|
||
"animation: none",
|
||
):
|
||
assert prop in body, f".typing-elapsed must keep {prop}"
|
||
# Reduced motion: the hint is plain text, not motion — it keeps full
|
||
# opacity (the AA pairing) while the dots calm to 0.7.
|
||
blocks = re.findall(
|
||
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
|
||
)
|
||
assert any(".typing span.typing-elapsed" in b and "opacity: 1" in b for b in blocks), (
|
||
"the hint must keep full contrast under reduced motion"
|
||
)
|
||
# The existing reduced-motion dot fallback is untouched (phase 06 pin).
|
||
assert any(".typing span" in b and "animation: none" in b for b in blocks)
|
||
|
||
|
||
# ---------- task 02: the per-tool-line elapsed clock ----------
|
||
|
||
|
||
def test_tool_line_elapsed_constant_is_named_and_five_seconds() -> None:
|
||
"""A5: the visible "processing" threshold is the NAMED module constant
|
||
``TOOL_LINE_ELAPSED_AFTER_MS = 5_000`` (below it a frameless gap reads
|
||
as normal latency; at/above it the latest line proves it is still
|
||
processing) — a pinned constant, not a magic number in the tick."""
|
||
js = _js()
|
||
assert re.search(r"TOOL_LINE_ELAPSED_AFTER_MS\s*=\s*5_?000", js), (
|
||
"the 5s threshold must be the named constant TOOL_LINE_ELAPSED_AFTER_MS"
|
||
)
|
||
|
||
|
||
def test_tool_line_clock_state_is_turn_scoped_module_state() -> None:
|
||
"""One clock per turn: the three state vars live at module scope next
|
||
to the existing ``thinkingClock`` / ``turnTimeout`` state (re-armed
|
||
per `tool` frame, so each line counts its OWN silence)."""
|
||
js = _js()
|
||
for decl in (
|
||
"let toolLineTimer = 0",
|
||
"let toolLineStart = 0",
|
||
"let toolLineWrap = null",
|
||
):
|
||
assert decl in js, f"{decl} must be module-scope state"
|
||
anchor = js.find("let turnTimeoutCb = null")
|
||
assert anchor != -1, "the existing timer state must exist"
|
||
assert (
|
||
js.find("let toolLineTimer = 0") - anchor < 1500
|
||
), "the clock state sits next to the existing timer state"
|
||
|
||
|
||
def test_arm_has_exactly_one_live_call_site() -> None:
|
||
"""A6 (live-only): ``armToolLineClock(`` appears EXACTLY twice in
|
||
app.js — the definition + the single live call site in the `tool`
|
||
frame branch (right after the line's append). The restore path
|
||
(phase 14, ``renderStoredMessage``) never arms: a restored line reads
|
||
exactly as it did pre-phase (the permanent record, no stale timer)."""
|
||
js = _js()
|
||
assert js.count("armToolLineClock(") == 2, (
|
||
"the arm must be the definition + exactly one live call site"
|
||
)
|
||
tool_idx = js.find('ev.type === "tool"')
|
||
delta_idx = js.find('ev.type === "delta"')
|
||
branch = js[tool_idx:delta_idx]
|
||
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
|
||
append = branch.find("appendToolLine(wrap, name, argument)")
|
||
arm = branch.find("armToolLineClock(wrap)")
|
||
assert -1 < append < arm, (
|
||
"the arm follows the line's append — the baseline resets per line"
|
||
)
|
||
restore_fn = js.find("function renderStoredMessage")
|
||
restore_end = js.find("function restoreConversation")
|
||
assert -1 < restore_fn < restore_end
|
||
assert "armToolLineClock" not in js[restore_fn:restore_end], (
|
||
"the restore path must never arm the clock (A6)"
|
||
)
|
||
|
||
|
||
def test_settle_covers_the_three_live_frame_branches() -> None:
|
||
"""Settle = REMOVE the suffix: the `thinking`, `retry`, and `delta`
|
||
branches each call ``settleToolLine()`` at the TOP of the branch
|
||
(a frame arrived — the line is no longer "processing"), and the
|
||
settle clears the interval + removes every ``.tool-elapsed`` from the
|
||
wrap. Definition + three branches → at least 4 occurrences."""
|
||
js = _js()
|
||
assert js.count("settleToolLine(") >= 4, (
|
||
"the definition + the three frame branches must settle"
|
||
)
|
||
for branch_open, branch_close, first_work in (
|
||
('ev.type === "thinking"', 'ev.type === "tool"', "thinkingAcc += ev.text"),
|
||
('ev.type === "retry"', 'ev.type === "delta"', "const attempt"),
|
||
('ev.type === "delta"', 'ev.type === "done"', "acc += ev.text"),
|
||
):
|
||
start = js.find(branch_open)
|
||
end = js.find(branch_close, start)
|
||
assert -1 < start < end, f"the {branch_open!r} branch must exist"
|
||
seg = js[start:end]
|
||
assert "settleToolLine();" in seg, f"the {branch_open!r} branch must settle"
|
||
assert seg.index("settleToolLine();") < seg.index(first_work), (
|
||
f"the settle sits at the TOP of the {branch_open!r} branch, before its "
|
||
"content work (the line is no longer 'processing' the instant the "
|
||
"frame arrives)"
|
||
)
|
||
fn = js.find("function settleToolLine")
|
||
assert fn != -1
|
||
body = js[fn : js.find("\n}\n", fn)]
|
||
assert "clearInterval(toolLineTimer)" in body, "the settle drops the interval"
|
||
assert 'querySelectorAll?.(".tool-elapsed")' in body and "el.remove()" in body, (
|
||
"the settle REMOVES every suffix (a frozen timestamp is noise)"
|
||
)
|
||
|
||
|
||
def test_stop_lives_in_setui_state_next_to_the_other_stops() -> None:
|
||
"""House invariant — "a stuck button is impossible" applied to a
|
||
stuck timer: ``stopToolLineClock()`` is called inside
|
||
``setUiState`` so EVERY transition stops/clears the clock. Documented
|
||
pin: the call sits in the function body AFTER the
|
||
``stopThinkingClock();`` line and BEFORE the first button write
|
||
(``sendBtn.disabled``) — stable under comment churn, and it forces
|
||
the call into the timer-clear block rather than a later branch.
|
||
``stopToolLineClock`` itself settles + forgets the wrap (no residue
|
||
across turns)."""
|
||
js = _js()
|
||
fn = js.find("export function setUiState")
|
||
assert fn != -1
|
||
body = js[fn : js.find("\n}\n", fn)]
|
||
assert "stopToolLineClock();" in body, "setUiState must stop the clock"
|
||
assert (
|
||
body.index("stopThinkingClock();")
|
||
< body.index("stopToolLineClock();")
|
||
< body.index("sendBtn.disabled")
|
||
), (
|
||
"the stop sits in setUiState's clear block (after stopThinkingClock, "
|
||
"before the button writes)"
|
||
)
|
||
sf = js.find("function stopToolLineClock")
|
||
assert sf != -1
|
||
sbody = js[sf : js.find("\n}\n", sf)]
|
||
assert "settleToolLine();" in sbody, "stop settles (clear interval + remove suffixes)"
|
||
assert "toolLineWrap = null" in sbody, "stop forgets the wrap — no residue"
|
||
|
||
|
||
def test_suffix_is_textcontent_only_on_the_latest_line() -> None:
|
||
"""The suffix is a parenthesized "(Ns)" status suffix (e.g. "📄
|
||
Reading src/app.py (12s)" — the typing hint stays bare "Ns") written
|
||
with ``textContent`` on a ``createElement`` span — never innerHTML —
|
||
appended as a SIBLING after the line's existing children (the pinned
|
||
template text + the <code> argument). It targets the LATEST line only
|
||
(`.tool-call:last-child` — older lines keep their permanent record),
|
||
and a missing container (New-Chat click mid-gap) makes the tick a
|
||
no-op (the null-safe chain is the guard)."""
|
||
js = _js()
|
||
fn = js.find("function armToolLineClock")
|
||
assert fn != -1, "armToolLineClock must exist"
|
||
body = js[fn : js.find("\n}\n", fn)]
|
||
assert 'querySelector?.(".tool-calls .tool-call:last-child")' in body, (
|
||
"the suffix targets the LATEST line only"
|
||
)
|
||
assert 'line.querySelector(".tool-elapsed")' in body, "find-or-create the suffix span"
|
||
assert 'el.className = "tool-elapsed"' in body
|
||
assert 'document.createElement("span")' in body, "the span is createElement'd"
|
||
assert "line.appendChild(el)" in body, (
|
||
"the suffix is a SIBLING appended AFTER the line's existing children"
|
||
)
|
||
assert "`(${secs}s)`" in body, ("the parenthesized '(Ns)' suffix, textContent-built")
|
||
assert "innerHTML" not in body, "no HTML write in the clock — textContent only"
|
||
assert "TOOL_LINE_ELAPSED_AFTER_MS" in body, "the tick gates on the named constant"
|
||
assert "toolLineWrap?.querySelector?" in body, (
|
||
"null-safe: a wrap reset mid-gap (New Chat) makes the tick a no-op"
|
||
)
|
||
|
||
|
||
def test_tool_line_template_literals_stay_byte_identical() -> None:
|
||
"""The exact ``line.textContent = "…"`` template literals survive —
|
||
mirrors test_frontend_tool_states.py so this module is
|
||
self-documenting: the suffix is a separate element the clock appends,
|
||
and a rewrite of the line text would break the emoji-guard strip set
|
||
(it strips precisely those) plus the phase-37 pins. The helper stays
|
||
clock-free — that is also what makes A6 fall out for free on restore."""
|
||
js = _js()
|
||
fn = js.find("function appendToolLine")
|
||
assert fn != -1, "appendToolLine must exist"
|
||
body = js[fn : js.find("\n}\n", fn)]
|
||
for lit in (
|
||
'line.textContent = "📄 Reading "',
|
||
'line.textContent = "🔎 Searching for "',
|
||
'line.textContent = "🔎 Listing documents in "',
|
||
'line.textContent = "🔎 Listing documents"',
|
||
):
|
||
assert lit in body, f"the pinned tool-line literal {lit!r} must stay byte-identical"
|
||
assert "tool-elapsed" not in body, "appendToolLine stays clock-free (A6)"
|
||
assert "armToolLineClock" not in body
|
||
|
||
|
||
def test_tool_elapsed_css_rule_is_the_aa_pairing() -> None:
|
||
"""The ``.tool-elapsed`` rule (next to the .tool-call rules): small
|
||
mono in ink-soft — the same AA pairing as the task-01 hint — with
|
||
``white-space: nowrap`` so "(12s)" never wraps in the row."""
|
||
css = _css()
|
||
block = re.search(r"\.tool-elapsed \{([\s\S]*?)\n\}", css)
|
||
assert block, "styles.css must style .tool-elapsed"
|
||
body = block.group(1)
|
||
for prop in (
|
||
"font-family: var(--mono)",
|
||
"font-size: 0.75rem",
|
||
"color: var(--ink-soft)",
|
||
"margin-left: 0.5rem",
|
||
"white-space: nowrap",
|
||
):
|
||
assert prop in body, f".tool-elapsed must keep {prop}"
|