Files
brain-of-reese/tests/unit/test_history_page.py
T

616 lines
28 KiB
Python

"""Unit: the phase-50 task-04 History-page contract.
The browser behavior itself is E2E-gated by the story suite (task 05);
like the other frontend-adjacent unit files, this module pins the
JS/CSS/HTML markers the History page depends on, so a silent
regression is caught without a browser:
* the anonymous no-fetch gate (the gate in, the table out, and the
single ``GET /api/chats`` fetch lives ONLY in ``loadChats`` —
unreachable from the anonymous branch);
* the inline two-step Delete (the "Delete? [Yes] [No]" pair, focus to
Yes, the row kept on No / a failed request, ``Deleted "<title>".``
on success) and the ``window.confirm`` absence in ``history.js``
(owner-locked 2026-08-29: no native confirm dialog on this page);
* the ``/?chat=<id>`` Open-link href shape (TODO.md L5 — "return to
that history with a click");
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
+ ``header.js``'s reveal-for-admin block;
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
the empty-state row.
The Containerfile stage-1 coverage (history.html copied, history.js
bundled) is pinned dynamically by
``tests/integration/test_containerfile_assets.py`` — a page or module
missing from stage 1 fails there.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
TUNING_HTML = FRONTEND / "tuning.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
HISTORY_HTML = FRONTEND / "history.html"
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 History page + the shared
#: page: EIGHT pages.
ALL_PAGES = (
INDEX_HTML,
SOURCES_HTML,
GIT_SOURCES_HTML,
TUNING_HTML,
DOCUMENT_HTML,
LOGIN_HTML,
HISTORY_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 _fn(js: str, name: str) -> str:
"""The source of a top-level ``function <name>(...)`` (to its close)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist in history.js"
return js[start : js.find("\n}\n", start) + 4]
def _nav_history_tag(html: str) -> str:
tag = re.search(r'<a[^>]*id="nav-history"[^>]*>', html)
assert tag, "the #nav-history link is missing"
return tag.group(0)
# ---------- the #nav-history link: all seven pages ----------
def test_nav_history_present_on_all_seven_pages() -> None:
"""The phase-34 one-bar contract extended by phase 50: the admin-only
History link SHIPS hidden (revealed by header.js for admin) on every
page, after the Tuning link, pointing at /history.html. The page's
own link is the active one (is-active + aria-current)."""
for html in ALL_PAGES:
text = _text(html)
tag = _nav_history_tag(text)
assert 'href="/history.html"' in tag
assert "hidden" in tag, f"{html.name}: #nav-history must ship hidden"
# Placed after the Tuning link (the owner-locked position).
assert text.find('id="nav-tuning"') < text.find('id="nav-history"'), (
f"{html.name}: #nav-history must follow #nav-tuning"
)
# The history page is the only one whose link is active.
for html in ALL_PAGES:
tag = _nav_history_tag(_text(html))
if html.name == "history.html":
assert 'class="nav-link is-active"' in tag
assert 'aria-current="page"' in tag
else:
assert "is-active" not in tag, (
f"{html.name}: no nav link is current there"
)
def test_nav_history_count_is_exactly_eight_pages() -> None:
"""The pin counting occurrences across ``frontend/*.html`` — exactly
one ``id="nav-history"`` per page, eight pages (phase 51: + the
shared page), 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 == 8, f"expected #nav-history on 8 pages, found {total}"
def test_header_js_reveals_nav_history_for_admin() -> None:
"""header.js reveals #nav-history for admin exactly like
#nav-tuning — the same ship-hidden / reveal-for-admin contract,
inside initSharedHeader (null-safe: a page without the link is a
no-op)."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert 'querySelector("#nav-history")' in body
assert "navHistory.hidden = !admin" in body
# ---------- history.html: the page scaffold ----------
def test_history_page_scaffold_and_landmarks() -> None:
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
shared header, the steering panel + announcer (phase 34 — ships on
every page), the page-head, the gate (ship-hidden), the
role="status" live region, and the table inside the
.table-wrap card. Footer with the version span (the index.html
shape)."""
html = _text(HISTORY_HTML)
assert '<a class="skip-link" href="#main">' in html
assert 'class="app-header"' in html
assert 'nav class="app-nav" id="app-nav" aria-label="Primary"' in html
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
assert '<h1>Saved chats</h1>' in html
# The anonymous gate — the #sources-gate pattern, ship-hidden.
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', html)
assert gate and "hidden" in gate.group(0), "#history-gate must ship hidden"
assert 'href="/login.html?next=/history.html"' in html, (
"the gate's Sign in returns to the History page (no-JS fallback)"
)
# The action-feedback live region.
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', html)
# The table wrapper: the .table-wrap card (scrollable) with its
# own id, a labeled region, focusable.
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', html)
assert wrap, "the table must live in the .table-wrap card"
assert 'id="history-table-wrap"' in wrap.group(0)
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
# Footer with the version span.
assert 'class="footer-version" id="app-version"' in html
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the five columns —
Title | Messages | Updated | 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)."""
html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
assert col in html
# The Share column sits BETWEEN Updated and Actions.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Share column must sit between Updated and Actions"
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 5 (the Share column
# joined the table in phase 51), 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=\"5\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
) in html
def test_history_page_scripts_and_no_cdn() -> None:
"""Script load order (the house pattern): brand.js classic FIRST,
the history.js module second, NO direct header.js <script> tag
(single-evaluation design — history.js imports it relatively).
No-CDN rule (AGENTS.md rule 6): no external script/link tags."""
html = _text(HISTORY_HTML)
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert srcs == ["assets/brand.js", "/assets/history.js"], (
f"history.html must load brand.js (classic, first) + the history.js "
f"module, got {srcs}"
)
js = _js()
assert 'from "./header.js"' in js, (
"history.js must import the shared header module relatively"
)
assert '"/assets/header.js"' not in js
assert 'src="http' not in html and 'href="http' not in html, (
"no CDN: every asset is local (AGENTS.md rule 6)"
)
# ---------- the anonymous no-fetch gate ----------
def test_anonymous_boot_makes_no_chats_request() -> None:
"""The whoami gate in the boot IIFE: ``initSharedHeader()`` first
(shared-header contract), then the anonymous branch hides the
table, shows the gate, and RETURNS — no ``/api/chats`` request on
the wire (the router 403s anonymous; the story E2E pins the
request log). Only the admin path reaches ``loadChats()``. The
single ``fetch("/api/chats")`` in the file lives in loadChats."""
js = _js()
assert js.count('fetch("/api/chats")') == 1, (
"exactly ONE list fetch — the anonymous path must never add one"
)
load = _fn(js, "loadChats")
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
boot = js[js.find("(async () => {"):]
assert boot, "the boot IIFE must exist"
assert "await initSharedHeader()" in boot
gate_i = boot.find("if (!(await fetchIsAdmin()))")
assert gate_i != -1, "the whoami gate must run in boot"
# The anonymous branch: gate in, table out, then a bare return —
# and NO fetch call anywhere inside it.
branch = boot[gate_i : boot.find("return;", gate_i)]
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
assert "tableWrap.hidden = true" in branch
assert "gateEl.hidden = false" in branch
# The admin path: the gate hides, then the list loads.
after = boot[boot.find("return;", gate_i):]
assert "gateEl.hidden = true" in after
assert "loadChats();" in after
def test_admin_load_renders_rows_or_empty_state() -> None:
"""loadChats: a 0-row fetch (and non-2xx / a network failure)
reveals the empty-state row; a populated fetch renders one row per
chat, in the server's order (latest activity first)."""
js = _js()
load = _fn(js, "loadChats")
# Every no-data outcome lands on the empty state.
assert load.count("showEmptyState()") == 3, (
"network failure, non-2xx and a 0-row list all show the empty state"
)
assert "chats.length" in load
assert "makeRow(chat)" in load
empty = _fn(js, "showEmptyState")
assert "emptyRow.hidden = false" in empty
# ---------- the /?chat=<id> Open link ----------
def test_open_link_is_the_title_with_chat_href() -> None:
"""makeRow: the Title cell is the Open link — ``/?chat=<id>``
("return to that history with a click", TODO.md L5) — rendered
through textContent (the auto-title is user-derived; never
innerHTML). The Updated cell carries the locale date+time with the
full ISO in the title attribute; Messages is the message_count."""
js = _js()
row = _fn(js, "makeRow")
assert 'link.href = "/?chat=" + chat.id' in row, (
"the Open link returns to /?chat=<id> (task 03's boot load)"
)
assert 'link.className = "history-title-link"' in row
assert "link.textContent = chat.title" in row, "XSS contract: textContent only"
assert 'innerHTML' not in row, "makeRow must never build HTML"
assert "String(chat.message_count)" in row
assert "updatedTd.title = chat.updated_at" in row, "full ISO on hover"
assert "fmtDate(chat.updated_at)" in row
assert "link.title" in row or "titleTd.title = chat.title" in row
# ---------- the inline two-step Delete ----------
def test_two_step_delete_confirm_pair() -> None:
"""makeDeleteControl: the first click swaps the Delete button for
the "Delete? [Yes] [No]" pair IN PLACE (keyboard-reachable — focus
moves to Yes); No restores the Delete button (focus returns); the
Delete button carries a labeled aria-name."""
js = _js()
# Owner-locked 2026-08-29: no native confirm dialog in the file.
assert "window.confirm" not in js, "history.js must use the inline two-step only"
fn = _fn(js, "makeDeleteControl")
assert 'del.className = "history-delete"' in fn
assert 'del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`)' in fn
assert 'label.textContent = "Delete?"' in fn
assert 'yes.className = "history-confirm-yes"' in fn
assert 'no.className = "history-confirm-no"' in fn
# The shipped state of the actions cell IS the Delete button
# (before any click) — a cell that only gains the button on
# restore would render an empty Actions column.
append = fn.find("cell.appendChild(del)")
ret = fn.rfind("return cell")
assert -1 < append < ret, "the Delete button is appended before the return"
# The swap + the focus handoff.
assert "cell.replaceChildren(label, yes, no)" in fn
yes_swap = fn.find("cell.replaceChildren(label, yes, no)")
assert fn.find("yes.focus()", yes_swap) > 0, "focus moves to Yes after the swap"
# No (and the restore helper) bring the Delete button back, focused.
restore_start = fn.find("function restoreDelete")
restore_end = fn.find("\n }", restore_start)
restore = fn[restore_start:restore_end]
assert "cell.replaceChildren(del)" in restore
assert "del.focus()" in restore
assert 'no.addEventListener("click", restoreDelete)' in fn
def test_confirmed_delete_outcomes() -> None:
"""confirmDelete: double-fire guarded; 2xx → the row is removed +
the empty-state row reappears when it was the last + the live
region `Deleted "<title>".`; a 404 (already gone) drops the stale
row and says so; any other failure / a network error KEEPS the row
(restore) and lands the error line."""
js = _js()
fn = _fn(js, "confirmDelete")
assert "yesBtn.disabled = true" in fn
assert "fetch(`/api/chats/${chat.id}`, { method: \"DELETE\" })" in fn
# Success: remove + empty-state check + the exact live-region line.
assert "row.remove()" in fn
assert "showEmptyIfLast()" in fn
assert 'announce(`Deleted "${chat.title}".`)' in fn
# 404: the row is stale — drop it, no restore. (The branch slices
# stop at the NEXT branch boundary — a template-literal `}` inside
# an announce line must not end the slice early.)
nf = fn.find("r.status === 404")
assert nf != -1, "the 404 branch must be handled"
notok = fn.find("if (!r.ok)")
nf_branch = fn[nf:notok]
assert "row.remove()" in nf_branch
assert "already deleted" in nf_branch
assert "restoreDelete()" not in nf_branch
# !ok (non-404) and network: the row stays, the button is
# retryable, and the error line lands.
# !ok (non-404) and network: the row stays, the button is
# retryable, and the error line lands. (The try/catch wraps the
# FETCH, so it precedes the status branches; the !ok slice runs to
# the function's close — the success tail after it carries neither
# a restore nor that line.)
assert notok != -1
notok_branch = fn[notok:]
assert "restoreDelete()" in notok_branch
assert "try again" in notok_branch
# The network-error catch: the reachable? line + the restore (the
# catch wraps the fetch, so it precedes the status branches).
catch_i = fn.find("} catch {")
assert catch_i != -1
catch_branch = fn[catch_i : fn.find("if (r.status === 404)")]
assert "is the app reachable?" in catch_branch
assert "restoreDelete()" in catch_branch
# The empty-state row reappears exactly when the last data row is
# gone (the hidden empty row itself ships in the tbody).
empty = _fn(js, "showEmptyIfLast")
assert "querySelectorAll(\"tr\").length > 1" in empty
# ---------- the table CSS ----------
def test_history_table_css_full_width_and_palette() -> None:
"""styles.css: .history-table is the full-width sources-table family
(width 100%, --line borders, the brand-soft thead, row hover); the
title link is the accent link (brand-ink, focus-visible); the
confirm pair is Yes-on-error-rose + No-ghost; the empty-state row
is the muted centered message. Every pair is Phase-08 AA
(brand-ink/brand-soft 6.9:1, err 9.1:1, ink-soft >=6.9:1)."""
css = _css()
block = re.search(r"\.history-table \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .history-table"
body = block.group(1)
assert "width: 100%" in body, "the table is FULL-WIDTH (AGENTS.md rule 5)"
assert "min-width: 640px" in body
th = re.search(r"\.history-table th \{([\s\S]*?)\n\}", css)
assert th and "var(--brand-soft)" in th.group(1) and "var(--brand-ink)" in th.group(1)
hover = re.search(r"\.history-table tbody tr:hover \{([^}]*)\}", css)
assert hover, "row hover is part of the table family"
link = re.search(r"\.history-title-link \{([\s\S]*?)\n\}", css)
assert link and "var(--brand-ink)" in link.group(1), "the Open link is the accent link"
assert re.search(r"\.history-title-link:focus-visible \{[^}]*outline[^}]*3px", css), (
"the Open link keeps a :focus-visible outline"
)
yes = re.search(r"\.history-confirm-yes \{([\s\S]*?)\n\}", css)
assert yes, "the confirm Yes button must be styled"
ybody = yes.group(1)
assert "var(--err-bg)" in ybody and "var(--err-ink)" in ybody and "var(--err-line)" in ybody
no = re.search(r"\.history-confirm-no \{([\s\S]*?)\n\}", css)
assert no and "background: transparent" in no.group(1), "No is the ghost"
empty = re.search(r"\.history-empty-row td \{([\s\S]*?)\n\}", css)
assert empty, "the empty-state row must be styled"
ebody = empty.group(1)
assert "text-align: center" in ebody and "var(--ink-soft)" in ebody
def test_history_table_mobile_behavior() -> None:
"""≤640px (the phase-07 responsive contract): the table keeps its
full width (the .table-wrap's horizontal scroll already covers
it) and the actions cell wraps so the two-step confirm pair fits
the phone width."""
css = _css()
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
mbody = mobile.group(1)
assert ".history-actions-cell { white-space: normal; }" in mbody
assert ".history-actions { flex-wrap: wrap; }" in mbody
# ---------- 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 | Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < share_i < actions_i, (
"the share cell must sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
f"row cell order must be title/count/updated/share/actions, got {seq}"
)
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"
)