"""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 "".`` 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" )