feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return

This commit is contained in:
2026-08-29 21:22:25 -04:00
parent 6832957ab0
commit ece93a7c8f
29 changed files with 3099 additions and 20 deletions
+24
View File
@@ -185,6 +185,30 @@ def test_empty_static_dir_is_dev(tmp_path) -> None:
assert asset_version(str(empty)) == "dev"
# ---------------------------------------------------------------------------
# HTML_PAGES registration
# ---------------------------------------------------------------------------
def test_html_pages_include_history() -> None:
"""Phase 50: the History page is registered in HTML_PAGES — without
this entry it would serve unversioned asset refs, which the
immutable-for-a-year asset caching would pin to stale CSS after a
deploy (the phase-35 git-sources lesson). The entry is ADDED —
every pre-phase-50 page stays registered."""
for path in (
"/",
"/index.html",
"/sources.html",
"/document.html",
"/login.html",
"/tuning.html",
"/git-sources.html",
"/history.html",
):
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
# ---------------------------------------------------------------------------
# rewrite_asset_refs (task 02)
# ---------------------------------------------------------------------------
+1
View File
@@ -28,6 +28,7 @@ HTML_PAGES = (
"document.html",
"login.html",
"git-sources.html",
"history.html", # phase 50: the admin saved-chats page
)
+428
View File
@@ -0,0 +1,428 @@
"""Unit: the phase-50 task-04 History-page contract.
The browser behavior itself is E2E-gated by the story suite (task 05);
like the other frontend-adjacent unit files, this module pins the
JS/CSS/HTML markers the History page depends on, so a silent
regression is caught without a browser:
* the anonymous no-fetch gate (the gate in, the table out, and the
single ``GET /api/chats`` fetch lives ONLY in ``loadChats`` —
unreachable from the anonymous branch);
* the inline two-step Delete (the "Delete? [Yes] [No]" pair, focus to
Yes, the row kept on No / a failed request, ``Deleted "<title>".``
on success) and the ``window.confirm`` absence in ``history.js``
(owner-locked 2026-08-29: no native confirm dialog on this page);
* the ``/?chat=<id>`` Open-link href shape (TODO.md L5 — "return to
that history with a click");
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
+ ``header.js``'s reveal-for-admin block;
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
the empty-state row.
The Containerfile stage-1 coverage (history.html copied, history.js
bundled) is pinned dynamically by
``tests/integration/test_containerfile_assets.py`` — a page or module
missing from stage 1 fails there.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
TUNING_HTML = FRONTEND / "tuning.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
HISTORY_HTML = FRONTEND / "history.html"
HISTORY_JS = ASSETS / "history.js"
HEADER_JS = ASSETS / "header.js"
STYLES_CSS = ASSETS / "styles.css"
#: The phase-34 one-bar contract + the new History page: SEVEN pages.
ALL_PAGES = (
INDEX_HTML,
SOURCES_HTML,
GIT_SOURCES_HTML,
TUNING_HTML,
DOCUMENT_HTML,
LOGIN_HTML,
HISTORY_HTML,
)
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _js() -> str:
return _text(HISTORY_JS)
def _css() -> str:
return _text(STYLES_CSS)
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 history.js"
return js[start : js.find("\n}\n", start) + 4]
def _nav_history_tag(html: str) -> str:
tag = re.search(r'<a[^>]*id="nav-history"[^>]*>', html)
assert tag, "the #nav-history link is missing"
return tag.group(0)
# ---------- the #nav-history link: all seven pages ----------
def test_nav_history_present_on_all_seven_pages() -> None:
"""The phase-34 one-bar contract extended by phase 50: the admin-only
History link SHIPS hidden (revealed by header.js for admin) on every
page, after the Tuning link, pointing at /history.html. The page's
own link is the active one (is-active + aria-current)."""
for html in ALL_PAGES:
text = _text(html)
tag = _nav_history_tag(text)
assert 'href="/history.html"' in tag
assert "hidden" in tag, f"{html.name}: #nav-history must ship hidden"
# Placed after the Tuning link (the owner-locked position).
assert text.find('id="nav-tuning"') < text.find('id="nav-history"'), (
f"{html.name}: #nav-history must follow #nav-tuning"
)
# The history page is the only one whose link is active.
for html in ALL_PAGES:
tag = _nav_history_tag(_text(html))
if html.name == "history.html":
assert 'class="nav-link is-active"' in tag
assert 'aria-current="page"' in tag
else:
assert "is-active" not in tag, (
f"{html.name}: no nav link is current there"
)
def test_nav_history_count_is_exactly_seven_pages() -> None:
"""The pin counting occurrences across ``frontend/*.html`` — exactly
one ``id="nav-history"`` per page, seven pages, no duplicates and no
extra page that forgot (or added twice)."""
total = 0
for html in sorted(FRONTEND.glob("*.html")):
count = html.read_text(encoding="utf-8").count('id="nav-history"')
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
total += count
assert total == 7, f"expected #nav-history on 7 pages, found {total}"
def test_header_js_reveals_nav_history_for_admin() -> None:
"""header.js reveals #nav-history for admin exactly like
#nav-tuning — the same ship-hidden / reveal-for-admin contract,
inside initSharedHeader (null-safe: a page without the link is a
no-op)."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert 'querySelector("#nav-history")' in body
assert "navHistory.hidden = !admin" in body
# ---------- history.html: the page scaffold ----------
def test_history_page_scaffold_and_landmarks() -> None:
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
shared header, the steering panel + announcer (phase 34 — ships on
every page), the page-head, the gate (ship-hidden), the
role="status" live region, and the table inside the
.table-wrap card. Footer with the version span (the index.html
shape)."""
html = _text(HISTORY_HTML)
assert '<a class="skip-link" href="#main">' in html
assert 'class="app-header"' in html
assert 'nav class="app-nav" id="app-nav" aria-label="Primary"' in html
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
assert '<h1>Saved chats</h1>' in html
# The anonymous gate — the #sources-gate pattern, ship-hidden.
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', html)
assert gate and "hidden" in gate.group(0), "#history-gate must ship hidden"
assert 'href="/login.html?next=/history.html"' in html, (
"the gate's Sign in returns to the History page (no-JS fallback)"
)
# The action-feedback live region.
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', html)
# The table wrapper: the .table-wrap card (scrollable) with its
# own id, a labeled region, focusable.
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', html)
assert wrap, "the table must live in the .table-wrap card"
assert 'id="history-table-wrap"' in wrap.group(0)
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
# Footer with the version span.
assert 'class="footer-version" id="app-version"' in html
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the four columns —
Title | Messages | Updated | Actions (the Actions header text is
visually-hidden — the row buttons carry their own aria-labels) —
and the empty-state row (ship-hidden, the exact copy)."""
html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>'):
assert col in html
actions_th = re.search(
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
html,
)
assert actions_th, "the Actions column header must be visually-hidden text"
assert actions_th.group(1) == "", "no visible text beside the hidden header"
# The empty-state row: ship-hidden, colspan 4, the exact copy.
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
assert row, "the empty-state row must ship in the skeleton"
assert "hidden" in row.group(0)
assert 'id="history-empty-row"' in row.group(0)
assert "<td colspan=\"4\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
) in html
def test_history_page_scripts_and_no_cdn() -> None:
"""Script load order (the house pattern): brand.js classic FIRST,
the history.js module second, NO direct header.js <script> tag
(single-evaluation design — history.js imports it relatively).
No-CDN rule (AGENTS.md rule 6): no external script/link tags."""
html = _text(HISTORY_HTML)
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert srcs == ["assets/brand.js", "/assets/history.js"], (
f"history.html must load brand.js (classic, first) + the history.js "
f"module, got {srcs}"
)
js = _js()
assert 'from "./header.js"' in js, (
"history.js must import the shared header module relatively"
)
assert '"/assets/header.js"' not in js
assert 'src="http' not in html and 'href="http' not in html, (
"no CDN: every asset is local (AGENTS.md rule 6)"
)
# ---------- the anonymous no-fetch gate ----------
def test_anonymous_boot_makes_no_chats_request() -> None:
"""The whoami gate in the boot IIFE: ``initSharedHeader()`` first
(shared-header contract), then the anonymous branch hides the
table, shows the gate, and RETURNS — no ``/api/chats`` request on
the wire (the router 403s anonymous; the story E2E pins the
request log). Only the admin path reaches ``loadChats()``. The
single ``fetch("/api/chats")`` in the file lives in loadChats."""
js = _js()
assert js.count('fetch("/api/chats")') == 1, (
"exactly ONE list fetch — the anonymous path must never add one"
)
load = _fn(js, "loadChats")
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
boot = js[js.find("(async () => {"):]
assert boot, "the boot IIFE must exist"
assert "await initSharedHeader()" in boot
gate_i = boot.find("if (!(await fetchIsAdmin()))")
assert gate_i != -1, "the whoami gate must run in boot"
# The anonymous branch: gate in, table out, then a bare return —
# and NO fetch call anywhere inside it.
branch = boot[gate_i : boot.find("return;", gate_i)]
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
assert "tableWrap.hidden = true" in branch
assert "gateEl.hidden = false" in branch
# The admin path: the gate hides, then the list loads.
after = boot[boot.find("return;", gate_i):]
assert "gateEl.hidden = true" in after
assert "loadChats();" in after
def test_admin_load_renders_rows_or_empty_state() -> None:
"""loadChats: a 0-row fetch (and non-2xx / a network failure)
reveals the empty-state row; a populated fetch renders one row per
chat, in the server's order (latest activity first)."""
js = _js()
load = _fn(js, "loadChats")
# Every no-data outcome lands on the empty state.
assert load.count("showEmptyState()") == 3, (
"network failure, non-2xx and a 0-row list all show the empty state"
)
assert "chats.length" in load
assert "makeRow(chat)" in load
empty = _fn(js, "showEmptyState")
assert "emptyRow.hidden = false" in empty
# ---------- the /?chat=<id> Open link ----------
def test_open_link_is_the_title_with_chat_href() -> None:
"""makeRow: the Title cell is the Open link — ``/?chat=<id>``
("return to that history with a click", TODO.md L5) — rendered
through textContent (the auto-title is user-derived; never
innerHTML). The Updated cell carries the locale date+time with the
full ISO in the title attribute; Messages is the message_count."""
js = _js()
row = _fn(js, "makeRow")
assert 'link.href = "/?chat=" + chat.id' in row, (
"the Open link returns to /?chat=<id> (task 03's boot load)"
)
assert 'link.className = "history-title-link"' in row
assert "link.textContent = chat.title" in row, "XSS contract: textContent only"
assert 'innerHTML' not in row, "makeRow must never build HTML"
assert "String(chat.message_count)" in row
assert "updatedTd.title = chat.updated_at" in row, "full ISO on hover"
assert "fmtDate(chat.updated_at)" in row
assert "link.title" in row or "titleTd.title = chat.title" in row
# ---------- the inline two-step Delete ----------
def test_two_step_delete_confirm_pair() -> None:
"""makeDeleteControl: the first click swaps the Delete button for
the "Delete? [Yes] [No]" pair IN PLACE (keyboard-reachable — focus
moves to Yes); No restores the Delete button (focus returns); the
Delete button carries a labeled aria-name."""
js = _js()
# Owner-locked 2026-08-29: no native confirm dialog in the file.
assert "window.confirm" not in js, "history.js must use the inline two-step only"
fn = _fn(js, "makeDeleteControl")
assert 'del.className = "history-delete"' in fn
assert 'del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`)' in fn
assert 'label.textContent = "Delete?"' in fn
assert 'yes.className = "history-confirm-yes"' in fn
assert 'no.className = "history-confirm-no"' in fn
# The shipped state of the actions cell IS the Delete button
# (before any click) — a cell that only gains the button on
# restore would render an empty Actions column.
append = fn.find("cell.appendChild(del)")
ret = fn.rfind("return cell")
assert -1 < append < ret, "the Delete button is appended before the return"
# The swap + the focus handoff.
assert "cell.replaceChildren(label, yes, no)" in fn
yes_swap = fn.find("cell.replaceChildren(label, yes, no)")
assert fn.find("yes.focus()", yes_swap) > 0, "focus moves to Yes after the swap"
# No (and the restore helper) bring the Delete button back, focused.
restore_start = fn.find("function restoreDelete")
restore_end = fn.find("\n }", restore_start)
restore = fn[restore_start:restore_end]
assert "cell.replaceChildren(del)" in restore
assert "del.focus()" in restore
assert 'no.addEventListener("click", restoreDelete)' in fn
def test_confirmed_delete_outcomes() -> None:
"""confirmDelete: double-fire guarded; 2xx → the row is removed +
the empty-state row reappears when it was the last + the live
region `Deleted "<title>".`; a 404 (already gone) drops the stale
row and says so; any other failure / a network error KEEPS the row
(restore) and lands the error line."""
js = _js()
fn = _fn(js, "confirmDelete")
assert "yesBtn.disabled = true" in fn
assert "fetch(`/api/chats/${chat.id}`, { method: \"DELETE\" })" in fn
# Success: remove + empty-state check + the exact live-region line.
assert "row.remove()" in fn
assert "showEmptyIfLast()" in fn
assert 'announce(`Deleted "${chat.title}".`)' in fn
# 404: the row is stale — drop it, no restore. (The branch slices
# stop at the NEXT branch boundary — a template-literal `}` inside
# an announce line must not end the slice early.)
nf = fn.find("r.status === 404")
assert nf != -1, "the 404 branch must be handled"
notok = fn.find("if (!r.ok)")
nf_branch = fn[nf:notok]
assert "row.remove()" in nf_branch
assert "already deleted" in nf_branch
assert "restoreDelete()" not in nf_branch
# !ok (non-404) and network: the row stays, the button is
# retryable, and the error line lands.
# !ok (non-404) and network: the row stays, the button is
# retryable, and the error line lands. (The try/catch wraps the
# FETCH, so it precedes the status branches; the !ok slice runs to
# the function's close — the success tail after it carries neither
# a restore nor that line.)
assert notok != -1
notok_branch = fn[notok:]
assert "restoreDelete()" in notok_branch
assert "try again" in notok_branch
# The network-error catch: the reachable? line + the restore (the
# catch wraps the fetch, so it precedes the status branches).
catch_i = fn.find("} catch {")
assert catch_i != -1
catch_branch = fn[catch_i : fn.find("if (r.status === 404)")]
assert "is the app reachable?" in catch_branch
assert "restoreDelete()" in catch_branch
# The empty-state row reappears exactly when the last data row is
# gone (the hidden empty row itself ships in the tbody).
empty = _fn(js, "showEmptyIfLast")
assert "querySelectorAll(\"tr\").length > 1" in empty
# ---------- the table CSS ----------
def test_history_table_css_full_width_and_palette() -> None:
"""styles.css: .history-table is the full-width sources-table family
(width 100%, --line borders, the brand-soft thead, row hover); the
title link is the accent link (brand-ink, focus-visible); the
confirm pair is Yes-on-error-rose + No-ghost; the empty-state row
is the muted centered message. Every pair is Phase-08 AA
(brand-ink/brand-soft 6.9:1, err 9.1:1, ink-soft >=6.9:1)."""
css = _css()
block = re.search(r"\.history-table \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .history-table"
body = block.group(1)
assert "width: 100%" in body, "the table is FULL-WIDTH (AGENTS.md rule 5)"
assert "min-width: 640px" in body
th = re.search(r"\.history-table th \{([\s\S]*?)\n\}", css)
assert th and "var(--brand-soft)" in th.group(1) and "var(--brand-ink)" in th.group(1)
hover = re.search(r"\.history-table tbody tr:hover \{([^}]*)\}", css)
assert hover, "row hover is part of the table family"
link = re.search(r"\.history-title-link \{([\s\S]*?)\n\}", css)
assert link and "var(--brand-ink)" in link.group(1), "the Open link is the accent link"
assert re.search(r"\.history-title-link:focus-visible \{[^}]*outline[^}]*3px", css), (
"the Open link keeps a :focus-visible outline"
)
yes = re.search(r"\.history-confirm-yes \{([\s\S]*?)\n\}", css)
assert yes, "the confirm Yes button must be styled"
ybody = yes.group(1)
assert "var(--err-bg)" in ybody and "var(--err-ink)" in ybody and "var(--err-line)" in ybody
no = re.search(r"\.history-confirm-no \{([\s\S]*?)\n\}", css)
assert no and "background: transparent" in no.group(1), "No is the ghost"
empty = re.search(r"\.history-empty-row td \{([\s\S]*?)\n\}", css)
assert empty, "the empty-state row must be styled"
ebody = empty.group(1)
assert "text-align: center" in ebody and "var(--ink-soft)" in ebody
def test_history_table_mobile_behavior() -> None:
"""≤640px (the phase-07 responsive contract): the table keeps its
full width (the .table-wrap's horizontal scroll already covers
it) and the actions cell wraps so the two-step confirm pair fits
the phone width."""
css = _css()
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
mbody = mobile.group(1)
assert ".history-actions-cell { white-space: normal; }" in mbody
assert ".history-actions { flex-wrap: wrap; }" in mbody
+293
View File
@@ -0,0 +1,293 @@
"""Unit: the phase-50 task-03 save-chat contract on the chat page.
The browser behavior itself is E2E-gated by the story suite (task 05);
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:
* the ``currentChatId`` lifecycle (set on create/open, cleared by New
chat and by the 404-PUT fallback);
* the upsert branch (PUT when linked, POST when not, the 404→recreate
fallback, the live-region feedback strings);
* 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 ship-hidden / reveal-for-admin gate on ``#save-chat-btn``.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
TUNING_HTML = FRONTEND / "tuning.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_button_ships_hidden_beside_new_chat() -> None:
"""#save-chat-btn: a real type=button with the accessible name
"Save chat", SHIPPED HIDDEN (app.js reveals it for admin only),
beside #new-chat-btn in .chat-shell inside <main>, above
#messages — the two chat-shell actions read as a pair. No other
page carries it (chat-page only, like New chat)."""
html = _index()
btn = re.search(r'<button[^>]*id="save-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #save-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="Save chat"' in tag
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
# Beside New chat: after it, still inside .chat-shell, above #messages.
main_idx = html.find('main id="main"')
shell_idx = html.find('class="container chat-shell"')
new_idx = html.find('id="new-chat-btn"')
messages_idx = html.find('id="messages"')
assert -1 < main_idx < shell_idx < new_idx < btn.start() < messages_idx, (
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
)
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
assert 'id="save-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the Save button is chat-page only"
)
def test_save_button_css_is_the_exact_new_chat_family() -> None:
"""styles.css: .save-chat-btn carries the EXACT visual family of
.new-chat-btn — 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; icon-only elsewhere)."""
css = _css()
block = re.search(r"\.save-chat-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .save-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 New chat"
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
hover = re.search(r"\.save-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
svg = re.search(r"\.save-chat-btn svg \{([\s\S]*?)\n\}", css)
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like New chat)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
assert ".save-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, "squeezes with New chat"
assert ".save-chat-label { display: none; }" in mbody
assert ".save-chat-btn svg { display: block; }" in mbody
assert ".chat-shell .save-chat-label { display: inline; }" in mbody, (
"in .chat-shell the label stays visible, as for New chat"
)
assert ".chat-shell .save-chat-btn svg { display: none; }" in mbody
# ---------- 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 Save (201), set to the opened id on a
successful boot load, cleared by "New chat" AND by the 404-PUT
fallback (a stale link must never leave the conversation unsaved)."""
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, "saveCurrentChat")
assert "res.status === 201" in save_body
assert "currentChatId = String(created.id)" in save_body, (
"a fresh 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
# 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 upsert branch ----------
def test_save_upsert_put_when_linked_post_when_not() -> None:
"""saveCurrentChat: linked → PUT /api/chats/<id> with the messages
payload (re-Save updates the SAME row — no title in the body, so the
row keeps its current one); unlinked → POST /api/chats (the server
auto-titles). The 404 from the PUT unlinks and retries as a create.
Empty conversation → no request, live-region "Nothing to save
yet."; success → live-region "Conversation saved." (status text
only, no banner); 403/5xx/network → the error banner."""
js = _js()
body = _fn(js, "saveCurrentChat")
# No-op first: nothing to save → live-region line, no fetch.
noop = body.find('sendStatus.textContent = "Nothing to save 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: 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"
# Success is status text only — the live region, never stale — and
# nothing between the 201 link and the success line may raise a
# banner (the !res.ok branch returns before either).
assert body.count('sendStatus.textContent = "Conversation saved."') == 1
saved_line = 'sendStatus.textContent = "Conversation saved."'
between = body[body.find("res.status === 201") : body.find(saved_line)]
assert "showErrorBanner" not in between, "no banner on the success path"
# Failures raise an actionable banner (non-ok HTTP + network).
assert 'showErrorBanner("Couldn\'t save the conversation — is the app reachable?")' in body
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
# The double-click guard releases on EVERY outcome.
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "saveBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
# ---------- boot-load precedence ----------
def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
"""Inside the boot IIFE: after fetchIsAdmin() + the reveal gate,
restoreSavedChatFromUrl() runs; only when it returns false does the
phase-14 local restore run. Header init stays first (shared-module
contract)."""
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();")
reveal_i = boot.find("saveBtn.hidden = !isAdmin")
saved_i = boot.find("await restoreSavedChatFromUrl();")
local_i = boot.find("restoreConversation();")
assert -1 < init_i < admin_i < reveal_i < saved_i < local_i, (
"boot order: header init → whoami → Save reveal → ?chat= load → local fallback"
)
assert "if (!openedSaved) restoreConversation();" in boot, (
"the local restore runs ONLY when the saved-chat load did not open"
)
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_save_button_revealed_only_for_admin() -> None:
"""The ship-hidden/reveal-for-admin contract: app.js queries
#save-chat-btn, binds the click to saveCurrentChat, and the boot
IIFE sets saveBtn.hidden = !isAdmin (phase 16 absent-not-hidden —
hidden is display:none, no trace for anonymous)."""
js = _js()
assert 'document.querySelector("#save-chat-btn")' in js
assert 'saveBtn?.addEventListener("click", saveCurrentChat)' in js
assert "saveBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
# The reveal happens in the boot IIFE (after whoami), not at module
# evaluation (isAdmin is false there).
boot_start = js.find("(async () => {")
reveal = js.find("saveBtn.hidden = !isAdmin")
assert boot_start < reveal, "the reveal must run at boot, after whoami resolves"
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
+3 -2
View File
@@ -22,8 +22,8 @@ ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
#: All six pages carry the shared header block (phase 34's five pages +
#: phase 35's git-sources page).
#: All seven pages carry the shared header block (phase 34's five pages
#: + phase 35's git-sources page + phase 50's History page).
PAGES = (
FRONTEND / "index.html",
FRONTEND / "sources.html",
@@ -31,6 +31,7 @@ PAGES = (
FRONTEND / "git-sources.html",
FRONTEND / "login.html",
FRONTEND / "tuning.html",
FRONTEND / "history.html",
)