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