Files
brain-of-reese/tests/unit/test_history_page.py
T
ducoterra ffa919b8bf fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the
five navbar views (Chat, RAG, Sources, Tuning, History) were separate
HTML documents, so a navbar click was a REAL cross-document navigation
— the chat page unloaded, the in-flight SSE fetch was aborted, and the
phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled")
stopped the model. Observed: send question -> click RAG mid-stream ->
click Chat -> the answer never finished: no `query_log` row, and on
return a dangling question with no brain record (the pre-token pagehide
partial persist skips because `acc` is empty).

Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06,
flagged per AGENTS.md rule 3, not silently deviated): "real navigation
cancels the fetch" now means LEAVING THE APP — tab close,
external/other-document navigation, the Stop button. In-app navbar
switches are client-side view switches and no longer cancel.

Fix — Option A (SPA shell), chosen over B (Service Worker owns the
stream) and C (server-side turn registry + resume):
- frontend/index.html is the shell: ONE `<main id="main">` holds the
  five `<section class="view">` blocks; hidden views carry BOTH
  `hidden` and `inert` (WCAG — no focus/keyboard traversal). The
  shared header, the single `doc-modal-*` skeleton, and the
  `#app-version` footer each exist exactly once; the per-view copies
  from the four folded pages are dropped.
- New frontend/assets/router.js (vanilla module — no framework, no
  bundler, No-CDN rule intact): lazy-imports a view module on FIRST
  show only (mount-once, hide-forever — the chat view's in-flight SSE
  reader persists across switches; that persistence IS the fix);
  intercepts same-shell navbar links with preventDefault +
  history.pushState (never a document load); handles popstate; single
  writer of `.nav-link` active state (is-active + aria-current),
  document.title, and the per-view meta description (values carried
  over from the old pages' heads, brand-resolved at write time).
- Each folded page's JS becomes `export async function mount(root)` —
  root-scoped queries; `initSharedHeader()` dropped (the header boots
  once in the shell via the chat module; the admin flag comes from the
  same cached `fetchIsAdmin()` promise — zero extra requests).
- app/main.py: a small list-driven route factory serves the shell for
  /tuning.html, /sources.html, /git-sources.html, /history.html —
  registered AFTER the API routers and BEFORE the static catch-all
  (routes-first). The phase-33 caching middleware applies no-cache +
  `?v=` rewriting unchanged; app/core/caching.py needed NO change
  (the view paths did not change — pinned by the integration tests).
- The four old view .html files are DELETED (one source of truth);
  deep links to the old URLs keep working (the router picks the view
  from the pathname); `/?chat=<id>` is unaffected; the Containerfile
  bundles router.js (inlining the lazy view modules) and drops the
  folded page files.
- app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell
  keeps long saved answers in the chat, and the old cap (stricter than
  the 24_000-char total history budget) 422-rejected any second turn
  in such a chat (found by the phase-42 E2E suite on the shell).

Boundaries: login.html, shared.html, doc-edit.html, document.html
REMAIN separate documents (flow pages, not navbar tabs); a mid-stream
navigation to doc-edit/document.html still cancels per phase 48
(follow-up candidate, out of scope). The SSE API is unchanged. Real
departures still cancel the turn — phase 48 intact (pinned by
tests/e2e/test_stop_generation.py, unchanged, and by the new suite's
real-departure control).

Tests:
- Phase-20 suite REWRITTEN to the new semantics
  (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer
  cancels — the stream survives the switch and the FULL answer
  settles; the pagehide partial persist REMAINS for real departures
  (the partial's exact shape — first streamed chunk prefix, no done
  metadata — is still pinned there).
- NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock
  LLM): the owner repro (send -> RAG mid-stream -> Chat: window
  sentinel survives = same document, FULL answer, exactly one brain
  turn in bor.chat.v1, exactly one settled query_log row, auto-saved
  row matches) + the same mid-stream switch against the other three
  views + the real-departure-still-cancels control + the no-switch
  baseline.
- tests/unit/test_frontend_router.py: source-level pins of the router
  invariants (click interceptor targets ONLY same-shell view paths,
  pushState-only switches, mount-once guard, hidden+inert pair,
  single-writer active state/title); shell-route integration tests
  (each folded path serves the shell with no-cache + `?v=` body; a
  non-view path still 404s); the file-reading unit pins re-pointed at
  the shell (the four view files are gone — the shell is the source
  of truth).

Verification (this commit): full suite green — 1565 unit+integration
tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the
phase's E2E suites green in isolation (house protocol, AGENTS.md rule
9). Owner repro verified in a real browser against the real LLM
(dev server :8010, headful Chromium): "tell me about everquest" ->
RAG mid-stream -> Chat — the answer completed with one brain bubble
and no error banner, `query_log` gained exactly one settled row
(deflected=True: the dev KB holds no EverQuest docs — the settle, not
the topic, is the proof), zero "chat: turn cancelled" lines for that
turn; the control (real navigation to /shared.html mid-stream) still
cancelled (no settled row, the cancel line logged, the partial
persisted on return). Screenshots: .agents/screenshots/76_manual_*.

