"""Unit: the loading-feedback contract in the static frontend (phase 06). The JS behavior itself is E2E-covered (tests/e2e/test_loading_feedback.py); here we pin the exported constants and state-machine markers that the story depends on, so a silent regression in app.js/styles.css is caught without a browser. """ 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") def _html() -> str: return (FRONTEND / "index.html").read_text(encoding="utf-8") def test_turn_timeout_constant_exported_at_120s() -> None: """The 120s client-side guard (PLAN §7.4) must be an *exported* constant — testable, and the single value the E2E timeout story keys off.""" js = _js() match = re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*120_?000\s*;", js) assert match, "app.js must export `const TURN_TIMEOUT_MS = 120000`" def test_state_machine_has_all_four_states() -> None: """idle → thinking → streaming → done | error → idle (PLAN §7.4). `done` is not a UI state: it settles into `idle` in the turn handler's finally block, so the state machine itself has exactly four states. """ js = _js() for state in ("idle", "thinking", "streaming", "error"): assert re.search(rf'{state}:\s*"{state}"', js), f"state {state!r} missing" assert "function setUiState" in js, "setUiState must exist as the single entry point" assert "export function setUiState" in js, "setUiState must be exported (testable)" def test_typing_indicator_contract_strings() -> None: """The indicator's accessible label and the 10s elapsed-seconds hint are the strings screen readers (and the E2E) rely on. Phase 39: the label is BUILT at call time from window.BOR_BRAND (the classic brand.js layer) — `TYPING_LABEL()` — with the default name as the only fallback, so the default path renders the same bytes as before.""" js = _js() assert "TYPING_LABEL = () =>" in js, "the label must be built at call time (phase 39)" assert "`${brand()} is thinking`" in js, "the label must resolve the name via brand()" assert 'window.BOR_BRAND || "Brain of Reese"' in js, ( "the no-config fallback must keep the default name" ) assert "still thinking" in js, "elapsed-seconds hint must update the aria label" assert "secs < 10" in js, "the hint must only appear after 10s of silence" assert "role=\"status\"" in js, "typing bubble must be role=status" def test_error_banner_has_actionable_hint() -> None: """Every error path (SSE error event, non-2xx, 120s timeout) must surface the same actionable retry hint (story AC4).""" js = _js() assert "check the LLM is reachable" in js assert '"role", "alert"' in js, "error banner must be role=alert" # the banner must be the red error variant assert "is-error" in js def test_reduced_motion_calm_not_removed() -> None: """prefers-reduced-motion: feedback is calmed, never removed (story AC7). Dots go static; the spinner only slows down.""" css = _css() blocks = re.findall( r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css ) assert blocks, "styles.css must contain prefers-reduced-motion rules" assert any(".typing span" in b and "animation: none" in b for b in blocks), ( "typing dots must have a static fallback under reduced motion" ) assert any(".spinner" in b and "animation-duration" in b for b in blocks), ( "spinner must slow down (not vanish) under reduced motion" ) def test_busy_button_style_tokens() -> None: """Phase 48 (revised contract, owner-locked 2026-08-29): in flight the button is the enabled Stop control — "Stop" label, .is-stop class (rose treatment, 6.3:1 with the #fff label), spinner hidden; idle/error keep the brand Send button (dark ink on brand 5.2:1). The spinner element stays in the markup + CSS (16px dark arc — the reduced-motion pin below) but the state machine never shows it: the Stop label + treatment carry the in-flight state.""" css = _css() js = _js() assert ".send-btn.is-stop" in css assert "#be123c" in css, "the stop background: rose-700 (6.3:1 with #fff)" assert ".send-btn.is-stop:hover" in css, "the darker hover step" assert re.search(r"\.spinner \{[^}]*width: 16px", css) assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js assert 'sendBtn.classList.toggle("is-stop", inFlight)' in js assert "sendBtn.disabled = false" in js, "the button is a control, never disabled" # ---------- thinking display (phase 17) ---------- def test_thinking_event_is_a_first_class_turn_branch() -> None: """Phase 17: `thinking` SSE frames stream live into the collapsible Thinking block — the typing dots make way, the 120s pre-token guard clears (the stream is alive), and the text renders through the escape-first markdown renderer (XSS-safe). While open, the stream is pinned to the bottom of the block.""" js = _js() thinking_idx = js.find('ev.type === "thinking"') delta_idx = js.find('ev.type === "delta"') assert -1 < thinking_idx < delta_idx, "the turn handler must branch on thinking frames" branch = js[thinking_idx:delta_idx] assert "thinkingAcc += ev.text" in branch assert "sawThinking = true" in branch assert "clearTurnTimeout()" in branch, "first thinking frame clears the 120s guard" assert "removeTyping()" in branch, "the live block replaces the typing dots" assert "ensureThinkingBlock(wrap)" in branch assert "renderMarkdown(thinkingAcc)" in branch, "escape-first renderer (XSS-safe)" assert "textEl.scrollTop = textEl.scrollHeight" in branch, "bottom-pinned while open" def test_thinking_block_helpers_are_idempotent() -> None: """ensureThinkingBlock returns the existing `.thinking` details or creates it OPEN above the .bubble; closeThinkingBlock is a no-op without a block and never reopens one once the answer started.""" js = _js() fn = js.find("function ensureThinkingBlock") assert fn != -1, "ensureThinkingBlock must exist (near addTyping/removeTyping)" body = js[fn : js.find("\n}\n", fn)] assert "block.open = true" in body, "created open — the stream is the show" assert "insertBefore" in body assert 'querySelector(".bubble")' in body, "the block sits ABOVE the bubble" fn2 = js.find("function closeThinkingBlock") assert fn2 != -1, "closeThinkingBlock must exist" body2 = js[fn2 : js.find("\n}\n", fn2)] assert "block.open = false" in body2 def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None: """The first answer delta transitions thinking → streaming (even when thinking created the wrap first) and auto-collapses the block — idempotent, and it never reopens once the answer started.""" js = _js() delta_idx = js.find('ev.type === "delta"') done_idx = js.find('ev.type === "done"') assert -1 < delta_idx < done_idx branch = js[delta_idx:done_idx] assert "uiState === UI_STATE.thinking" in branch assert "setUiState(UI_STATE.streaming)" in branch assert "closeThinkingBlock(wrap)" in branch def test_done_branch_sets_sawdone_and_closes_block() -> None: """On `done` the turn marks itself complete (sawDone — the stream-drop guard keys off it) and settles the thinking block closed.""" js = _js() done_idx = js.find('ev.type === "done"') error_idx = js.find('ev.type === "error"') assert -1 < done_idx < error_idx branch = js[done_idx:error_idx] assert "sawDone = true" in branch assert "closeThinkingBlock(wrap)" in branch def test_stream_drop_guard_reports_severed_stream() -> None: """A stream that delivered frames but no `done` event ends in the error state (never a silent idle with a half bubble); the zero-frame case falls through to the existing empty-answer fallback. The guard runs after readSSE, before that fallback.""" js = _js() assert "let sawDone = false" in js assert re.search(r"if \(!sawDone && !aborted && \(acc \|\| thinkingAcc\)\)", js), ( "sawDone stream-drop guard missing after readSSE" ) assert "The stream ended before my answer finished" in js sse_idx = js.find("await readSSE(res,") guard_idx = js.find("!sawDone && !aborted") fallback_idx = js.find("!aborted && !wrap") assert -1 < sse_idx < guard_idx < fallback_idx, ( "guard must sit between readSSE and the zero-frame fallback" ) def test_thinking_chevron_stills_under_reduced_motion() -> None: """Phase 17: the only motion in the thinking block (the summary chevron rotation) is disabled under prefers-reduced-motion.""" css = _css() blocks = re.findall( r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css ) assert any( "details.thinking summary::before" in b and "transition: none" in b for b in blocks ), "chevron transition must still under reduced motion" # ---------- stop generation (phase 48, task 02) ---------- def test_in_flight_button_is_the_stop_control() -> None: """Phase 48 (owner-locked 2026-08-29): in flight the button is the enabled Stop control — "Stop" label, .is-stop class, spinner hidden (the label + the rose treatment carry the state); idle/error keep the Send label with the class removed. The state machine otherwise stays unchanged (same four states, same single entry point).""" js = _js() assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js assert 'sendBtn.classList.toggle("is-stop", inFlight)' in js assert 'sendBtn.querySelector(".spinner").hidden = true' in js, ( "the spinner never shows — the Stop label carries the state" ) assert "sendBtn.disabled = false" in js, "enabled in every state" def test_abort_plumbing_owns_the_fetch() -> None: """The in-flight fetch is owned by an AbortController created at turn start (module scope, cleared in the finally), passed to the fetch as its signal; the 120s guard aborts the same controller as its backstop — with `aborted = true` FIRST, so the catch never reads the guard's abort as a user stop (one owner, same outcome).""" js = _js() assert "let turnAbort = null" in js, "module-scope abort owner" assert "turnAbort = new AbortController()" in js, "fresh controller per turn" assert "signal: turnAbort.signal" in js, "the fetch carries the signal" guard_start = js.find("armTurnTimeout(() => {") guard = js[guard_start : js.find("});", guard_start)] assert "aborted = true" in guard and "turnAbort?.abort()" in guard, ( "the guard keeps cancelStream + the abort as backstops" ) assert guard.index("aborted = true") < guard.index("turnAbort?.abort()"), ( "aborted must be set before the guard's abort" ) handle = js.find("async function handleSend") finally_idx = js.find("} finally {", handle) finally_block = js[finally_idx : finally_idx + 700] assert "turnAbort = null" in finally_block, "the abort owner is spent after the turn" def test_stop_turn_is_the_user_abort() -> None: """stopTurn: a no-op unless a turn is in flight (thinking/streaming); it marks the turn as user-stopped and aborts. The in-flight guard at the top of handleSend routes a click / Enter-to-submit to it BEFORE the !text guard — the enabled in-flight button can never start a second turn.""" js = _js() fn = js.find("function stopTurn") assert fn != -1, "stopTurn must exist" body = js[fn : js.find("\n}\n", fn)] assert "uiState !== UI_STATE.thinking" in body assert "uiState !== UI_STATE.streaming" in body assert "stoppedByUser = true" in body assert "turnAbort?.abort()" in body handle = js.find("async function handleSend") guard_idx = js.find("stopTurn();", handle) text_idx = js.find("const text = input.value.trim()", handle) assert handle < guard_idx < text_idx, ( "the in-flight guard (→ stopTurn) must precede the !text guard" ) def test_stop_branch_keeps_partial_and_persists_stopped() -> None: """The stop path in handleSend's catch: no error state, no error banner; when answer text streamed the partial is kept on screen (thinking block closed, Tune + Stopped note appended — admin parity with the restore path) and persisted with the owner-locked optional `stopped: true` marker (+ optional thinking/tools); a pre-token stop persists nothing brain-side (phase-20 convention). The "Answer stopped." live-region confirmation is set in the finally, AFTER the single settle, so setUiState(idle) can't overwrite it.""" js = _js() handle = js.find("async function handleSend") catch_idx = js.find("} catch (err) {", handle) stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch_idx) finally_idx = js.find("} finally {", catch_idx) assert catch_idx < stop_idx < finally_idx, "the stop branch must live in the catch" # The stop branch only (the error `else` follows it and is not pinned here). branch = js[stop_idx : js.find("} else {", stop_idx)] assert "setUiState(UI_STATE.error" not in branch, "no error state on the stop path" assert "showErrorBanner" not in branch, "no error banner on the stop path" assert "if (wrap && acc && !persistedOnLeave)" in branch, ( "only a partial WITH answer text is persisted (phase-20 dedupe)" ) assert "closeThinkingBlock(wrap)" in branch assert "appendTuneButton(wrap)" in branch, "admin parity with the restore path" assert "appendStoppedNote(wrap)" in branch assert "stopped: true" in branch, "the owner-locked optional marker" assert "thinking: thinkingAcc || undefined" in branch assert "tools: toolAcc.length ? toolAcc : undefined" in branch # The confirmation rides the single settle in the finally. finally_block = js[finally_idx : finally_idx + 900] assert 'if (stoppedByUser) sendStatus.textContent = "Answer stopped."' in finally_block def test_stopped_note_helper_and_restore_path() -> None: """appendStoppedNote: reuses/creates the .msg-meta row exactly like appendTuneButton (role=list → the span joins as a listitem), one .stopped-note per bubble — the aria-hidden stop-glyph SVG + the "Stopped" text (the accessible meaning). The restore path renders it for records with `m.stopped` (phase-14 optional-field convention — no version bump).""" js = _js() fn = js.find("function appendStoppedNote") assert fn != -1, "appendStoppedNote must exist" body = js[fn : js.find("\n}\n", fn)] assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists" assert 'className = "msg-meta"' in body, "creates it otherwise" assert 'className = "stopped-note"' in body assert 'querySelector(".stopped-note")' in body, "one note per bubble" assert 'note.setAttribute("role", "listitem")' in body assert 'aria-hidden="true"' in body, "the glyph is decoration" assert '"Stopped"' in body, "the text carries the accessible meaning" # Restore path: the same helper, gated on the stored marker. rfn = js.find("function renderStoredMessage") rbody = js[rfn : js.find("\n}\n", rfn)] assert "if (m.stopped) appendStoppedNote(wrap)" in rbody def test_tool_branch_no_longer_writes_the_button_label() -> None: """Phase 48 (owner-locked): the `tool` frame no longer relabels the button — it stays "Stop" for the whole in-flight turn; the calling-tool status lives in #send-status + the typing indicator's aria-label only (exactly where the phase-37 state used to write).""" js = _js() tool_idx = js.find('ev.type === "tool"') delta_idx = js.find('ev.type === "delta"') branch = js[tool_idx:delta_idx] assert "sendLabel" not in branch, "the button keeps its Stop label" assert "sendStatus.textContent = toolStatus" in branch assert 'setAttribute("aria-label", toolStatus)' in branch def test_composer_form_is_novalidate() -> None: """Phase 48 (latent-defect fix, 2026-08-29): the composer form must skip browser constraint validation. The input is cleared after every send, so a `required` textarea would fail validation on the Stop click/Enter — the `submit` event never fires and handleSend's in-flight guard never runs, so the Stop control is dead. The `!text` guard in app.js is the real empty-input check (same precedent as the tuning form's noValidate).""" html = _html() composer = html.find('id="composer"') assert composer != -1, "index.html must contain #composer" form_tag = html[html.rfind("", composer) + 1] assert "novalidate" in form_tag.lower(), ( "the composer form must carry novalidate — a `required` input that " "is empty in flight would silently block the Stop submit" ) textarea = html[composer: html.find("", composer)] assert not re.search(r"\brequired\b", textarea), ( "the composer textarea must not carry `required` (see novalidate)" )