"""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 55 task 05 (the action row, owner-locked A5): a single
``
`` 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 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 (...)`` (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/ 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)."""
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)"
)
assert "if (persisting) return;" in body, "an in-flight upsert skips the second call"
assert "persisting = true;" in body
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_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:]
init_i = boot.find("await initSharedHeader();")
admin_i = boot.find("isAdmin = await fetchIsAdmin();")
saved_i = boot.find("await restoreSavedChatFromUrl();")
local_i = boot.find("restoreConversation();")
assert -1 < init_i < admin_i < saved_i < local_i, (
"boot order: 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 — 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'", btn.start())]
assert '>Share' 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("", messages_idx)
composer_idx = html.find('