Phase 76 (76_spa_nav_shell) complete — moved to
.agents/phases/complete/.
2026-09-06 06:31:31 -04:00

777 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit: the phase-50 task-04 History contract.
Phase 76 (task 03): the History view is a view of the shell —
``frontend/history.html`` is deleted, its content lives in the shell's
``#view-history`` section, and ``history.js`` is a ``mount(root)`` view
module the router lazy-imports. This module pins the JS/CSS/HTML
markers the view depends on accordingly (the JS pins against
``history.js`` — brace-balanced slices, since phase 76 task 03 the
functions live inside ``mount(root)`` — the HTML pins against the
shell, scoped to the view where view-scoped); the browser behavior
itself is E2E-gated by the story suite (task 05), and 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 surviving pages (the phase-34 one-bar
contract — phase 76: the four folded navbar-view files are gone
(tasks 01–03), so the page list is the shell + the surviving
documents) + ``header.js``'s reveal-for-admin block;
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
the empty-state row;
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
between Updated and Share in the shell's History view, and the
``.stale-pill`` rose-family CSS in ``styles.css``.
The Containerfile stage-1 coverage (the shell copied, the view modules
bundled into the router) 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"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
SHARED_HTML = FRONTEND / "shared.html" # phase 51: the anonymous shared page
HISTORY_JS = ASSETS / "history.js"
HEADER_JS = ASSETS / "header.js"
STYLES_CSS = ASSETS / "styles.css"
#: The phase-34 one-bar contract + the shared page.
#: Phase 76 (task 01): the post-shell set — TUNING_HTML dropped (the
#: Tuning view is folded into the shell; its file is deleted).
#: Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped (both
#: views folded into the shell; both files deleted).
#: Phase 76 (task 03): HISTORY_HTML dropped (the History view is
#: folded into the shell; its file is deleted) — the shell + the
#: surviving documents.
ALL_PAGES = (
INDEX_HTML,
DOCUMENT_HTML,
LOGIN_HTML,
SHARED_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 _shell() -> str:
return _text(INDEX_HTML)
def _view(html: str) -> str:
"""The shell's History view section — from the #view-history open
tag to the container main's close (the view is the shell's LAST
view section, so the slice ends at the first ``</main>`` after
it)."""
start = html.find('<section class="view" id="view-history"')
assert start != -1, "the #view-history section must be in the shell"
end = html.find("</main>", start)
assert end != -1, "the container main must close after the view"
return html[start:end]
def _fn(js: str, name: str) -> str:
"""The source of the first ``function <name>`` in history.js (brace
balanced — since phase 76 task 03 the functions live inside
mount(root), so the closing brace is indented, not line-leading;
the house pin pattern from tests/unit/test_frontend_sync_upload.py).
"""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must be defined in history.js"
open_idx = js.find("{", fn)
depth = 0
for i in range(open_idx, len(js)):
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[fn : i + 1]
raise AssertionError(f"unbalanced braces in {name}")
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 surviving pages ----------
def test_nav_history_present_on_all_surviving_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
standalone page (the shell carries it once for its views — phase 76),
after the Tuning link, pointing at /history.html. NO page's link is
statically stamped active: in the shell the router is the SINGLE
WRITER of the active state (client-side, per view — the old
history.html page-level stamp is gone with the file), and the
surviving documents keep their pre-shell no-stamp state."""
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"
)
assert "is-active" not in tag, (
f"{html.name}: no statically-current nav link (in the shell the "
"active state is the router's single-writer job)"
)
assert 'aria-current="page"' not in tag
def test_nav_history_count_is_exactly_four_pages() -> None:
"""The pin counting occurrences across ``frontend/*.html`` — exactly
one ``id="nav-history"`` per page, FOUR pages (phase 51: + the shared
page; phase 76: the four folded navbar-view files are gone — task 01
− Tuning, task 02 − RAG + Sources, task 03 − History — the shell's
ONE link covers all its views), 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 == 4, f"expected #nav-history on 4 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
# ---------- the shell's History view: the scaffold ----------
def test_history_view_scaffold_and_landmarks() -> None:
"""The shell's History view (formerly history.html — phase 76 task
03): the shell's standard landmarks (skip link, the shared header,
the steering panel + announcer — the shell's ONE header-owned pair,
the view's copies dropped with the move) + the view section
(hidden AND inert + focusable — the WCAG pair, AGENTS.md rule 5),
the page-head, the gate (ship-hidden), the role="status" live
region, the table inside the .table-wrap card, and the shell's ONE
footer with the version span (the history page's footer copy is
dropped — no duplicate #app-version)."""
html = _shell()
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
assert html.count('id="steering-panel"') == 1, (
"the shell carries its ONE steering panel (the view's copy is dropped)"
)
assert html.count('id="steering-announcer"') == 1
assert html.count('<main id="main" class="app-main" tabindex="-1">') == 1
# The view section: hidden AND inert (the WCAG pair) + focusable.
view = re.search(r'<section[^>]*id="view-history"[^>]*>', html)
assert view, "the #view-history section must be in the shell"
view_tag = view.group(0)
assert "hidden" in view_tag and "inert" in view_tag, (
"the folded view ships hidden AND inert"
)
assert 'tabindex="-1"' in view_tag, "the target view is focusable"
body = _view(html)
assert '<h1>Saved chats</h1>' in body
# The anonymous gate — the #sources-gate pattern, ship-hidden.
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', body)
assert gate and "hidden" in gate.group(0), "#history-gate must ship hidden"
assert 'href="/login.html?next=/history.html"' in body, (
"the gate's Sign in returns to the History view (no-JS fallback)"
)
# The action-feedback live region.
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', body)
# 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"[^>]*>', body)
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)
# The shell keeps its ONE footer with the version span (the history
# page's footer copy is dropped — no duplicate #app-version).
assert 'class="footer-version" id="app-version"' in html
assert html.count('id="app-version"') == 1
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the six columns —
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
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). Phase 76 (task 03): scoped to the
shell's History view section."""
html = _view(_shell())
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>', '<th scope="col">Stale</th>',
'<th scope="col">Share</th>'):
assert col in html
# The Stale column (phase 53) sits BETWEEN Updated and Share —
# i.e. between Updated and Actions — so the phase-51 contract
# (Share between Updated and Actions) still holds.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Stale</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Stale column must sit between Updated and Share"
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 6 (phase 51 added
# Share, phase 53 added Stale), 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=\"6\">" in html
# Phase 66 (owner-locked A3, 2026-09-01): the auto-save copy —
# the Save button is retired (phase 55), every conversation
# saves itself; the old "press Save" line is gone.
assert (
"No saved chats yet — start a conversation and it will be saved automatically."
) in html
def test_shell_scripts_and_no_cdn() -> None:
"""Shell script load order (the house pattern): brand.js classic
FIRST, the app.js (chat) + router.js modules, NO direct history.js
<script> tag (single-evaluation design — the router lazy-imports
the view module on first show; in the image the Containerfile's
esbuild stage inlines it into the router bundle). history.js keeps
its relative shared-header import (it uses the cached whoami
promise). No-CDN rule (AGENTS.md rule 6): no external
script/link tags."""
html = _shell()
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert srcs[0] == "assets/brand.js", "brand.js (classic) must load first"
assert "/assets/app.js" in srcs, "the shell loads the chat module"
assert "/assets/router.js" in srcs, "the shell loads the router module"
assert [s for s in srcs if "history.js" in s] == [], (
"no direct history.js tag — the router lazy-imports the view module"
)
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 ``mount(root)`` (phase 76 task 03 — the shell
view module form): the view module does NOT boot the shared header
(the shell's header boots exactly once, via the chat module
(app.js) at shell boot — no call site, and the import carries ONLY
the cached whoami promise), so the anonymous branch gates on
``fetchIsAdmin()`` alone (the SAME cached /api/whoami request —
zero extra), 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"
# Phase 76 (task 03): the view module never boots the shell's
# header — no CALL to the header boot (a docstring may name it;
# a call may not) and no initSharedHeader in the import — the
# shell's header boots via the chat module (app.js) at shell boot.
assert "await initSharedHeader()" not in js, (
"the view must not re-boot the shell's header"
)
import_lines = [line for line in js.splitlines() if line.strip().startswith("import")]
assert all("initSharedHeader" not in line for line in import_lines), (
"the view must not import the header boot — the shell's header boots "
"via the chat module (app.js) at shell boot"
)
assert 'import { fetchIsAdmin } from "./header.js";' in js, (
"the view imports ONLY the shared cached whoami promise"
)
mount_i = js.find("export async function mount(root)")
assert mount_i != -1, "mount(root) must be the module's entry"
gate_i = js.find("if (!(await fetchIsAdmin()))")
assert gate_i > mount_i, "the whoami gate must run in mount"
# The anonymous branch: gate in, table out, then a bare return —
# and NO fetch call anywhere inside it.
branch = js[gate_i : js.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 = js[js.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.
# (Brace-balanced — since phase 76 task 03 the helper lives inside
# mount(root), so a line-leading-brace slice would not find it.)
restore = _fn(js, "restoreDelete")
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
# ---------- the Share column (phase 51, owner-locked 2026-08-29) ----------
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
"""makeRow: the Share <td> (with the share control) lands BETWEEN
the Updated cell and the Actions cell — the column order in
history.html is Title | Messages | Updated | Stale (phase 53) |
Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
stale_i = row.find('staleTd.className = "history-stale-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < stale_i < share_i < actions_i, (
"the stale cell (phase 53) must sit between Updated and Share —"
" i.e. the share cell must still sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == [
"titleTd", "countTd", "updatedTd", "staleTd", "shareTd", "actionsTd",
], f"row cell order must be title/count/updated/stale/share/actions, got {seq}"
# ---------- the Stale column (phase 53, task 04) ----------
def test_stale_cell_branches_on_row_flag_with_aria_label() -> None:
"""makeRow (phase 53 task 04): the Stale cell renders from the
row's ``stale`` flag — the SERVER computes staleness (task 03),
the client never does version math. Stale rows: the rose
``.stale-pill`` (the ``Stale`` text + the EXACT hover copy pointing
at the Regenerate action on the chat page, task 05). Fresh rows:
a plain em-dash (no pill). The <td> carries its own aria-label in
BOTH states (WCAG 2.1 AA — the marker must be conveyed without the
visual). READ-ONLY badge: the cell binds no events and creates no
controls (the Regenerate button lives on the chat-page banner);
textContent only (XSS contract)."""
js = _js()
row = _fn(js, "makeRow")
assert 'staleTd.className = "history-stale-cell"' in row
assert "tr.appendChild(staleTd)" in row
assert "if (chat.stale)" in row, "the cell branches on the row's stale flag"
branch = row[row.find("if (chat.stale)") : row.find("tr.appendChild(staleTd)")]
# The stale branch: the rose pill with the exact hover copy.
assert 'pill.className = "stale-pill"' in branch
assert 'pill.textContent = "Stale"' in branch
assert (
'pill.title = "Sources have changed since this chat was saved'
" — open the chat to Regenerate\";"
) in branch, "the pill's hover copy points at the Regenerate action (task 05)"
# The fresh branch: the plain em-dash, never the pill.
else_i = branch.find("} else {")
assert else_i != -1, "the fresh branch must exist"
fresh = branch[else_i:]
assert 'staleTd.textContent = "—"' in fresh, "fresh rows render the em-dash"
assert "stale-pill" not in fresh, "fresh rows render the em-dash, not the pill"
# The <td> aria-label ships in BOTH states (conveyed without the
# visual — WCAG 2.1 AA).
assert branch.count('staleTd.setAttribute("aria-label"') == 2, (
"the cell's aria-label must exist in the stale AND the fresh branch"
)
# READ-ONLY: no events, no controls, no innerHTML anywhere in the
# cell's construction.
assert "addEventListener" not in branch
assert "createElement(\"button\")" not in branch
assert "innerHTML" not in branch
def test_stale_column_css_rose_family() -> None:
"""styles.css (phase 53 task 04): ``.stale-pill`` is the rose
family — the Stop-treatment tokens (err-ink on err-bg ≈9.3:1, the
err-line border), theme-token based so it stays AA with the
palette; a compact rounded pill (border-radius 999px, nowrap). The
``.history-stale-cell`` keeps the marker on one line and rides
ink-soft (5.1:1 on --surface) for the fresh rows' em-dash."""
css = _css()
pill = re.search(r"\.stale-pill \{([\s\S]*?)\n\}", css)
assert pill, "styles.css must style .stale-pill"
body = pill.group(1)
assert "background: var(--err-bg)" in body, "the Stop-treatment tokens"
assert "color: var(--err-ink)" in body
assert "border: 1px solid var(--err-line)" in body
assert "border-radius: 999px" in body, "the pill shape"
assert "white-space: nowrap" in body
cell = re.search(r"\.history-stale-cell \{([^}]*)\}", css)
assert cell, "the stale cell must be styled"
cbody = cell.group(1)
assert "white-space: nowrap" in cbody
assert "var(--ink-soft)" in cbody, "the em-dash rides ink-soft (AA on --surface)"
def test_share_control_three_states_and_two_step_unshare() -> None:
"""The share cell's THREE states — unshared → [Create link];
shared → [Copy] [Unshare]; confirming → "Unshare? [Yes] [No]" —
plus the inline two-step unshare (the phase-50 Delete-confirm
pattern: focus moves to Yes, No restores the shared state, no
native dialog). The shipped state comes from the row's share_url
(the list endpoint populates it — no second fetch)."""
js = _js()
assert "window.confirm" not in js, "history.js must use the inline two-step only"
# makeShareControl: the shipped state branches on chat.share_url.
make = _fn(js, "makeShareControl")
assert "cell.className = \"history-share\"" in make
assert "chat.share_url" in make
assert "renderShareShared(chat, cell)" in make
assert "renderShareUnshared(chat, cell)" in make
# Unshared state: the Create link button (labeled, textContent).
unshared = _fn(js, "renderShareUnshared")
assert 'create.className = "history-share-create"' in unshared
assert 'create.textContent = "Create link"' in unshared
assert 'create.setAttribute("aria-label", `Create share link: ${chat.title}`)' in unshared
assert "innerHTML" not in unshared, "XSS contract: textContent only"
# Shared state: Copy + Unshare, then the two-step confirm.
shared = _fn(js, "renderShareShared")
assert 'copy.className = "history-share-copy"' in shared
assert 'copy.textContent = "Copy"' in shared
assert 'unshare.className = "history-unshare"' in shared
assert 'unshare.textContent = "Unshare"' in shared
assert 'label.textContent = "Unshare?"' in shared
assert 'yes.className = "history-confirm-yes"' in shared, (
"the unshare two-step reuses the phase-50 .history-confirm-* pair"
)
assert 'no.className = "history-confirm-no"' in shared
assert "cell.replaceChildren(label, yes, no)" in shared
swap_i = shared.find("cell.replaceChildren(label, yes, no)")
assert shared.find("yes.focus(", swap_i) > 0, "focus moves to Yes after the swap"
assert 'no.addEventListener("click", restoreShared)' in shared
restore_i = shared.find("function restoreShared")
assert restore_i != -1
assert "cell.replaceChildren(copy, unshare)" in shared[restore_i:restore_i + 120], (
"No (and a failed request) restore the shared state"
)
def test_share_create_and_unshare_request_outcomes() -> None:
"""createShareLink: POST /api/chats/<id>/share → the response's
share_url becomes the row's data, the cell re-renders shared, and
the ABSOLUTE link is offered for copying (clipboard → fallback);
non-2xx / network keep the unshared state (retryable) + the error
line. confirmUnshare: POST /api/chats/<id>/unshare → the cell
re-renders unshared + `Unshared "<title>".`; non-2xx / network
restore the shared state + the error line. Both double-fire
guarded."""
js = _js()
create = _fn(js, "createShareLink")
assert "createBtn.disabled = true" in create
assert 'fetch(`/api/chats/${chat.id}/share`, { method: "POST" })' in create
assert "renderShareShared(chat, cell)" in create, "success re-renders the shared state"
assert "chat.share_url = share_url" in create, "the row's data gains the link"
assert "new URL(share_url, window.location.origin).toString()" in create, (
"the ABSOLUTE link is what gets copied (the origin supplies scheme/host)"
)
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in create
assert "is the app reachable?" in create, "the network-error line"
assert "try again" in create, "the non-2xx line"
# A failed request keeps the button (re-enabled) — retryable.
assert create.count("createBtn.disabled = false") == 2, (
"both failure paths re-enable the Create link button"
)
unshare = _fn(js, "confirmUnshare")
assert "yesBtn.disabled = true" in unshare
assert 'fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" })' in unshare
assert "chat.share_url = null" in unshare, "a revoked link drops the row's share_url"
assert "renderShareUnshared(chat, cell)" in unshare, "success re-renders the unshared state"
assert 'announce(`Unshared "${chat.title}".`)' in unshare
assert unshare.count("restoreShared()") == 2, (
"non-2xx and network both restore the shared state (retryable)"
)
assert "is the app reachable?" in unshare
assert "try again" in unshare
def test_share_copy_uses_own_per_page_clipboard_helper_with_fallback() -> None:
"""The per-page duplication house style: history.js keeps its OWN
~10-line copy of the clipboard + inline-link fallback helper (no
import from app.js, no new shared module). A non-secure (http)
origin rejects navigator.clipboard → a transient .share-link-fallback
<a> field lands in the row's share cell (selects its full URL on
focus — the range-based selectAllInField), one field at a time."""
js = _js()
import_lines = [line for line in js.splitlines() if line.strip().startswith("import")]
assert all("app.js" not in line for line in import_lines), (
"no cross-page import — the helper is duplicated per page"
)
copy = _fn(js, "copyShareLink")
assert "navigator.clipboard.writeText(absoluteUrl)" in copy, "the clipboard try"
assert 'cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove())' in copy, (
"one field at a time — a new offer replaces the old"
)
assert 'field.className = "share-link-fallback"' in copy
assert "field.href = absoluteUrl" in copy
assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only"
assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy
assert "field.focus({ preventScroll: true })" in copy, "selects the URL on focus"
sel = _fn(js, "selectAllInField")
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
# Copy (shared state) goes through the same helper.
rowcopy = _fn(js, "copyRowShareLink")
assert "copyShareLink(" in rowcopy
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in rowcopy
def test_share_column_css() -> None:
"""styles.css: the Share cell's ghost buttons (the Tune/Retry family
— transparent, --line border, ink-soft, ≥44px) + Unshare's
error-rose hover (it revokes — the Delete language) + the inline
fallback field (input-like: mono, surface fill, --line border,
ellipsis, 3px focus-visible). The unshare two-step reuses the
.history-confirm-* pair CSS (no new confirm styles)."""
css = _css()
for cls in (".history-share-create", ".history-share-copy", ".history-unshare"):
assert re.search(re.escape(cls), css), f"styles.css must style {cls}"
btn = re.search(
r"\.history-share-create,\n\.history-share-copy,\n\.history-unshare \{([\s\S]*?)\n\}",
css,
)
assert btn, "the share buttons share one ghost-button block"
body = btn.group(1)
assert "min-height: 44px" in body, "≥44px comfortable target"
assert "border: 1px solid var(--line)" in body
assert "background: transparent" in body
assert "var(--ink-soft)" in body
assert (
".history-unshare:hover:not(:disabled) { background: var(--err-bg);"
" color: var(--err-ink); border-color: var(--err-line); }"
) in css, "Unshare hovers the error rose (it revokes the link)"
field = re.search(r"\.share-link-fallback \{([\s\S]*?)\n\}", css)
assert field, "the inline fallback field must be styled"
fbody = field.group(1)
assert "var(--mono)" in fbody, "input-like: mono (the URL is data)"
assert "background: var(--surface)" in fbody
assert "border: 1px solid var(--line)" in fbody
assert "text-overflow: ellipsis" in fbody
assert re.search(r"\.share-link-fallback:focus-visible \{[^}]*outline[^}]*3px", css), (
"the fallback field keeps a 3px :focus-visible outline"
)