**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/`.
1389 lines
72 KiB
Python
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"
|
|
)
|