Files
brain-of-reese/tests/unit/test_save_chat_ui.py
T
ducoterra 140a343b92
Build and Push Containers / build-and-push-app (push) Successful in 3m4s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 189_chat_save_long_turns
**Phase 189 final verification — all green**

- **Verified (all 4 tasks already implemented):** A1 caps in `app/schemas.py` — `sources`/`related`/`tools` 200, `suggestions` 100, `segments` 450; `text`/`thinking` 600 000, `error`/`image` 500, `answer_text` 50, `messages` 500, 8 M budget all UNCHANGED; rationale docstrings rewritten to `merge_cited_docs`/`agent_max_rounds`. A2 `shareCurrentChat` linked branch upserts (PUT, 404→create+re-link) before `POST /{id}/share`, aborts on failure (banner, no token); unlinked branch byte-identical. A3 `savePending` + `finally` re-run. A4 `saveFailedSticky` + one 5 s `setTimeout` retry on network/5xx only, 4xx never retries.
- `uv run pytest --cov=app --cov-report=term-missing` → **4609 passed, 1 warning, EXIT=0**, `TOTAL 99%`, `app/schemas.py 100%` (>90%).
- `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` → **4 passed, EXIT=0** (isolation).
- Regression green & **unedited** (`git status` clean): `test_chat_save_payload_limits.py` 2, `test_chat_history.py` 5, `test_share_chat.py` 4. Extra blast-radius sweep (share/save/persist/segments surface, all EXIT=0): `test_save_share_ux`, `test_stale_saved_chats`, `test_history_pagination`, `test_history_copy`, `test_bottom_chat_actions`, `test_reasoning_bubble`, `test_tool_budget_telemetry`, `test_verbatim_show_text`, `test_smoke`. No leaked `saved_chats` rows (`save-long-%` = 0).
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**.
- **Completion criteria:** caps ✓ · incident payload 22/33/51 → 201/200 anonymously + 201 tools/451 segments/101 suggestions → 422 naming the offender, row unchanged ✓ · share-saves-first + abort + unlinked byte-identical ✓ · overlap re-run + sticky note + one-shot retry ✓ · phase E2E in isolation + 3 regression suites unedited ✓ · full suite / coverage / lint / types ✓ · `git diff --stat` = `app/schemas.py`, `frontend/assets/app.js`, 3 test files, `README.md` + new E2E & phase files (no `app/api/chats.py`, no migration, no new dependency) ✓ · commit + `todo/`→`complete/` move left to the harness (rules forbid git index writes).
- **Docs:** full README review (all 2 258 lines) + machine cross-check — all 85 settings documented in the Configuration reference, all 34 `.env.example` vars documented, documented defaults match `app/config.py`. Fixed 2 inconsistencies: (1) the 117-char prose line task 04 left when reflowing the Saved-chats pager paragraph; (2) the phase-189 sentence implying the 8 M total bounds the widened arrays — now states precisely that the total counts `text`/`thinking`/verbatim-block text only, and that the arrays are bounded by fixed item COUNT (matches `_require_within_total_budget`). AGENTS.md verified consistent — it enumerates no save contract or list cap (rule 5's phase-166 in-flight-turn clause untouched), so **no AGENTS.md change** per the phase mandate.
- **Notable/deviation:** the wider caps raise the *theoretical* anonymous-POST worst case (arrays are outside the 8 M char budget); left as designed (A1 fixes every other bound), documented honestly in README — realistic payloads stay ~10 MB, so the existing `client_max_body_size ≥ 16 MB` guidance still holds. No live-infra changes were made (data-repair of the two stale shared rows stays owner action).
- **Next pending phase:** none — `189_chat_save_long_turns` is the last entry in `.agents/phases/todo/`.
2026-10-17 07:36:28 -04:00

1389 lines
72 KiB
Python

"""Unit: the save/share contract on the chat page (phase 50 → phase 55).
The browser behavior itself is E2E-gated by the story suites (phase 50
+ phase 55 task 06); like the other frontend-adjacent unit files, this
module pins the JS/CSS/HTML markers the save/load contract depends on,
so a silent regression is caught without a browser:
* phase 55 (A2): the Save pill is GONE — no ``#save-chat-btn`` in
index.html, no ``.save-chat-btn`` in styles.css, no ``saveBtn`` /
``saveCurrentChat`` symbol in app.js; the headless
``persistConversation()`` upsert (PUT when linked, POST when not, the
404→recreate fallback) is wired to the save points (the user send in
runTurn's ``!reask`` block, ``rememberBrainTurn`` — the pagehide
partial rides it, no direct call there) with the A2 quiet contract
(one-line status note on failure, NO error banner, silent success)
and the module-level ``persisting`` double-fire guard;
* the ``bor.chat.v1`` record carries ``chatId`` (the row link survives
reloads; a pre-55 record without the field reads as null — never
throws);
* the ``currentChatId`` lifecycle (set on create/open, hydrated from the
record on the local restore, cleared by New chat and by the 404-PUT
fallback);
* the boot-load precedence (a valid ``?chat=`` uuid + admin replaces the
local restore and mirrors it to localStorage; anonymous / invalid /
404 / network → the local restore);
* the phase-55 task-03 Share contract on ``#share-chat-btn``: static,
ALWAYS-VISIBLE markup — no ``hidden`` attribute, NO reveal step
(no ``shareBtn.hidden`` assignment anywhere in app.js), and NEUTRAL
error copy on a failed share (the write surface is public — no
sign-in wording);
* phase 55 task 04 (the share-success toast, owner-locked A4):
``showToast`` — the SINGLE aria-hidden ``.toast`` node (lazy-created
once, reused — no stacking; text via ``textContent``, never
``innerHTML``; the pending dismiss cleared + reflow forced so a
second toast re-runs the entry; ~4s auto-dismiss) is called from
BOTH ``shareCurrentChat`` success branches with their own texts and
NEVER from a failure branch (the error banner is the failure UI);
styles.css ``.toast`` — fixed top-right just under the sticky header
(z-index 1000, brand fill — --bg on --brand 5.2:1 AA, a small
max-width, hidden by default with ``pointer-events: none``), a ~200ms
slide-down + fade entry via ``.toast.is-visible``, and the
reduced-motion override (transform dropped for BOTH states, the
opacity fade kept);
* phase 189 task 02 (A2, the owner incident 2026-10-16): the LINKED
``shareCurrentChat`` branch UPSERTS the current conversation (the
``persistConversation`` PUT + the 404→recreate fallback + the 201
re-link) BEFORE minting the share token, and an upsert failure ABORTS
the share (the neutral banner, no token, no toast) — a Share can never
publish a row the conversation has outgrown. The UNLINKED branch
(``POST /api/chats`` with ``share: true``) is byte-identical;
* phase 189 task 03 (A3/A4, the same incident): the ``persisting``
double-fire guard no longer DROPS an overlapping save point — it sets
``savePending`` and the in-flight upsert's ``finally`` re-runs
``persistConversation()`` ONCE after it settles (``persisting = false``
first, the flag cleared before the re-run — never concurrent, never
stacking). A failed save's one-line note is STICKY (``saveFailedSticky``,
cleared by the next SUCCESS along with ``#send-status``), and a
TRANSIENT failure (the network ``catch`` arm or a 5xx) schedules ONE
retry after 5 s behind the one-shot ``retryScheduled`` guard — a 4xx
NEVER retries (a 422 is deterministic; task 01's cap realign is the
remedy). The A2 contract's SHAPE is unchanged: the same note text,
still no error banner, still silent on success.
* phase 55 task 05 (the action row, owner-locked A5): a single
``<div class="chat-actions">`` in index.html wraps BOTH pills as its
element children (DOM order New chat → Share), replacing the two
pills as direct children of ``.chat-shell`` — the row sits inside the
shell, above ``#messages``, with the kb-banner / stale-banner /
steering / announcer structure around it untouched; styles.css
``.chat-actions`` — base ``display: flex; flex-direction: row;
align-items: center; gap: 0.6rem`` (the row's cross-axis override of
the column's stretch: the pills keep their intrinsic widths, side by
side, left-aligned) and the ≤640px override
``flex-direction: column; align-items: stretch; gap: 0.5rem`` (full-
width stack, New chat above Share) with the existing ≤640px pill
rules (padding, icon/label handling, the ``.chat-shell`` label
overrides) left intact for the stacked pills.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
# Phase 182 (task 07): the ONE clipboard implementation moved to the shared
# verbatim panel client; the share path delegates to it, so the fallback's
# internals are pinned there (the owner-locked behavior is re-pinned, not
# dropped).
VERBATIM_JS = FRONTEND / "assets" / "verbatim.js"
# Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both
# views are folded into the shell (index.html); both files deleted.
INDEX_HTML = FRONTEND / "index.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
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 _index() -> str:
return INDEX_HTML.read_text(encoding="utf-8")
def _fn(js: str, name: str) -> str:
"""The source of a top-level ``function <name>(...)`` (to its close)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist in app.js"
return js[start : js.find("\n}\n", start) + 4]
# ---------- the Save button on the chat page ----------
def test_save_pill_is_gone_from_the_chat_page() -> None:
"""Phase 55 (A2): the Save control is RETIRED — there is no
#save-chat-btn anywhere in index.html (at no width), no
.save-chat-btn rule anywhere in styles.css (base, ≤900px squeeze,
≤640px overrides), and no saveBtn / saveCurrentChat symbol left in
app.js (the headless persistConversation() replaced the handler)."""
html = _index()
assert 'id="save-chat-btn"' not in html, "index.html must not carry #save-chat-btn"
assert "save-chat-label" not in html, "no Save label left in index.html"
css = _css()
assert "save-chat-btn" not in css, "styles.css must not style .save-chat-btn"
assert "save-chat-label" not in css, "styles.css must not style .save-chat-label"
# The ≤900px combined squeeze rule drops the Save pill (New chat +
# auth only).
tablet = re.search(r"@media \(max-width: 900px\) \{([\s\S]*?)\n\}", css)
assert tablet, "tablet media query missing"
assert ".new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }" in tablet.group(1), (
"the tablet squeeze rule is New chat + auth only"
)
js = _js()
assert "saveBtn" not in js, "no saveBtn symbol left in app.js"
assert "saveCurrentChat" not in js, "no saveCurrentChat symbol left in app.js"
assert 'querySelector("#save-chat-btn")' not in js, "the pill query is gone"
# ---------- currentChatId lifecycle ----------
def test_current_chat_id_module_scope_and_lifecycle() -> None:
"""currentChatId: module scope, string | null — set to the created
row's id on a fresh auto-save (201), set to the opened id on a
successful boot load, hydrated from the record on the local restore
(phase 55 — the link survives reloads), cleared by "New chat" AND by
the 404-PUT fallback (a stale link must never wedge the
conversation)."""
js = _js()
assert "let currentChatId = null" in js, "module-scope link, null = unlinked"
# Set on create: the 201 branch links to the created row's id.
save_body = _fn(js, "persistConversation")
assert "res.status === 201" in save_body
assert "currentChatId = String(created.id)" in save_body, (
"a fresh auto-save links to the created row's id"
)
# Set on open: the boot load links to the fetched id.
load_body = _fn(js, "restoreSavedChatFromUrl")
assert "currentChatId = chatId" in load_body
# Hydrated on the local restore: the record carries the link.
restore_body = _fn(js, "restoreConversation")
assert "currentChatId = record ? record.chatId : null" in restore_body, (
"the local restore hydrates the link from the record (phase 55)"
)
# Cleared by New chat.
new_body = _fn(js, "startNewChat")
assert "currentChatId = null" in new_body, "New chat unlinks"
# Cleared by the 404-PUT fallback (see the upsert test for the branch).
assert "res.status === 404" in save_body
assert save_body.count("currentChatId = null") >= 1
# ---------- the headless auto-save (phase 55, A2) ----------
def test_persist_conversation_upsert_semantics() -> None:
"""persistConversation (the headless replacement of the phase-50
Save handler): the EXACT upsert semantics, unchanged — linked →
PUT /api/chats/<id> with the messages payload (the SAME row updates
— no title in the body, so the row keeps its current one);
unlinked → POST /api/chats (the server auto-titles) and link to the
created id (201). The 404 from the PUT unlinks and retries as a
create — a stale link can never wedge the conversation. Empty
conversation → no-op (no request, no feedback line)."""
js = _js()
body = _fn(js, "persistConversation")
# No-op first: nothing to save → silent return before any fetch.
noop = body.find("if (!conversation.length) return;")
first_fetch = body.find("await fetch(")
assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch"
# The branch: PUT when linked, POST when not.
assert "if (currentChatId)" in body
assert '`/api/chats/${currentChatId}`' in body
assert 'method: "PUT"' in body
assert 'fetch("/api/chats"' in body
assert 'method: "POST"' in body
put_idx = body.find('method: "PUT"')
post_idx = body.find('method: "POST"')
assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback"
assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)"
# The 404→recreate fallback: unlink, then POST again.
notfound_idx = body.find("res.status === 404")
assert notfound_idx != -1, "the PUT 404 must be handled"
fallback = body[notfound_idx:post_idx]
assert "currentChatId = null" in fallback, "the stale link is dropped"
# The 201 branch links to the created row.
assert "res.status === 201" in body
assert "currentChatId = String(created.id)" in body
def test_persist_conversation_is_headless_and_quiet() -> None:
"""The A2 quiet contract: NO error banner anywhere in the helper
(the phase-50 banner lines are gone), NO success status text
("Conversation saved." is retired — success is silent; the History
page is the visible proof), and the failure feedback is the
one-line #send-status note — on BOTH failure paths (non-ok HTTP and
network) — with the "next save point retries" promise. The
module-level `persisting` flag is the double-fire guard (released
in the finally — never stuck).
Phase 189 (A3) turns the DROPPED call into a PENDING one: the guard
arm still returns without issuing a second request (the upsert stays
one-at-a-time), it only remembers the save point for the ``finally``
re-run pinned by test_persisting_overlap_sets_pending_and_reruns."""
js = _js()
body = _fn(js, "persistConversation")
assert "showErrorBanner" not in body, "A2: a failed auto-save never raises a banner"
assert "Conversation saved." not in body, "A2: success is silent (no status text)"
note = "Couldn't save automatically — will try on the next message."
assert body.count(note) == 2, "the one-line note covers non-ok AND network failure"
# The non-ok branch notes and returns (no banner, no 201 handling).
notok = body.find("if (!res.ok)")
first_note = body.find(note)
assert -1 < notok < first_note, "the non-ok branch lands on the one-line note"
# The network path (catch) notes too.
catch_idx = body.find("} catch {")
assert catch_idx != -1 and first_note < body.rfind(note) < body.rfind("finally"), (
"the catch branch carries the second note"
)
# The module-level double-fire guard, released on EVERY outcome.
assert re.search(r"^let persisting = false", js, re.M), (
"the persisting flag is module scope (save points can overlap)"
)
# Phase 189 (A3): the guard arm still SKIPS the call (no second
# upsert, no second request) — it only records the save point for the
# finally re-run pinned by test_persisting_overlap_sets_pending_and_reruns.
guard = body.find("if (persisting) {")
guard_return = body.find("return;", guard)
assert -1 < guard < guard_return < body.find("persisting = true;"), (
"an in-flight upsert skips the second call"
)
assert body.count("persisting = true;") == 1, "one upsert in flight, ever"
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "persisting = false" in body[finally_idx:], (
"the flag is released in the finally — never stuck"
)
def test_persisting_overlap_sets_pending_and_reruns() -> None:
"""Phase 189 (A3, the owner incident 2026-10-16): a save point that
arrives while an upsert is in flight is PENDING, not dropped. The
guard arm sets ``savePending`` and returns; a fresh run CONSUMES the
flag at the top of its body (so a re-run never schedules a second
one); the ``finally`` re-runs ``persistConversation()`` once, AFTER
``persisting = false`` (the re-run is a fresh serialized call —
never concurrent with the upsert it trails) and clears the flag
before re-entering. Because the payload is built at CALL time, the
re-run carries the LATEST ``conversation`` — the done-time save a
still-running send-time save used to swallow lands with the brain
record."""
js = _js()
body = _fn(js, "persistConversation")
# All three phase-189 flags live at module scope (state across the
# overlapping save points), beside the pre-existing `persisting`.
for flag in ("savePending", "saveFailedSticky", "retryScheduled"):
assert re.search(rf"^let {flag} = false;", js, re.M), (
f"{flag} is module scope like `persisting`"
)
noop = body.find("if (!conversation.length) return;")
first_fetch = body.find("await fetch(")
guard = body.find("if (persisting) {")
guard_return = body.find("return;", guard)
assert -1 < noop < guard < first_fetch, (
"the overlap guard still sits before any request"
)
set_pending = body.find("savePending = true;", guard)
assert guard < set_pending < guard_return, (
"the in-flight arm marks the save point PENDING instead of dropping it"
)
# A fresh run consumes the flag — before the payload is built, so the
# re-run is THIS run and cannot schedule a second re-run.
start = body.find("persisting = true;")
consume = body.find("savePending = false;", start)
assert -1 < start < consume < first_fetch, (
"the run consumes the pending flag at the top of its body"
)
payload = body.find("JSON.stringify({ messages: conversation })")
assert consume < payload, (
"the payload is built at call time — the re-run reads the LATEST conversation"
)
# The finally: release FIRST, then re-run once (never concurrent).
finally_idx = body.rfind("finally")
assert finally_idx > first_fetch, "the release lives in the finally"
tail = body[finally_idx:]
release = tail.find("persisting = false")
recheck = tail.find("if (savePending)", release)
rerun = tail.find("void persistConversation();", recheck)
assert -1 < release < recheck < rerun, (
"the re-run starts only after the in-flight upsert fully settled"
)
clear = tail.find("savePending = false;", recheck)
assert recheck < clear < rerun, "the flag is cleared before re-entering — one re-run, not two"
assert tail.count("void persistConversation();") == 1, "exactly one re-run site"
def test_failed_save_note_is_sticky_until_success() -> None:
"""Phase 189 (A4): a failed auto-save's note does not vanish with the
next paint — both failure arms set ``saveFailedSticky`` after the
EXACT phase-55 note text (the A2 shape is untouched: the same
one-line #send-status note, still NO error banner, still no success
text), and the SUCCESS path clears the flag and resets
``#send-status``. The clear sits on the success path only — after the
201 link and the stale-banner clear, never in a failure arm."""
js = _js()
body = _fn(js, "persistConversation")
note = "Couldn't save automatically — will try on the next message."
assert body.count(note) == 2, "the A2 note text is unchanged, on both failure paths"
assert "showErrorBanner" not in body, "A2 unchanged: a failed auto-save never raises a banner"
assert "Conversation saved." not in body, "A2 unchanged: success stays silent"
# Non-ok arm: note, then the sticky flag.
notok = body.find("if (!res.ok)")
first_note = body.find(note)
first_sticky = body.find("saveFailedSticky = true;", first_note)
assert -1 < notok < first_note < first_sticky, (
"the non-ok arm notes AND marks the note sticky"
)
# Network arm (the catch): the same note, the same flag.
catch_idx = body.find("} catch {")
second_note = body.find(note, catch_idx)
second_sticky = body.find("saveFailedSticky = true;", second_note)
assert catch_idx < second_note < second_sticky, (
"the catch arm notes AND marks the note sticky"
)
assert body.count("saveFailedSticky = true;") == 2, "both — and only both — failure arms"
# Success: the flag clears and the status line resets, on the success
# path only (after the 201 link + the stale-banner clear).
link = body.find("res.status === 201")
hide = body.find("staleBanner.hidden = true")
clear_guard = body.find("if (saveFailedSticky)")
assert -1 < link < hide < clear_guard < catch_idx, (
"the stuck note clears on the success path, never on failure"
)
block = body[clear_guard : body.find("}", clear_guard)]
assert "saveFailedSticky = false;" in block, "the flag resets with the note"
assert 'sendStatus.textContent = "";' in block, "the status line returns to empty"
def test_transient_failure_retries_once_after_5s() -> None:
"""Phase 189 (A4): a TRANSIENT failure — the network ``catch`` arm or
a 5xx status — schedules ONE retry after 5 000 ms. The one-shot
``retryScheduled`` guard is set BEFORE the ``setTimeout`` and reset
INSIDE the callback (overlapping failures never stack retries), and
the retry is a plain ``persistConversation()`` — the LATEST
conversation through the same guards (the pending re-run included)."""
js = _js()
body = _fn(js, "persistConversation")
assert body.count("setTimeout(") == 2, "one retry site per transient arm — no more"
for arm_start in (body.find("if (!res.ok)"), body.find("} catch {")):
arm_end = body.find("} finally {", arm_start)
assert -1 < arm_start < arm_end
timeout = body.find("setTimeout(", arm_start)
assert arm_start < timeout < arm_end, "the arm schedules the retry"
guard = body.find("retryScheduled = true;", arm_start)
assert arm_start < guard < timeout, (
"the one-shot guard is set BEFORE the setTimeout"
)
delay = body.find(", 5000);", timeout)
assert timeout < delay < arm_end, "the retry waits 5 000 ms"
callback = body[timeout:delay]
assert "retryScheduled = false;" in callback, "the guard resets INSIDE the callback"
assert "void persistConversation();" in callback, "the retry is a plain auto-save"
# The 5xx condition is the ONLY status gate on the non-ok arm.
notok_arm = body[body.find("if (!res.ok)") : body.find("} catch {")]
assert "res.status >= 500" in notok_arm
def test_4xx_never_retries() -> None:
"""Phase 189 (A4): a 4xx is DETERMINISTIC — a 422 (the incident: the
22-source payload over the old 20-cap) would 422 again, and task 01's
cap realign is the remedy. The non-ok arm's only scheduling site sits
behind ``res.status >= 500``, so every other non-ok status (404 aside
— the recreate fallback — 409, 422, 400) notes and returns with no
timer; the arm still marks the note sticky (it stays visible)."""
js = _js()
body = _fn(js, "persistConversation")
notok = body.find("if (!res.ok)")
catch_idx = body.find("} catch {")
arm = body[notok:catch_idx]
assert arm.count("setTimeout(") == 1, "the non-ok arm has exactly ONE scheduling site"
gate = arm.find("if (res.status >= 500 && !retryScheduled) {")
timeout = arm.find("setTimeout(")
assert -1 < gate < timeout, "the only retry is gated on a 5xx status"
# Nothing else in the arm keys off a 4xx, and nothing after the gate's
# block runs before the return.
for forbidden in ("status === 4", "status >= 4", "status < 500", "retryScheduled"):
assert forbidden not in arm[timeout + len("setTimeout(") :].replace(
"retryScheduled = false;", ""
), f"no extra scheduling branch keyed on {forbidden!r}"
assert arm.find("saveFailedSticky = true;") < arm.find("return;"), (
"a 4xx still leaves the note visible — it just never retries"
)
assert body.count("setTimeout(") == 2, "the catch arm is the only other scheduling site"
def test_auto_save_wired_to_the_save_points() -> None:
"""The headless helper is referenced from the save points: the user
send (runTurn's ``!reask`` block — after the localStorage
saveConversation()) and the brain save point (rememberBrainTurn —
after its saveConversation()). The pagehide partial rides
rememberBrainTurn: NO second direct call there."""
js = _js()
# Save point 1: the user send in runTurn's !reask block.
turn = js.find("async function runTurn")
reask_block = js[js.find("if (!reask) {", turn) : js.find("let wrap = null", turn)]
save1 = reask_block.find("saveConversation();")
persist1 = reask_block.find("persistConversation();")
assert -1 < save1 < persist1, "the user-send save point rides persistConversation()"
# Save point 2: rememberBrainTurn (the brain-done + stop + pagehide path).
body = _fn(js, "rememberBrainTurn")
save2 = body.find("saveConversation();")
persist2 = body.find("persistConversation();")
assert -1 < save2 < persist2, "the brain save point rides persistConversation()"
# The pagehide handler itself carries no direct persist call — the
# partial rides rememberBrainTurn (no extra wiring, phase 20
# contract untouched).
m = re.search(r'window\.addEventListener\("pagehide", \(\) => \{([\s\S]*?)\n\}\);', js)
assert m, "the pagehide handler must exist"
assert "persistConversation" not in m.group(1), (
"the pagehide partial rides rememberBrainTurn — no second call"
)
def test_record_carries_the_row_link() -> None:
"""Phase 55 (A2): the bor.chat.v1 record carries ``chatId``. The
write (saveConversation) persists the CURRENT currentChatId (null
when unlinked) with the versioned record; the reader
(loadStoredRecord) reads it back with old-record safety — a
pre-55 record without the field (or a non-string) reads as null,
never throws; the restore validates the version before trusting
anything."""
js = _js()
save_body = _fn(js, "saveConversation")
assert "chatId: currentChatId" in save_body, ("the write persists the current link")
assert "v: STORAGE_VERSION" in save_body
assert "trimToBudget(conversation)" in save_body
read_body = _fn(js, "loadStoredRecord")
assert "data.v !== STORAGE_VERSION" in read_body, "version validated first"
assert "Array.isArray(data.messages)" in read_body
# Old-record safety: optional field, string check, null fallback.
assert 'typeof data.chatId === "string"' in read_body
assert "data.chatId.length ? data.chatId : null" in read_body
# The defensive message filter survives the reshape.
assert 'm.who === "user" || m.who === "brain"' in read_body
assert 'typeof m.text === "string"' in read_body
# ---------- boot-load precedence ----------
def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
"""Inside the boot IIFE: after fetchIsAdmin(), restoreSavedChatFromUrl()
runs; only when it returns false does the phase-14 local restore run
(which hydrates the row link from the record — phase 55). Header init
stays first (shared-module contract). The phase-50 Save-reveal line is
GONE — and phase 55 task 03 removed the Share-reveal line too (the
pill is static, always-visible markup: no reveal step at boot)."""
js = _js()
boot_start = js.find("(async () => {")
assert boot_start != -1, "the boot IIFE must exist"
boot = js[boot_start:]
# Phase 79 (task 05): the token gate settles FIRST — a cached
# token's silent re-auth lands before the first whoami fires.
gate_i = boot.find("await mountGate(")
init_i = boot.find("await initSharedHeader();")
admin_i = boot.find('who.role === "admin";')
saved_i = boot.find("await restoreSavedChatFromUrl();")
local_i = boot.find("restoreConversation();")
assert -1 < gate_i < init_i < admin_i < saved_i < local_i, (
"boot order: token gate → header init → whoami → ?chat= load → local fallback"
)
assert "shareBtn.hidden" not in boot, ("no Share-reveal line left in boot (phase 55 task 03)")
assert "if (!openedSaved) restoreConversation();" in boot, (
"the local restore runs ONLY when the saved-chat load did not open"
)
assert "saveBtn" not in boot, "no Save-reveal line left in boot (phase 55)"
def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
"""restoreSavedChatFromUrl: a VALID uuid + admin is the ONLY
fetch path — invalid/absent ?chat= and anonymous short-circuit to
false (no request: the gate would 403). On 200 the messages
REPLACE the local conversation, render through the SAME
renderStoredMessage loop (pixel-identical restore), link
currentChatId, and mirror to localStorage. 404/network/malformed/
empty → banner + false (the local restore then runs)."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
# The gates, in order: param present → valid uuid → admin.
assert '.get("chat")' in body, "the ?chat= param"
assert "UUID_RE.test(chatId)" in body, "a valid uuid only"
assert "!isAdmin" in body, "admin only (no fetch for anonymous)"
gate = body.find("!isAdmin")
fetch_i = body.find('fetch(`/api/chats/${chatId}`)')
assert -1 < gate < fetch_i, "the gates short-circuit BEFORE the fetch"
assert "const UUID_RE" in js, "the uuid pattern is module-level"
# On success: replace → render through the SAME loop → link → mirror.
assert "conversation = messages" in body, "the saved messages REPLACE the local conversation"
assert "renderStoredMessage(m)" in body, "the SAME renderStoredMessage path as local restore"
assert "markLastRetryable()" in body, "parity with local restore: Retry on the last bubble"
save_mir = body.find("saveConversation()")
link_i = body.find("currentChatId = chatId")
assert -1 < link_i < save_mir, "link first, then mirror to localStorage"
# Failure: the exact banner line, then false (→ local restore). The
# gate line returns false directly; the 404/network, malformed-body
# and empty-payload paths all route through the banner helper.
banner_line = 'showErrorBanner("That saved chat isn\'t available — it may have been deleted.")'
assert banner_line in body
assert "return false" in body, "invalid/absent param or anonymous → no fetch, local restore"
assert body.count("return unavailable()") == 4, (
"network, non-ok (404/403/5xx), malformed body and empty payload all fall back"
)
# The ?chat= param is a one-shot boot instruction: the success path
# normalizes the URL back to / so a later refresh (or "New chat" +
# refresh) restores the LOCAL session instead of re-opening the row.
assert 'history.replaceState(null, "", "/")' in body, (
"a consumed ?chat= must not linger in the URL"
)
# The defensive filter keeps a corrupted stored row from poisoning the
# restore (same shape check as loadStoredConversation).
assert 'm.who === "user" || m.who === "brain"' in body
assert 'typeof m.text === "string"' in body
# ---------- the reveal gate ----------
def test_no_save_pill_wiring_left_in_app_js() -> None:
"""Phase 55 (A2): the Save pill's wiring is GONE — no
#save-chat-btn query, no click binding to a save handler, no boot
reveal line. The headless persistConversation() replaces all of it
(no button, no admin gate: every visitor's conversation auto-saves
— the write surface is public, phase 55 task 01)."""
js = _js()
assert 'querySelector("#save-chat-btn")' not in js, "the pill query is gone"
assert "saveCurrentChat" not in js, "the button handler is gone"
assert 'addEventListener("click", saveCurrentChat)' not in js, "no save binding"
assert "saveBtn" not in js, "no saveBtn symbol anywhere (the tune form uses its own)"
def test_boot_load_adds_no_direct_storage_access() -> None:
"""The localStorage accesses stay EXACTLY the phase-14 three
(loadStoredConversation / saveConversation / clearStoredConversation)
— the saved-chat mirror goes through saveConversation(), so the
house failure-safety pin (exactly 3, all try-wrapped) holds."""
js = _js()
accesses = list(re.finditer(r"localStorage\.(?:getItem|setItem|removeItem)", js))
assert len(accesses) == 3, f"expected exactly 3 localStorage accesses, got {len(accesses)}"
def test_no_cdn_added() -> None:
"""AGENTS.md rule 6: the Save button adds no external script/link."""
index = _index()
assert 'src="http' not in index and 'href="http' not in index
# ---------- the Share button on the chat page (phase 51, task 02) ----------
def test_share_button_ships_visible_beside_new_chat() -> None:
"""#share-chat-btn: a real type=button with the accessible name
"Share chat", SHIPPED VISIBLE to every visitor (phase 55 task 03 —
NO ``hidden`` attribute, no reveal step; the phase-51 admin-only
ship-hidden gate is gone), BESIDE #new-chat-btn in .chat-shell
inside <main> — the chat-shell actions read as a pair (New chat |
Share; the Save pill is gone, phase 55). Phase 65 (2026-09-01,
``TODO.md`` L3, owner confirmation) moved the row from the top of
the column to the bottom: below #messages, directly above the
composer. No other page carries it (chat-page only, like New
chat)."""
html = _index()
btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #share-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="Share chat"' in tag
assert "hidden" not in tag, ("the button ships visible — no reveal step (phase 55 task 03)")
# The label: the visible text is "Share" (the link SVG is aria-hidden
# decoration; the aria-label carries the accessible name).
btn_block = html[btn.start() : html.find("</button>", btn.start())]
assert '>Share</span>' in btn_block
# Beside New chat (the Save pill is gone): after it, still inside
# .chat-shell — below #messages, above the composer (phase 65
# moved the row to the bottom of the column).
shell_idx = html.find('class="container chat-shell"')
new_idx = html.find('id="new-chat-btn"')
messages_idx = html.find('id="messages"')
messages_end = html.find("</section>", messages_idx)
composer_idx = html.find('<form class="composer" id="composer"')
assert -1 < shell_idx < new_idx < btn.start() < composer_idx, (
"the button must sit beside #new-chat-btn in .chat-shell, below "
"#messages, above the composer (phase 65)"
)
assert messages_end < new_idx, ("the row moved below the #messages section")
# Phase 76 (task 02): the folded view files are gone (the shell's
# chat view is the one and only carrier of the button — pinned
# above); the standalone pages carry none (task 03 dropped the
# last folded file, history.html).
for other in (DOCUMENT_HTML, LOGIN_HTML):
assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the Share button is chat-page only"
)
def test_share_button_css_is_the_exact_save_family() -> None:
"""styles.css: .share-chat-btn carries the EXACT visual family of the
phase-50 Save pill (now the .new-chat-btn family — the Save rules
are gone with the pill, phase 55): same solid brand pill — --bg on
--brand = 5.2:1, AA; borderless; 999px radius; ≥44px target; hover
lightens the brand fill; the ≤640px block mirrors the New chat
overrides (label stays visible in .chat-shell, icon hidden there).
"""
css = _css()
block = re.search(r"\.share-chat-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .share-chat-btn"
body = block.group(1)
assert "min-height: 44px" in body
assert "border-radius: 999px" in body
assert "border: 0" in body
assert "background: var(--brand)" in body, "same solid brand fill as Save"
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
hover = re.search(r"\.share-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "var(--brand-hover)" in hover.group(1), (
"hover lightens the brand fill (phase 92: --brand-hover)"
)
svg = re.search(r"\.share-chat-btn svg \{([\s\S]*?)\n\}", css)
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like Save)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
assert ".share-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, ("squeezes with Save")
assert ".share-chat-label { display: none; }" in mbody
assert ".share-chat-btn svg { display: block; }" in mbody
assert ".chat-shell .share-chat-label { display: inline; }" in mbody, (
"in .chat-shell the label stays visible, as for Save"
)
assert ".chat-shell .share-chat-btn svg { display: none; }" in mbody
def test_share_current_chat_save_then_share_branch() -> None:
"""shareCurrentChat: the same empty-conversation no-op guard as
Save (live region, no request). The save-then-share branch: linked
(currentChatId set) → POST /api/chats/<id>/share (the idempotent
token); unlinked → POST /api/chats with { messages: conversation,
share: true } and link currentChatId to the created id — one action
saves AND shares (owner-locked). Success: the ABSOLUTE URL is
copied — the clipboard try succeeds → the live region reads
"Share link copied."; the rejection (a non-secure http origin)
renders the .share-link-fallback field + "Share link ready — copy it
from the field." 403/5xx → the actionable banner (NEUTRAL "try
again" — the write surface is public, phase 55 task 01, so a 403
is no longer a sign-in problem for a guest); network → the
reachable? banner. The double-click guard releases in the finally —
never stale.
Phase 189 (task 02) moves the ORDERING pins to the create-with-share
payload: the linked branch now ALSO holds a POST (its 404→recreate
fallback), so "the first /api/chats POST is the unlinked arm" no
longer reads — ``share: true`` is what identifies that arm (the
upsert-before-mint ordering itself is pinned by
test_share_linked_branch_upserts_before_the_share_mint)."""
js = _js()
body = _fn(js, "shareCurrentChat")
# No-op first: nothing to share → live-region line, no fetch.
noop = body.find('sendStatus.textContent = "Nothing to share yet."')
first_fetch = body.find("await fetch(")
assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch"
assert "if (!conversation.length)" in body
# The branch: POST share when linked, create-with-share when not.
assert "if (currentChatId)" in body
linked = '`/api/chats/${currentChatId}/share`'
share_fetch = body.find(linked)
assert share_fetch != -1, "the linked branch POSTs the idempotent share"
assert 'fetch("/api/chats", {' in body, "the unlinked branch POSTs /api/chats"
assert 'JSON.stringify({ messages: conversation, share: true })' in body, (
"the create-with-share payload — the server sets the token in the same commit"
)
post_idx = body.find('JSON.stringify({ messages: conversation, share: true })')
assert -1 < share_fetch < post_idx, "the linked branch precedes the unlinked fallback"
created_idx = body.find("currentChatId = String(created.id)", post_idx)
assert created_idx != -1, "one action saved AND shared: the conversation links to the row"
# The copy: the ABSOLUTE URL (share_url resolved against the page
# origin) + the two live-region outcomes (success / the owner-locked
# inline-field fallback).
abs_fn = _fn(js, "absoluteShareUrl")
assert "new URL(shareUrl, window.location.origin).toString()" in abs_fn, (
"the ABSOLUTE URL is what gets copied (the origin supplies scheme/host)"
)
assert "copyShareLinkWithFallback(absoluteShareUrl(shareUrl))" in body
assert (
'sendStatus.textContent = copied\n'
' ? "Share link copied."\n'
' : "Share link ready — copy it from the field."'
) in body
# Failures raise an actionable banner (non-ok HTTP + network).
# Phase 55 task 03: the 403/5xx copy is NEUTRAL ("try again") — no
# sign-in wording anywhere in the share handler (the write surface
# is public); the network banner keeps its own line.
assert 'showErrorBanner("Couldn\'t share the conversation — is the app reachable?")' in body
neutral = "Couldn't share the conversation — try again."
assert body.count(neutral) == 3, (
"three non-ok arms carry the SAME neutral banner: the phase-189 "
"upsert abort, the linked share mint, and the unlinked create "
"(no sign-in wording on any of them)"
)
assert "signed in" not in body, "no sign-in wording left in the share handler (task 03)"
# The double-click guard releases on EVERY outcome.
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
# Phase 182 (task 07): the clipboard + fallback pair MOVED to
# assets/verbatim.js so the answer's verbatim panels and ordinary code
# blocks share ONE degradation with the share link. The owner-locked
# behavior is unchanged and re-pinned on its new home: the same host (the
# composer, beside the status line — it rides the delegation), the same
# transient `.share-link-fallback` field, the same select-on-focus, the
# same XSS contract (textContent only), the same title string.
copy = _fn(js, "copyShareLinkWithFallback")
assert "copyTextWithFallback(" in copy, (
"the share path delegates — ONE clipboard implementation"
)
assert "absoluteUrl," in copy, "the shared URL is what gets copied"
assert "composer," in copy, "near the status line (the composer)"
assert '"Share link — click, then copy (Ctrl/Cmd+C)"' in copy, (
"the owner-locked field title rides the delegation byte-identically"
)
ver = VERBATIM_JS.read_text(encoding="utf-8")
impl = _fn(ver, "copyTextWithFallback")
assert "await navigator.clipboard.writeText(text)" in impl
assert 'field.className = "share-link-fallback"' in impl
assert "field.href = text" in impl, "a URL field carries the href"
assert "field.textContent = text" in impl, "XSS contract: textContent only"
assert (
'field.addEventListener("focus", () => selectAllInFallbackField(field))' in impl
), "select-on-focus — the field-like behavior"
assert "host.appendChild(field)" in impl, "appended to the caller's host"
assert (
'document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove())' in impl
), "one field at a time (a new offer replaces the old)"
sel = _fn(ver, "selectAllInFallbackField")
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
def test_share_linked_branch_upserts_before_the_share_mint() -> None:
"""Phase 189 (task 02, A2): the LINKED branch of ``shareCurrentChat``
upserts the CURRENT conversation BEFORE minting the token — the owner
incident (2026-10-16: a row holding only the question because the
done-time auto-save 422'd into the phase-55 quiet failure; the Share
minted on that stale row and published a truncated snapshot) is the
why. The upsert carries ``persistConversation``'s semantics: the
``{ messages: conversation }`` payload + JSON headers, the PUT to
/api/chats/<id>, the 404→recreate fallback (unlink, then POST
/api/chats with the SAME body/headers) and the 201 re-link. The
share mint is the LAST request of the branch (string-ordering pin —
no browser)."""
js = _js()
body = _fn(js, "shareCurrentChat")
branch = body.find("if (currentChatId) {")
else_idx = body.find("} else {")
assert -1 < branch < else_idx
arm = body[branch:else_idx]
put = arm.find('`/api/chats/${currentChatId}`')
mint = arm.find('`/api/chats/${currentChatId}/share`')
assert -1 < put < mint, "the upsert PUT precedes the share mint (save-first)"
# The persistConversation-shaped payload + headers, byte-identical to
# the auto-save's (ONE upsert shape, not two).
persist = _fn(js, "persistConversation")
assert arm.find('const body = JSON.stringify({ messages: conversation });') < put
assert 'const body = JSON.stringify({ messages: conversation });' in persist, (
"the share upsert reuses the auto-save's payload shape"
)
assert 'const headers = { "Content-Type": "application/json" };' in arm
assert 'method: "PUT"' in arm, "the linked branch PUTs the linked row"
# The 404→recreate fallback (the same stale-link rule as the auto-save).
notfound = arm.find("res.status === 404")
assert put < notfound < mint, "a PUT 404 (row deleted on History) is handled"
assert "currentChatId = null" in arm[notfound:mint], "the stale link is dropped"
fallback = arm.find('fetch("/api/chats", { method: "POST", headers, body })')
assert notfound < fallback < mint, (
"the 404 retries as a create with the SAME body/headers"
)
relink = arm.find("currentChatId = String((await res.json()).id)")
assert fallback < relink < mint, (
"the 201 re-links the conversation to the recreated row"
)
# The mint rides AFTER the upsert settled — and it is the branch's
# LAST request (nothing publishes before the row catches up).
assert arm.count("await fetch(") == 3, "PUT, the recreate POST, then the mint"
last_fetch = arm[arm.rfind("await fetch(") :].split("\n")[0]
assert "/share`" in last_fetch, "the share mint is the linked branch's LAST fetch"
# The docstring says WHY (the incident), not just what.
doc = js[: js.find("async function shareCurrentChat()")]
doc = doc[doc.rfind("/*"):]
assert "UPSERTS the current conversation BEFORE minting the token" in doc
assert "truncated snapshot" in doc, "the incident rationale is in the docstring"
def test_share_aborts_on_upsert_failure() -> None:
"""Phase 189 (task 02, A2): an upsert FAILURE aborts the share. The
failure arm that sits BETWEEN the upsert and the mint raises the
EXACT neutral banner text and ``return``s — no token minted, no
toast, no link handed to the owner (the mint line and every success
path come after it). The double-click guard still releases in the
finally, and a network throw still lands on the reachable? banner
(the outer catch, unchanged)."""
js = _js()
body = _fn(js, "shareCurrentChat")
branch = body.find("if (currentChatId) {")
arm = body[branch : body.find("} else {")]
mint = arm.find('`/api/chats/${currentChatId}/share`')
notok = arm.find("if (!res.ok)")
banner = arm.find(
'showErrorBanner("Couldn\'t share the conversation — try again.")', notok
)
assert -1 < notok < banner < mint, (
"the non-ok upsert raises the neutral banner BEFORE the mint line"
)
ret = arm.find("return;", banner)
assert notok < ret < mint, "the abort returns — the mint is never reached"
abort = arm[notok:ret]
assert "showToast" not in abort, "the abort toasts nothing (the toast stays success-only)"
assert "share_url" not in abort, "no link is read or handed out on the abort"
assert "copyShareLinkWithFallback" not in abort, "no copy on the abort"
assert "showToast" not in arm[:mint], "no toast anywhere before the mint"
# Both non-ok arms of the linked branch carry the SAME neutral copy:
# the upsert abort and the mint's own failure.
assert arm.count('showErrorBanner("Couldn\'t share the conversation — try again.")') == 2, (
"the upsert abort + the mint's non-ok arm — the neutral copy, twice in the branch"
)
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
"the button releases even on the abort — never stale"
)
def test_share_unlinked_branch_unchanged() -> None:
"""Phase 189 (task 02): the UNLINKED branch is byte-identical — one
``POST /api/chats`` with ``{ messages: conversation, share: true }``
(the server sets the 128-bit token in the same commit — one action
saves AND shares, owner-locked) and the 201 re-link. It carries no
PUT and no separate share mint: the save-first upsert belongs to the
linked arm only (its POST already saves the CURRENT conversation)."""
js = _js()
body = _fn(js, "shareCurrentChat")
else_idx = body.find("} else {")
copy_idx = body.find("copyShareLinkWithFallback(absoluteShareUrl(shareUrl))")
assert -1 < else_idx < copy_idx
arm = body[else_idx:copy_idx]
assert 'fetch("/api/chats", {' in arm
assert 'method: "POST"' in arm
assert 'JSON.stringify({ messages: conversation, share: true })' in arm
assert "currentChatId = String(created.id)" in arm, "the 201 re-links (saved AND shared)"
assert 'shareUrl = created.share_url' in arm, "the token comes from the same commit"
assert 'method: "PUT"' not in arm, "the unlinked arm never PUTs — unchanged"
assert "/share" not in arm, "the unlinked arm never mints a separate token"
assert "share: true" not in body[:else_idx], "share: true stays out of the linked arm"
def test_share_button_has_no_reveal_gate() -> None:
"""Phase 55 task 03: the pill is VISIBLE TO EVERY VISITOR — app.js
queries #share-chat-btn and binds the click to shareCurrentChat, but
there is NO reveal step: no ``shareBtn.hidden`` assignment ANYWHERE
in app.js (the phase-51 ship-hidden/admin-reveal gate is gone; the
markup ships visible and task 01 opened the write surface to all).
"""
js = _js()
assert 'document.querySelector("#share-chat-btn")' in js
assert 'shareBtn?.addEventListener("click", shareCurrentChat)' in js
assert "shareBtn.hidden" not in js, "no reveal step — the pill ships visible to all"
# ---------- the share-success toast (phase 55, task 04, A4) ----------
def test_show_toast_helper_single_instance_and_aria_hidden() -> None:
"""showToast (A4 owner-locked): a SINGLE node — lazy-created on the
first call and REUSED thereafter (toasts never stack), a plain
``<div class="toast">`` appended to ``document.body``; the text
lands via ``textContent`` (XSS-safe — never innerHTML); the node is
``aria-hidden="true"`` (visual only — #send-status is the
announcer). Re-triggering the entry (a second share while the first
toast is up): clear the pending dismiss timer, remove the visible
state class, force a reflow (``offsetWidth`` — restarts the CSS
transition), re-add the class. Auto-dismiss: a 4000ms timer set
AFTER the visible class is added, removing the class on fire."""
js = _js()
body = _fn(js, "showToast")
# Lazy single instance, appended to <body>, marked visual-only.
assert "if (!toastEl)" in body, "the node is created once, on first use"
assert 'document.createElement("div")' in body
assert 'toastEl.className = "toast"' in body
assert 'toastEl.setAttribute("aria-hidden", "true")' in body, ("A4: visual only")
assert "document.body.appendChild(toastEl)" in body
# textContent only — never innerHTML.
assert "toastEl.textContent = message" in body
assert "innerHTML" not in body, "XSS contract: textContent only"
# Single instance: module-scope node + timer, reused (no stacking).
assert re.search(r"^let toastEl = null", js, re.M), ("the node is module scope")
assert re.search(r"^let toastTimer = 0", js, re.M), ("the timer is module scope")
# Re-trigger order: clear dismiss → remove class → force reflow →
# re-add the visible class.
clear_i = body.find("clearTimeout(toastTimer)")
remove_i = body.find('toastEl.classList.remove("is-visible")')
reflow_i = body.find("void toastEl.offsetWidth")
add_i = body.find('toastEl.classList.add("is-visible")')
assert -1 < clear_i < remove_i < reflow_i < add_i, (
"dismiss cleared → class removed → reflow forced → visible re-added"
)
# Auto-dismiss ~4s, armed AFTER the visible class is set.
timer_i = body.find("setTimeout")
assert -1 < add_i < timer_i and "4000" in body
assert 'toastEl.classList.remove("is-visible")' in body[timer_i:], (
"the pending dismiss removes the visible state"
)
def test_toast_called_from_both_share_success_branches_only() -> None:
"""shareCurrentChat (task 04): BOTH success paths call showToast
with their own text — the clipboard path → "Share link copied.",
the fallback-field path → "Share link ready — copy it from the
field." — and both calls ride the SUCCESS branch (after the copy,
after the untouched #send-status live-region lines). showToast
appears EXACTLY twice in the handler and never in a failure branch
(the two !res.ok banners precede the copy; the network catch —
the error banner is the failure UI — carries no toast)."""
js = _js()
body = _fn(js, "shareCurrentChat")
assert body.count("showToast(") == 2, "exactly one toast per success path"
copy_i = body.find("copyShareLinkWithFallback(absoluteShareUrl(shareUrl))")
assert copy_i != -1, "the copy (the success branch) must exist"
t1 = body.find('showToast("Share link copied.")')
t2 = body.find('showToast("Share link ready — copy it from the field.")')
assert t1 != -1 and t2 != -1, "both success paths toast their own text"
assert -1 < copy_i < min(t1, t2), ("the toasts ride the SUCCESS branch (after the copy)")
# The #send-status lines stay exactly as they were (the a11y
# announcer) and precede the toast calls.
status_i = body.find("sendStatus.textContent = copied")
assert -1 < status_i < min(t1, t2)
# Never on failure: the catch block carries no toast.
catch_i = body.rfind("} catch {")
assert catch_i != -1 and "showToast" not in body[catch_i:], (
"a failed share shows the error banner, no toast"
)
def test_toast_css_top_right_brand_family_and_reduced_motion() -> None:
"""styles.css (task 04): .toast — position: fixed, top-right just
under the sticky header (--header-h + offset — the variable steps
64px → 58px at ≤640px), z-index 1000 (the modal overlay contract —
above the header's 20), a small max-width so long text wraps, the
solid brand fill (--bg text on --brand = 5.2:1, AA — the
.new-chat-btn family), rounded + shadowed. Hidden by default
(opacity 0 + pointer-events: none — it never intercepts clicks when
idle) and resting at translateY(-8px), with the ~200ms entry
transition; .toast.is-visible lands at opacity 1 / translateY(0).
Under prefers-reduced-motion: reduce the transform is dropped for
BOTH states (.is-visible would otherwise out-specify the bare
.toast) and the opacity fade remains."""
css = _css()
block = re.search(r"\.toast \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .toast"
body = block.group(1)
for prop in (
"position: fixed",
"top: calc(var(--header-h) + 0.75rem)",
"right: 1rem",
"z-index: 1000",
"max-width: min(22rem, calc(100vw - 2rem))",
"background: var(--brand)",
"color: var(--bg)",
"border-radius: var(--radius-sm)",
"box-shadow: var(--shadow)",
"opacity: 0",
"pointer-events: none",
"transform: translateY(-8px)",
):
assert prop in body, f".toast must keep {prop}"
assert "transition:" in body and "200ms" in body, ("the entry is a ~200ms slide-down + fade")
visible = re.search(r"\.toast\.is-visible \{([\s\S]*?)\n\}", css)
assert visible, "the .toast.is-visible state class (toggled by showToast) must exist"
assert "opacity: 1" in visible.group(1)
assert "transform: translateY(0)" in visible.group(1)
# The reduced-motion override: transform dropped (BOTH states
# named), the opacity fade kept.
rm = None
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css):
if ".toast" in m.group(1):
rm = m.group(1)
break
assert rm is not None, "a reduced-motion block must cover .toast"
assert ".toast.is-visible { transform: none; }" in rm, ("the slide is dropped for BOTH states")
assert re.search(r"\.toast \{ transition: opacity", rm), ("the opacity fade remains")
# ---------- the chat-actions row (phase 55, task 05, A5) ----------
def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
"""index.html (task 05, A5; phase 65 task 01): ONE
``<div class="chat-actions">`` wraps BOTH pills — its element
children are exactly the two buttons, in the A5 order New chat →
Share. The wrapper is a normal ``.chat-shell`` column child, but
phase 65 (2026-09-01, ``TODO.md`` L3, owner confirmation) moved it
from the top of the column to the bottom: below the ``#messages``
section, directly above the composer; nothing but the row's own
comment lands between ``#messages`` and the row, and nothing but the
phase-104 ``#char-count`` counter + the phase-123 attach preview
strip + the composer comment lands between the row and the composer
(both hidden by default — zero height at rest, the pinned-cluster
geometry untouched). No other page carries ``.chat-actions``
(chat-page only, like the pills)."""
html = _index()
start = html.find('<div class="chat-actions">')
assert start != -1, "index.html must carry the .chat-actions wrapper"
end = html.find("</div>", start)
assert end != -1, "the wrapper must close"
wrap = html[start:end]
# Exactly two element children: the two pill buttons, New chat first.
assert wrap.count("<div") == 1, "no nested div inside the row wrapper"
assert wrap.count("<button") == 2, "the row holds exactly the two pills"
new_i = wrap.find('id="new-chat-btn"')
share_i = wrap.find('id="share-chat-btn"')
assert -1 < new_i < share_i, "A5 order: New chat first, then Share"
# Position: a .chat-shell column child, BELOW #messages and directly
# above the composer (phase 65 — the top of the column is now
# banner → steering → announcer → messages; nothing else with an id
# around the row).
shell_idx = html.find('class="container chat-shell"')
messages_idx = html.find('id="messages"')
messages_end = html.find("</section>", messages_idx)
composer_idx = html.find('<form class="composer" id="composer"')
assert -1 < shell_idx < messages_end < start < end < composer_idx, (
"the row is a .chat-shell column child, below #messages, above the composer"
)
between = html[messages_end:start]
assert (
"id=" not in between
and "<button" not in between
and "<section" not in between
and "<form" not in between
), ("nothing but the row's comment lands between #messages and the row")
after = html[end:composer_idx]
# Phase 104 (owner 2026-09-12): the ONE permitted child between the
# row and the composer is the hidden-by-default question-length
# counter; phase 123 (task 02, TODO L6) added the second — the
# attach preview strip (the selected image above the input row:
# thumbnail + filename + remove button), also `hidden` by default
# (the global [hidden] rule — zero height at rest, the
# pinned-cluster geometry untouched). Everything else (ids,
# buttons, sections, forms) is still excluded from the gap — the
# strip's own element block is stripped (like the counter) so the
# exclusions below stay meaningful.
strip_start = after.find('<div class="attach-preview"')
assert strip_start != -1, (
"the phase-123 attach preview strip sits above the composer"
)
strip_end = after.find("</div>", strip_start) + len("</div>")
after = after[:strip_start] + after[strip_end:]
after_minus_counter = after.replace(
'<p class="char-count" id="char-count" hidden></p>', ""
)
assert (
after.count("<p") == 1
and "id=" not in after_minus_counter
and "<button" not in after_minus_counter
and "<section" not in after_minus_counter
and "<form" not in after_minus_counter
), (
"nothing but the phase-104 counter + the phase-123 attach "
"preview + the composer comment lands between the row and the "
"composer"
)
# Phase 76 (task 02): the folded view files are gone (the shell's
# chat view is the one and only carrier of the row — pinned above);
# the standalone pages carry none (task 03 dropped the last folded
# file, history.html).
for other in (DOCUMENT_HTML, LOGIN_HTML):
assert "chat-actions" not in other.read_text(encoding="utf-8"), (
f"{other.name}: the action row is chat-page only"
)
def test_chat_actions_row_on_desktop_and_stack_at_640() -> None:
"""styles.css (task 05, A5): the base ``.chat-actions`` rule is a
horizontal flex row — ``display: flex; flex-direction: row;
align-items: center; gap: 0.6rem``. The ``align-items: center`` is
load-bearing: the wrapper is a flex ITEM of the ``.chat-shell``
column (which stretches its items), and the row's own
cross-axis ``center`` (not the column default ``stretch``) keeps
each pill at its intrinsic content width — two pills side by side,
left-aligned, never full-column. The ≤640px override flips the
row to a full-width vertical stack — ``flex-direction: column;
align-items: stretch; gap: 0.5rem`` (New chat above Share) — and
the EXISTING ≤640px pill rules (padding squeeze, the icon/label
handling, the ``.chat-shell`` label overrides) stay in place for
the stacked pills."""
css = _css()
block = re.search(r"\.chat-actions \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .chat-actions (base row)"
for prop in (
"display: flex",
"flex-direction: row",
"align-items: center",
"gap: 0.6rem",
):
assert prop in block.group(1), f".chat-actions must keep {prop}"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
m = re.search(r"\.chat-actions \{([^}]*)\}", mbody)
assert m, "the ≤640px override (vertical stack) must exist"
for prop in (
"flex-direction: column",
"align-items: stretch",
"gap: 0.5rem",
):
assert prop in m.group(1), f"the ≤640px .chat-actions must keep {prop}"
# The stacked pills keep their existing mobile treatment (the rules
# the phase-50/51 pairs established — untouched by this task).
for rule in (
".new-chat-btn { padding: 0.4rem 0.3rem; }",
".share-chat-btn { padding: 0.4rem 0.3rem; }",
".new-chat-label { display: none; }",
".share-chat-label { display: none; }",
".chat-shell .new-chat-label { display: inline; }",
".chat-shell .new-chat-btn svg { display: none; }",
".chat-shell .share-chat-label { display: inline; }",
".chat-shell .share-chat-btn svg { display: none; }",
):
assert rule in mbody, f"the existing ≤640px pill rule must stay: {rule}"
# ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ----------
def test_stale_banner_html_after_kb_banner() -> None:
"""index.html: the #stale-banner section sits DIRECTLY AFTER
#kb-banner (the chat-shell top-of-column position — kb-banner keeps
the top slot when both are visible), role="status", shipped hidden,
with the exact text and the #stale-regenerate button (type=button,
visible label "Regenerate", the redo glyph — the SAME SVG paths as
RETRY_ICON in app.js, the phase-49 Retry asset). No other page
carries it (chat-page only)."""
html = _index()
kb_idx = html.find('id="kb-banner"')
banner = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
assert banner, "index.html must contain the #stale-banner section"
tag = banner.group(0)
assert 'role="status"' in tag
assert "hidden" in tag, "the banner ships hidden (the reveal is app.js's job)"
assert -1 < kb_idx < banner.start(), "the banner sits directly after #kb-banner"
# Nothing between the kb-banner close and the stale banner except
# whitespace + the phase-53 comment: the top-of-column pair is kept.
between = html[html.find("</div>", kb_idx) : banner.start()]
assert "id=" not in between, "no other element lands between the two banners"
block = html[banner.start() : html.find("</section>", banner.start())]
assert "The sources have been updated since this chat was saved." in block
btn = re.search(r'<button[^>]*id="stale-regenerate"[^>]*>', block)
assert btn, "the banner carries the #stale-regenerate button"
assert 'type="button"' in btn.group(0)
btn_block = block[btn.start() : block.find("</button>", btn.start())]
assert ">Regenerate</span>" in btn_block, "the visible label is Regenerate"
# The redo glyph: the SAME paths as RETRY_ICON (the phase-49 asset).
assert 'd="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"' in btn_block
assert 'd="M21 3v5h-5"' in btn_block
# The banner's own leading mark is the redo glyph too (distinct from
# the kb-banner warning triangle) — aria-hidden decoration.
lead = block[: btn.start()]
assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead
# Phase 76 (task 02): the folded view files are gone (the shell's
# chat view is the one and only carrier of the banner — pinned
# above); the standalone pages carry none (task 03 dropped the last
# folded file, history.html).
for other in (DOCUMENT_HTML, LOGIN_HTML):
assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the stale banner is chat-page only"
)
def test_boot_load_reveals_stale_banner_on_payload_stale() -> None:
"""restoreSavedChatFromUrl: on a 200 payload with stale: true, the
#stale-banner is revealed (hidden removed) — the flag is
server-computed (task 03), the client never does staleness math.
The reveal rides the boot SUCCESS path (after the link + the
localStorage mirror), so a non-stale payload leaves the banner
hidden."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
assert "data.stale === true" in body, "the reveal branches on the payload's stale flag"
link_i = body.find("currentChatId = chatId")
reveal_i = body.find("staleBanner.hidden = false")
assert -1 < link_i < reveal_i, "the reveal runs on the success path, after the link"
assert "staleBanner.hidden = false" in body, "reveal = remove `hidden`"
def test_boot_load_stale_reveal_no_brain_record_guard() -> None:
"""The no-brain-record guard: a stale conversation with NO brain
record (user-only) is revealed TEXT-ONLY — the #stale-regenerate
button is removed BEFORE the reveal (retryLastTurn is never called
in that state)."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
guard_i = body.find('!conversation.some((m) => m.who === "brain")')
remove_i = body.find("staleRegenBtn.remove()")
reveal_i = body.find("staleBanner.hidden = false")
assert -1 < guard_i < remove_i < reveal_i, (
"the no-brain check removes the button before the banner is revealed"
)
def test_retry_last_turn_returns_the_turn_promise() -> None:
"""retryLastTurn RETURNS the runTurn promise (phase 53 task 05):
the Regenerate path awaits the turn's completion to know when to
persist. The phase-49 Retry click handler ignores the return value
— behavior-neutral for it (the redo-order pins in
test_frontend_feedback.py keep holding unchanged)."""
js = _js()
body = _fn(js, "retryLastTurn")
assert "return runTurn(text, { reask: true })" in body, (
"the redo promise is returned for the Regenerate await"
)
assert "void runTurn" not in body, "the fire-and-forget void is gone"
# The existing Retry click handler still ignores the return value.
append = _fn(js, "appendRetryButton")
assert "retryLastTurn(wrap)" in append, "the Retry click is unchanged (no await)"
def test_stale_regenerate_drives_retry_last_turn_and_awaits() -> None:
"""regenerateStaleChat: drives retryLastTurn on the LAST brain
bubble's rendered wrap (phase-49 targeting — retryLastTurn's own
`wrap !== lastBrainWrap` guard makes a stale click a no-op that
resolves nothing), AWAITs the returned turn promise, and persists
only when the turn completed WITHOUT the error banner (a
mid-stream error leaves the linked row untouched — stale stays
true). The double-click guard releases in the finally — never
stale (PLAN §7.4)."""
js = _js()
body = _fn(js, "regenerateStaleChat")
assert "staleRegenBtn.disabled = true" in body, "one regenerate at a time"
call_i = body.find("retryLastTurn(lastBrainWrap)")
await_i = body.find("await turn")
assert -1 < call_i < await_i, "call the redo on the last brain wrap, then await it"
err_i = body.find('banner.classList.contains("is-error")')
put_i = body.find('`/api/chats/${currentChatId}`')
assert -1 < await_i < err_i < put_i, (
"the error-banner check sits between the await and the persist"
)
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "staleRegenBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
def test_stale_regenerate_persists_the_linked_row() -> None:
"""The post-regenerate persist (the existing upsert path): linked →
PUT /api/chats/<id> (the server re-stamps sources_version → the row
is fresh); a 404 (the row was deleted from History meanwhile)
follows persistConversation's stale-link rule — unlink + recreate
(POST), and the recreate links the new id. Success hides the banner
AND announces the outcome in the #send-status live region; 403/5xx
→ the actionable error banner (the row stays as the turn left it);
network → the reachable? banner."""
js = _js()
body = _fn(js, "regenerateStaleChat")
assert "if (currentChatId)" in body
assert 'method: "PUT"' in body
assert 'fetch("/api/chats"' in body and 'method: "POST"' in body
put_idx = body.find('method: "PUT"')
post_idx = body.find('method: "POST"')
assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback"
assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)"
# The 404→recreate fallback: unlink, then POST again.
notfound_idx = body.find("res.status === 404")
assert notfound_idx != -1, "the PUT 404 must be handled"
fallback = body[notfound_idx:post_idx]
assert "currentChatId = null" in fallback, "the stale link is dropped"
# The recreate links the new row.
assert "res.status === 201" in body
assert "currentChatId = String(created.id)" in body
# Success: hide the banner, then announce in the live region.
hide_i = body.find("staleBanner.hidden = true")
ann_i = body.find('sendStatus.textContent = "Regenerated')
assert -1 < hide_i < ann_i, "hide the banner, then announce the outcome"
assert "the answer now reflects the current sources." in body, ("the live-region line")
# Failures raise an actionable banner (non-ok HTTP + network).
assert (
'showErrorBanner("Couldn\'t save the regenerated answer — is the app reachable?")' in body
)
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
def test_stale_regenerate_binding_and_element_queries() -> None:
"""The wiring: app.js queries #stale-banner + #stale-regenerate at
module scope and binds the click to regenerateStaleChat. The banner
only ever shows on the /?chat=<id> boot path (admin), so the
binding is inert otherwise."""
js = _js()
assert 'document.querySelector("#stale-banner")' in js
assert 'document.querySelector("#stale-regenerate")' in js
assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js
def test_stale_banner_cleared_on_new_chat_and_autosave() -> None:
"""Never-stale (PLAN §7.4): "New chat" replaces the conversation the
banner described (and unlinks it) — the banner hides; a successful
auto-save re-stamps the row to the current generation (task 03) —
the banner is done the moment the save succeeds (on the success
path only — after the !res.ok early return and the 201 link)."""
js = _js()
new_body = _fn(js, "startNewChat")
assert "staleBanner.hidden = true" in new_body, "New chat hides the banner"
save_body = _fn(js, "persistConversation")
notok_idx = save_body.find("if (!res.ok)")
two01_idx = save_body.find("res.status === 201")
hide_idx = save_body.find("staleBanner.hidden = true")
catch_idx = save_body.find("} catch {")
assert -1 < notok_idx < two01_idx < hide_idx < catch_idx, (
"the banner clears on the success path, after the 201 link, never on failure"
)
def test_stale_banner_css_is_the_kb_banner_family() -> None:
"""styles.css: the banner rides the .kb-banner family (the section
carries BOTH classes — the flex row + accent tokens come from
.kb-banner; .stale-banner adds the wrap so the pill can drop below
the text when the row must wrap), and the .stale-regenerate pill is
the EXACT brand-pill family of Save/Share (solid --brand, --bg text
5.2:1 AA, borderless, 999px, ≥44px, hover lightens the fill, 16px
redo glyph). The ≤640px block makes the pill a full-width row."""
html = _index()
tag = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
assert tag and "kb-banner" in tag.group(0) and "stale-banner" in tag.group(0), (
"the section carries both classes — the family comes from .kb-banner"
)
css = _css()
block = re.search(r"\.stale-regenerate \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .stale-regenerate"
body = block.group(1)
for prop in (
"display: inline-flex",
"min-height: 44px",
"margin-left: auto",
"border-radius: 999px",
"border: 0",
"background: var(--brand)",
"color: var(--bg)",
"font-weight: 700",
"cursor: pointer",
):
assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})"
hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css)
assert hover and "var(--brand-hover)" in hover.group(1), (
"hover lightens the brand fill (phase 92: --brand-hover)"
)
assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), (
"the redo glyph rides the 16px pill size"
)
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
assert ".stale-regenerate { margin-left: 0; width: 100%; }" in mobile.group(1), (
"at phone width the pill takes a full-width row"
)