All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green. **Phase 175 final verification pass — report** - Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed. - Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate). - `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings. - Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN). - Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins). - Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's). - Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.** - Deviations: none. No defects found in previously completed work. - Next pending phase: none — 175 is the only entry in `todo/`.
830 lines
40 KiB
Python
830 lines
40 KiB
Python
"""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``.
|
||
|
||
Phase 156 (task 02): the ellipsis hint on the History action labels —
|
||
the ``Create link`` / ``Copy`` / ``Unshare`` / ``Delete`` visible
|
||
texts are ``.cell-clip`` spans (a squeezed cell shows "…" instead of
|
||
a hard cut) and the audit's accessible-name check lands: every one
|
||
of the four buttons' ``aria-label`` names the chat title (the
|
||
phase-66 idiom — nothing was missing, the pins keep it), so the
|
||
full text stays reachable.
|
||
|
||
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(pageOffset)``. The single list fetch in the file —
|
||
phase 175's parameterized form (``fetch("/api/chats?limit=" +
|
||
PAGE_SIZE + "&offset=" + pageOffset)``) — lives in loadChats."""
|
||
js = _js()
|
||
assert js.count('fetch("/api/chats?limit="') == 1, (
|
||
"exactly ONE list fetch — the anonymous path must never add one"
|
||
)
|
||
load = _fn(js, "loadChats")
|
||
assert (
|
||
'fetch("/api/chats?limit=" + PAGE_SIZE + "&offset=" + pageOffset)' in load
|
||
), "the parameterized list fetch lives in loadChats (phase 175)"
|
||
|
||
# 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 on page 1
|
||
# (phase 175: the view opens on page 1 — offset 0).
|
||
after = js[js.find("return;", gate_i):]
|
||
assert "gateEl.hidden = true" in after
|
||
assert "loadChats(0);" 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
|
||
# Phase 156 (task 02, A3) audit: the Delete button's aria-label
|
||
# names the row's identity (the chat title — the phase-66 idiom),
|
||
# and the visible text is the .cell-clip span — a squeezed cell
|
||
# shows "…" instead of a hard cut (the full text stays reachable).
|
||
assert 'delClip.className = "cell-clip"' in fn, (
|
||
"the clip label span (phase 156, A3)"
|
||
)
|
||
assert 'delClip.textContent = "Delete"' in fn, "the visible text"
|
||
assert "del.append(delClip)" in fn, "the span is the button's label"
|
||
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).
|
||
Phase 156 (task 01): the thead's brand-soft background moved to the
|
||
ONE .table-wrap table thead th sticky rule — .history-table th
|
||
keeps the text styling only."""
|
||
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-ink)" in th.group(1), (
|
||
"the th keeps its brand-ink text styling"
|
||
)
|
||
sticky = re.search(r"\.table-wrap table thead th \{([\s\S]*?)\n\}", css)
|
||
assert sticky and "var(--brand-soft)" in sticky.group(1), (
|
||
"the thead's brand-soft background is the ONE .table-wrap "
|
||
"table thead th rule (phase 156)"
|
||
)
|
||
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).
|
||
# Phase 156 (task 02, A3): the visible text is the .cell-clip
|
||
# span (a squeezed Share cell shows "…" instead of a hard cut);
|
||
# the aria-label names the chat title (the phase-66 idiom — the
|
||
# audit: every one of the four History action buttons already
|
||
# carries an accessible name that names the row's identity, so
|
||
# the full text stays reachable and nothing was missing).
|
||
unshared = _fn(js, "renderShareUnshared")
|
||
assert 'create.className = "history-share-create"' in unshared
|
||
assert 'createClip.className = "cell-clip"' in unshared, (
|
||
"the clip label span (phase 156, A3)"
|
||
)
|
||
assert 'createClip.textContent = "Create link"' in unshared
|
||
assert "create.append(createClip)" 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 'copyClip.className = "cell-clip"' in shared, (
|
||
"the clip label span (phase 156, A3)"
|
||
)
|
||
assert 'copyClip.textContent = "Copy"' in shared
|
||
assert "copy.append(copyClip)" in shared
|
||
assert 'unshare.className = "history-unshare"' in shared
|
||
assert 'unshareClip.className = "cell-clip"' in shared, (
|
||
"the clip label span (phase 156, A3)"
|
||
)
|
||
assert 'unshareClip.textContent = "Unshare"' in shared
|
||
assert "unshare.append(unshareClip)" in shared
|
||
# The audit's aria-label additions — present (the phase-66 idiom
|
||
# predated the audit; the pin keeps them):
|
||
assert 'copy.setAttribute("aria-label", `Copy share link: ${chat.title}`)' in shared
|
||
assert 'unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`)' 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"
|
||
)
|