All completion criteria verified green. Final state confirmed: phase stays in `todo/` per A4 (owner device gate), task files in `complete/`, no `app/` changes, no commits made (harness commits). ## Phase 88 — final verification pass report **Verified (all 6 tasks already complete):** `touch-action: manipulation` on mobile `#nav-toggle`; `#view-chat.chat-booted` double-rAF sticky handover (CSS gate + app.js boot IIFE); `body.nav-menu-open` marker in `setNavMenu` + ≤640px `visibility: hidden` rule; router boot contract (`wasMounted && !opts.boot` + `boot: true`); new unit module (10 pins) + 4-test real-touch E2E story. **Test / lint / coverage:** - `uv run pytest --cov=app --cov-report=term-missing` → 1741 passed, **99%** on `app/` (>90% ✓) - `tests/e2e/test_mobile_chat_hamburger_boot.py` 4/4, `test_mobile_hamburger_nav.py` 8/8, `test_pinned_composer.py` 4/4, `test_smoke.py` 3/3 — each in isolation, `--no-cov` ✓ - `uv run ruff check .` clean; `uv run pyright` 0 errors ✓ **Completion criteria:** (1) 360px cold-boot touch-action + sticky handover + tap-opens-menu-with-cluster-hidden (E2E 1–2) ✓; (2) boot fires no `bor:view-refresh`, re-show fires exactly one, `/sources.html` regression (E2E 3–4) ✓; (3) full gate green ✓; (4) diff limited to 4 assets + 2 new tests + phase files, 0 changes in `app/` ✓; (5) commit deferred to harness per executor rules ✓; (6) owner device re-verification **pending** (A4 — gates the archive; no Owner report recorded yet). **Deviations (both documented in-tree):** unit pins updated in `test_frontend_router.py`/`test_hamburger_nav.py` (their exact-text pins collided with the mandated new guard/marker text — without them the suite goes red); `boot: true` count pinned at 1 not 2 (codebase has one boot call site, no `history.state` branch — verified against git HEAD). **Next pending phase:** none in `todo/` — pipeline awaits the owner's on-device report (archive, or `?dbg=nav` instrumentation follow-up if the menu is still dead).
872 lines
43 KiB
Python
872 lines
43 KiB
Python
"""Unit: the phase-76 shell router contract (task 01).
|
||
|
||
The browser behavior is E2E-gated (the phase-76 story suite —
|
||
``test_nav_switch_keeps_stream.py`` — plus the tuning-adjacent suites);
|
||
here we pin the router.js / index.html source-level invariants the
|
||
"views of one document" architecture depends on, so a silent
|
||
regression is caught without a browser (house pattern:
|
||
``tests/unit/test_frontend_hidden_tab.py`` reads JS source and
|
||
asserts on its mechanisms).
|
||
|
||
Pinned design (phase 76 overview + task 01):
|
||
* the VIEW map — pathname → view name — is the ONLY set of paths the
|
||
click interceptor may swallow (every other link keeps its real,
|
||
document-level navigation);
|
||
* a switch is ``history.pushState`` + show/hide — NEVER a document
|
||
load (no ``location.assign`` / ``location.href`` / ``location.replace``
|
||
/ ``location.reload`` anywhere in the module);
|
||
* mount-once per view: the lazy module is imported on FIRST show only,
|
||
the guard runs before the import and is set only after ``mount``
|
||
resolves;
|
||
* hidden views carry BOTH ``hidden`` AND ``inert`` (WCAG — a hidden
|
||
view must not receive focus or keyboard traversal);
|
||
* the router is the SINGLE WRITER of the ``.nav-link`` active state
|
||
(``is-active`` + ``aria-current="page"``), of ``document.title``, and
|
||
of the per-view ``<meta name="description">``;
|
||
* focus lands on the target view ONLY on user-initiated switches
|
||
(navbar click / popstate) — never on initial boot (no focus steal);
|
||
* the shell markup: ONE main holding the view sections, only the Chat
|
||
link statically active, boot order brand.js → app.js → router.js,
|
||
and the chat view needs no module import (app.js ran at shell boot).
|
||
|
||
Phase 76 task 04 (the header is shell-owned): the shell's header is the
|
||
canonical one — the old per-page header copies (with their static
|
||
active stamps) are gone with the four folded view documents, so
|
||
``is-active`` occurs EXACTLY ONCE in the whole of index.html (on the
|
||
Chat link), and header.js carries NO ``is-active`` write: the whoami
|
||
auth gate + the mobile hamburger are its only nav responsibilities,
|
||
and the router is the SINGLE runtime writer of the active state.
|
||
|
||
Phase 77 task 01 (the re-show refresh hook): a user-initiated re-show
|
||
of an ALREADY-MOUNTED view dispatches ``bor:view-refresh`` on the
|
||
view's section — gated on the pre-mount ``wasMounted`` capture, so the
|
||
first show (the mount) and boot never fire it (the mount's own load is
|
||
the first fetch); a re-click of the active view's own nav link
|
||
dispatches the event instead of a bare return (no ``pushState`` — the
|
||
URL is already that view's path); the History view listens (armed only
|
||
in the admin branch, after the whoami gate — anonymous never fetches).
|
||
|
||
Phase 77 task 02 (the other data views join the refresh): RAG
|
||
(``sources.js``), Sources (``git-sources.js``) and Tuning
|
||
(``tuning.js``) each listen for ``bor:view-refresh`` on their root and
|
||
re-run their existing load (armed only in the admin branch, after the
|
||
whoami gate — the same gate guard as History). ``sources.js``'s
|
||
``loadDocs`` clears the tbody's rows at the TOP (before the fetch —
|
||
the History pattern), so a refresh from a populated list into an empty
|
||
result leaves no ghost rows. The Chat view (``app.js``) does NOT
|
||
listen — the negative pin: the in-flight SSE stream and the local
|
||
conversation must survive every switch (the phase-76 contract), so
|
||
the exclusion is a contract, not an oversight.
|
||
|
||
Phase 77 task 03 (the explicit History refresh control, TODO.md L3):
|
||
the History page-head becomes a flex row (scoped to ``#view-history``
|
||
— the other four views' page-heads are untouched) carrying the
|
||
``#history-refresh`` button (``aria-label="Refresh saved chats"``,
|
||
the aria-hidden house refresh glyph + the visible "Refresh" label —
|
||
the phase-46 auth-link convention) OUTSIDE the table wrap (reachable
|
||
while the empty state shows). history.js binds it in the admin branch
|
||
only (the anonymous branch hides it — no dead control beside the
|
||
gate); the click disables the button (no double-fire in flight),
|
||
reruns the re-entrant ``loadChats()`` and re-enables on success AND
|
||
failure (the finally). The outcome lands in ``#history-status``:
|
||
``Saved chats refreshed.`` on success (a 0-row fetch is a success) —
|
||
and the failure lines now live INSIDE ``loadChats`` (the house copy:
|
||
"is the app reachable?" / "try again."), so every caller of a failed
|
||
load sees it (the §7.4 never-stale contract). styles.css reuses the
|
||
``.new-chat-btn`` visual language (brand pill, ≥44px, hover,
|
||
:disabled) and goes icon-only below 640px.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||
ASSETS = FRONTEND / "assets"
|
||
ROUTER_JS = ASSETS / "router.js"
|
||
INDEX_HTML = FRONTEND / "index.html"
|
||
|
||
|
||
def _js() -> str:
|
||
assert ROUTER_JS.is_file(), f"missing {ROUTER_JS}"
|
||
return ROUTER_JS.read_text(encoding="utf-8")
|
||
|
||
|
||
def _html() -> str:
|
||
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
|
||
return INDEX_HTML.read_text(encoding="utf-8")
|
||
|
||
|
||
# ---------- the VIEW map: the only interceptable paths ----------
|
||
|
||
|
||
def test_view_map_covers_the_shell_paths() -> None:
|
||
"""The VIEW map is pathname → view name: the shell's own two URLs
|
||
("/" and "/index.html") are the chat view, plus one entry per
|
||
folded view (tasks 01–03: tuning, rag, git-sources, history;
|
||
phase 79 task 06: tokens — all five non-chat navbar views are
|
||
in)."""
|
||
js = _js()
|
||
view_start = js.find("const VIEW = {")
|
||
assert view_start != -1, "the VIEW map must exist"
|
||
view_body = js[view_start : js.find("\n}", view_start)]
|
||
assert '"/": "chat"' in view_body, "the app root is the chat view"
|
||
assert '"/index.html": "chat"' in view_body, (
|
||
"the shell's alternate URL is the chat view too (HTML_PAGES)"
|
||
)
|
||
assert '"/tuning.html": "tuning"' in view_body, (
|
||
"task 01 folds the Tuning view into the shell"
|
||
)
|
||
assert '"/history.html": "history"' in view_body, (
|
||
"task 03 folds the History view into the shell"
|
||
)
|
||
assert '"/tokens.html": "tokens"' in view_body, (
|
||
"phase 79 task 06 folds the Tokens view into the shell"
|
||
)
|
||
# The view names are the #view-<name> section slugs in index.html.
|
||
for name in ("chat", "tuning", "history", "tokens"):
|
||
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
|
||
|
||
|
||
def test_interceptor_matches_only_view_map_paths() -> None:
|
||
"""The delegated nav click handler intercepts ONLY a link whose href
|
||
is in VIEW — the `in VIEW` guard runs BEFORE preventDefault, and a
|
||
non-view link (login, the viewer, the not-yet-folded views) falls
|
||
through to its real, document-level navigation."""
|
||
js = _js()
|
||
fn = js.find('nav.addEventListener("click"')
|
||
assert fn != -1, "the delegated click handler on the nav must exist"
|
||
body = js[fn : js.find("\n });", fn)]
|
||
guard = body.find("in VIEW")
|
||
prevent = body.find("e.preventDefault()")
|
||
assert 0 <= guard < prevent, (
|
||
"the VIEW membership guard must run BEFORE the preventDefault"
|
||
)
|
||
assert 'closest("a.nav-link")' in body, (
|
||
"only the .nav-link family is considered (auth links are untouched)"
|
||
)
|
||
|
||
|
||
def test_switches_use_pushstate_not_document_navigation() -> None:
|
||
"""A view switch is history.pushState (same-document) — the module
|
||
must contain NO document-level navigation primitive: no
|
||
location.assign, no location.href write, no location.replace, no
|
||
location.reload (the phase-48 abort path lives in app.js, not here)."""
|
||
js = _js()
|
||
assert "history.pushState" in js, "the switch must pushState"
|
||
for banned in ("location.assign", "location.href", "location.replace", "location.reload"):
|
||
assert banned not in js, f"{banned} is a document load — the switch is same-document"
|
||
# The pushState is the click handler's (the popstate path only READS
|
||
# the location — it never writes it).
|
||
fn = js.find('nav.addEventListener("click"')
|
||
body = js[fn : js.find("\n });", fn)]
|
||
assert "history.pushState" in body
|
||
|
||
|
||
def test_popstate_switches_views() -> None:
|
||
"""Back / forward re-runs the switch for the pathname in history —
|
||
user-initiated (focus + top landing included)."""
|
||
js = _js()
|
||
fn = js.find('window.addEventListener("popstate"')
|
||
assert fn != -1, "the popstate listener must exist"
|
||
body = js[fn : js.find("});", fn)]
|
||
assert "location.pathname" in body, "popstate resolves the view from the pathname"
|
||
assert "userInitiated: true" in body, "back/forward is a user-initiated switch"
|
||
|
||
|
||
# ---------- mount-once, hide-forever ----------
|
||
|
||
|
||
def test_mount_once_guard_runs_before_import_and_after_mount() -> None:
|
||
"""A non-chat view's module is imported on FIRST show only: the
|
||
`mounted[name]` guard is checked BEFORE the lazy import, the import
|
||
+ `await module.mount(root)` run once, and the guard is set only
|
||
AFTER mount resolves (a failed mount may retry on the next show)."""
|
||
js = _js()
|
||
fn = js.find("async function switchTo")
|
||
assert fn != -1, "switchTo must exist"
|
||
body = js[fn : js.find("\n}", fn)]
|
||
guard = body.find("if (!mounted[name])")
|
||
load = body.find("await load()")
|
||
mount = body.find("await mod.mount(root)")
|
||
set_guard = body.find("mounted[name] = true")
|
||
assert 0 <= guard < load < mount < set_guard, (
|
||
"guard → lazy import → mount → set guard (in that order)"
|
||
)
|
||
# The guard map starts with chat mounted (app.js ran at shell boot).
|
||
assert re.search(r"const mounted = \{ chat: true \}", js), (
|
||
"chat starts mounted — it needs no module import"
|
||
)
|
||
|
||
|
||
def test_only_non_chat_views_have_lazy_modules() -> None:
|
||
"""VIEW_MODULES lazy-imports the non-chat views only — the view
|
||
modules are STATIC specifiers (so the Containerfile's esbuild
|
||
stage can inline them into the router bundle) and there is NO
|
||
import of app.js (the chat view needs no module — it ran at shell
|
||
boot)."""
|
||
js = _js()
|
||
mods_start = js.find("const VIEW_MODULES = {")
|
||
assert mods_start != -1, "the lazy module map must exist"
|
||
mods_body = js[mods_start : js.find("\n}", mods_start)]
|
||
assert 'tuning: () => import("./tuning.js")' in mods_body, (
|
||
"the Tuning view module is lazy-imported on first show"
|
||
)
|
||
assert 'history: () => import("./history.js")' in mods_body, (
|
||
"the History view module is lazy-imported on first show"
|
||
)
|
||
assert 'tokens: () => import("./tokens.js")' in mods_body, (
|
||
"the Tokens view module is lazy-imported on first show"
|
||
)
|
||
assert '"chat"' not in mods_body, "the chat view has no lazy module"
|
||
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
|
||
|
||
|
||
# ---------- show / hide: hidden AND inert ----------
|
||
|
||
|
||
def test_hidden_views_get_both_hidden_and_inert() -> None:
|
||
"""Show = drop hidden AND inert; hide = add BOTH — the same
|
||
comparison drives both attributes in one loop, so a view can never
|
||
be visible-but-inert or inert-but-visible (WCAG: a hidden view must
|
||
not receive focus or keyboard traversal)."""
|
||
js = _js()
|
||
fn = js.find("async function switchTo")
|
||
body = js[fn : js.find("\n}", fn)]
|
||
loop = body.find("Object.entries(viewEls)")
|
||
assert loop != -1, "the show/hide loop must walk every view"
|
||
loop_body = body[loop : body.find("\n }", loop)]
|
||
hidden_i = loop_body.find("el.hidden =")
|
||
inert_i = loop_body.find("el.inert =")
|
||
assert 0 <= hidden_i < inert_i, "both attributes are set in the same loop"
|
||
assert loop_body.count("viewName !== name") == 2, (
|
||
"one comparison drives hidden AND inert (they can never drift)"
|
||
)
|
||
|
||
|
||
# ---------- the router is the single writer ----------
|
||
|
||
|
||
def test_router_writes_active_state_title_and_meta() -> None:
|
||
"""The router stamps the .nav-link active state (is-active +
|
||
aria-current, removed on the inactive links), document.title, and
|
||
the per-view meta description — values carried over from the old
|
||
pages' <head>s (the tuning title/description survive the fold).
|
||
Phase 76 (task 02): the title/meta are composed through
|
||
titleFor()/descFor() — the per-view value with the brand literal
|
||
replaced by window.BOR_BRAND (phase 39): the lazy view import
|
||
defers switchTo past brand.js's one-time DOM pass, so a literal
|
||
stamp would overwrite a configured deployment's name; composing
|
||
at write time is a no-op for the default deployment."""
|
||
js = _js()
|
||
fn = js.find("async function switchTo")
|
||
body = js[fn : js.find("\n}", fn)]
|
||
assert 'querySelectorAll("a.nav-link")' in body, "the writer walks the nav links"
|
||
assert 'classList.toggle("is-active"' in body, "is-active is stamped + removed"
|
||
assert 'setAttribute("aria-current", "page")' in body
|
||
assert 'removeAttribute("aria-current")' in body
|
||
assert "document.title = titleFor(name)" in body
|
||
assert "metaDesc.content = descFor(name)" in body
|
||
# The carried-over values (the old pages' <head>s — default form).
|
||
assert 'chat: "Brain of Reese"' in js
|
||
assert 'tuning: "Global Tuning · Brain of Reese"' in js
|
||
assert "Manage the global tuning notes that steer every Brain of Reese answer." in js
|
||
assert 'history: "Saved chats · Brain of Reese"' in js
|
||
assert "Saved chats — every conversation is saved automatically, one click back." in js
|
||
assert 'tokens: "Access tokens · Brain of Reese"' in js
|
||
assert "Generate and revoke the API tokens that let people use the app." in js
|
||
# The brand composition (phase 39's window.BOR_BRAND, read at
|
||
# write time — never a hardcoded stamp).
|
||
assert 'window.BOR_BRAND || "Brain of Reese"' in js
|
||
assert 'TITLES[view].replaceAll("Brain of Reese", brandName())' in js
|
||
assert 'DESCRIPTIONS[view].replaceAll("Brain of Reese", brandName())' in js
|
||
|
||
|
||
def test_focus_only_on_user_initiated_switches() -> None:
|
||
"""The target view is focused ONLY when the switch is
|
||
user-initiated (navbar click / popstate) — the boot switch passes
|
||
userInitiated:false, so a page load never steals focus. The top
|
||
landing (scrollTo 0,0) rides the same flag. (Phase 88: the opts
|
||
object also carries the boot flag — the boot call site passes
|
||
``boot: true`` so the boot show never fires the view refresh; the
|
||
focus gate reads ``opts.userInitiated``.)"""
|
||
js = _js()
|
||
fn = js.find("async function switchTo")
|
||
body = js[fn : js.find("\n}", fn)]
|
||
flag = body.rfind("if (opts.userInitiated)")
|
||
focus = body.find("root.focus(")
|
||
scroll = body.find("window.scrollTo(0, 0)")
|
||
assert 0 <= flag < scroll < focus, "focus + top landing sit inside the flag"
|
||
# Boot is NOT user-initiated (no focus steal on load).
|
||
boot = js.find("switchTo(bootName")
|
||
assert boot != -1 and "userInitiated: false" in js[boot : boot + 60]
|
||
# The click handler IS user-initiated.
|
||
click = js.find('nav.addEventListener("click"')
|
||
click_body = js[click : js.find("\n });", click)]
|
||
assert "userInitiated: true" in click_body
|
||
|
||
|
||
# ---------- the shell markup + boot order ----------
|
||
|
||
|
||
def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
|
||
"""index.html: ONE main#main holds the view sections; the Tuning
|
||
section ships hidden AND inert (the a11y pair); ONLY the Chat link
|
||
carries the static active stamp (the router is the single writer —
|
||
no view other than chat may ship statically active)."""
|
||
html = _html()
|
||
assert html.count('id="main"') == 1, "the shell has exactly one main"
|
||
main = html.find('<main id="main" class="app-main" tabindex="-1">')
|
||
assert main != -1
|
||
view_chat = html.find('<section class="view" id="view-chat"')
|
||
view_tuning = html.find('<section class="view" id="view-tuning"')
|
||
main_end = html.find("</main>", main)
|
||
assert main < view_chat < view_tuning < main_end, (
|
||
"both view sections live inside the single main (chat first)"
|
||
)
|
||
tuning_tag = html[view_tuning : html.find(">", view_tuning)]
|
||
assert "hidden" in tuning_tag and "inert" in tuning_tag, (
|
||
"the folded view ships hidden AND inert"
|
||
)
|
||
assert 'tabindex="-1"' in html[view_chat : html.find(">", view_chat)]
|
||
assert 'tabindex="-1"' in tuning_tag, "the target view is focusable"
|
||
# Only the Chat link is statically active (exactly one stamp, on Chat).
|
||
assert html.count('class="nav-link is-active"') == 1, (
|
||
"only ONE nav link may ship statically active"
|
||
)
|
||
active = html.find('<a href="/" class="nav-link is-active" aria-current="page">Chat</a>')
|
||
assert active != -1, "the static active stamp is the Chat link"
|
||
# The tuning nav link ships hidden (admin-only) and UNstamped.
|
||
tuning_match = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', html)
|
||
assert tuning_match, "the shell must carry the #nav-tuning nav link"
|
||
tuning_link = tuning_match.group(0)
|
||
assert "hidden" in tuning_link, "#nav-tuning ships hidden (admin-only)"
|
||
assert "is-active" not in tuning_link, "no static active stamp on the Tuning link"
|
||
# The Tokens nav link (phase 79 task 06) ships hidden (admin-only)
|
||
# and UNstamped too — the router is the single writer of the active
|
||
# state, and a token user (role "user") must never see the link
|
||
# (header.js reveals it for admin only).
|
||
tokens_match = re.search(r'<a[^>]*id="nav-tokens"[^>]*>', html)
|
||
assert tokens_match, "the shell must carry the #nav-tokens nav link"
|
||
tokens_link = tokens_match.group(0)
|
||
assert "hidden" in tokens_link, "#nav-tokens ships hidden (admin-only)"
|
||
assert "is-active" not in tokens_link, "no static active stamp on the Tokens link"
|
||
|
||
|
||
# ---------- phase 76 task 04: the header is shell-owned ----------
|
||
|
||
|
||
def test_shell_carries_exactly_one_static_is_active_on_chat() -> None:
|
||
"""Phase 76 task 04: the shell's ONE header is the canonical header —
|
||
the folded pages' header copies (which stamped is-active statically
|
||
in their own markup) are gone, so ``is-active`` occurs EXACTLY ONCE
|
||
in the whole of index.html, on the Chat link (the default view).
|
||
A second stamp anywhere (a leaked page copy, a non-chat view
|
||
shipping statically active) would break the router's single-writer
|
||
contract the moment it disagrees with a switch."""
|
||
html = _html()
|
||
assert html.count("is-active") == 1, (
|
||
"index.html must carry is-active exactly once (the Chat link's stamp)"
|
||
)
|
||
i = html.find("is-active")
|
||
tag_start = html.rfind("<a ", 0, i)
|
||
tag_end = html.find(">", tag_start)
|
||
tag = html[tag_start:tag_end]
|
||
assert tag.startswith('<a href="/"'), (
|
||
"the single static active stamp must be the Chat link (href=\"/\")"
|
||
)
|
||
|
||
|
||
def test_header_js_never_writes_the_active_state() -> None:
|
||
"""Phase 76 task 04: header.js is NOT a writer of the nav's active
|
||
state — it never was (the old pages stamped is-active statically in
|
||
their OWN markup; the shell's ONE header is the only header left)
|
||
— and it must never become one: the whoami auth gate (the sign-in/
|
||
sign-out pair + the admin-only nav links' hidden attributes) and the
|
||
mobile hamburger are its only nav responsibilities, and neither
|
||
touches the active state. The string is absent from the module
|
||
entirely; the SINGLE runtime writer is the router (pinned in
|
||
test_router_writes_active_state_title_and_meta)."""
|
||
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
|
||
assert "is-active" not in header_js, (
|
||
"header.js must carry no is-active write — the router is the "
|
||
"SINGLE WRITER of the active state (the shell markup ships the "
|
||
"one static stamp on the Chat link)"
|
||
)
|
||
|
||
|
||
def test_boot_order_is_brand_app_router() -> None:
|
||
"""The shell's script boot order: brand.js (classic) FIRST, then
|
||
app.js (the chat view module — runs at shell boot exactly as
|
||
today), then router.js (module) — the router may only see a
|
||
fully-booted chat view."""
|
||
html = _html()
|
||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||
assert "assets/brand.js" in srcs, "brand.js (classic) still ships"
|
||
assert srcs.index("assets/brand.js") < srcs.index("/assets/app.js") < srcs.index(
|
||
"/assets/router.js"
|
||
), "boot order: brand.js → app.js → router.js"
|
||
router_tag_match = re.search(r'<script[^>]*src="/assets/router\.js"[^>]*>', html)
|
||
assert router_tag_match, "the shell must load the router module"
|
||
router_tag = router_tag_match.group(0)
|
||
assert 'type="module"' in router_tag, "router.js is an ES module"
|
||
# No CDN: every asset reference is local (AGENTS.md rule 6).
|
||
assert 'src="http' not in html and 'href="http' not in html
|
||
|
||
|
||
# ---------- phase 77 task 01: the re-show refresh hook ----------
|
||
|
||
|
||
def test_reshow_dispatches_view_refresh_gated_on_pre_mount_capture() -> None:
|
||
"""Phase 77: a user-initiated re-show of an already-mounted view
|
||
dispatches the ``bor:view-refresh`` CustomEvent on the view's
|
||
section. The dispatch site is INSIDE the ``if (wasMounted)`` guard,
|
||
and the ``wasMounted`` capture runs BEFORE the mount-once set
|
||
(``mounted[name] = true``) — so the first show (the mount) and boot
|
||
never dispatch: the mount's own load is the first fetch. Event
|
||
order: the view is visible and the head/nav state is written
|
||
BEFORE the refresh fires, and the focus/scroll tail runs after.
|
||
(Phase 88: the guard is ``if (wasMounted && !opts.boot)`` — the
|
||
boot show is the view's first display, so the code now matches the
|
||
documented contract; only the boot call site passes ``boot: true``,
|
||
pinned in test_mobile_chat_hamburger_boot.py.)"""
|
||
js = _js()
|
||
assert '"bor:view-refresh"' in js, "the refresh event literal must exist"
|
||
fn = js.find("async function switchTo")
|
||
assert fn != -1, "switchTo must exist"
|
||
body = js[fn : js.find("\n}", fn)]
|
||
capture = body.find("const wasMounted = mounted[name]")
|
||
mount_set = body.find("mounted[name] = true")
|
||
assert 0 <= capture < mount_set, (
|
||
"the wasMounted capture must precede the mount-once set "
|
||
"(first show is exempt from the refresh)"
|
||
)
|
||
gate = body.find("if (wasMounted && !opts.boot)")
|
||
dispatch = body.find('root.dispatchEvent(new CustomEvent("bor:view-refresh"))')
|
||
assert 0 <= gate < dispatch < gate + 120, (
|
||
"the dispatch must sit inside the wasMounted guard"
|
||
)
|
||
show_loop = body.find("Object.entries(viewEls)")
|
||
title_write = body.find("document.title = titleFor(name)")
|
||
current_set = body.find("current = name")
|
||
focus = body.find("root.focus(")
|
||
assert show_loop < title_write < current_set < gate < dispatch < focus, (
|
||
"visible → head/nav state → refresh dispatched → focus/scroll tail"
|
||
)
|
||
|
||
|
||
def test_active_view_reclick_dispatches_refresh_not_bare_return() -> None:
|
||
"""Phase 77: a re-click of the ACTIVE view's own nav link is a
|
||
re-fetch, not a no-op — the ``name === current`` branch dispatches
|
||
the refresh event on that view's section and returns. It must NOT
|
||
pushState (the URL is already this view's path) and must NOT
|
||
re-run the switch (no re-mount)."""
|
||
js = _js()
|
||
fn = js.find('nav.addEventListener("click"')
|
||
assert fn != -1, "the delegated click handler on the nav must exist"
|
||
body = js[fn : js.find("\n });", fn)]
|
||
branch = body.find("if (name === current)")
|
||
assert branch != -1, "the active re-click branch must exist"
|
||
branch_end = body.find("}", branch)
|
||
branch_body = body[branch : branch_end + 1]
|
||
assert 'new CustomEvent("bor:view-refresh")' in branch_body, (
|
||
"the re-click branch must dispatch the refresh event (not a bare return)"
|
||
)
|
||
assert "history.pushState" not in branch_body, (
|
||
"the re-click must NOT pushState — the URL is already this view's path"
|
||
)
|
||
assert "switchTo" not in branch_body, "the re-click must NOT re-run the switch"
|
||
assert "return" in branch_body, "the re-click still returns early (menu closes)"
|
||
|
||
|
||
def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None:
|
||
"""Phase 77: the History view re-fetches on a user-initiated
|
||
re-show — history.js registers a ``bor:view-refresh`` listener on
|
||
the view's root that re-runs the (now re-entrant) ``loadChats()``.
|
||
The listener is armed only AFTER the whoami gate passes: anonymous
|
||
shows the gate and never fetches (the phase-50 contract the story
|
||
E2E pins), and the ``started`` flag means the listener can only
|
||
re-run a load the mount already made."""
|
||
history_js = (ASSETS / "history.js").read_text(encoding="utf-8")
|
||
assert 'addEventListener("bor:view-refresh"' in history_js, (
|
||
"history.js must listen for the refresh event on the view root"
|
||
)
|
||
gate = history_js.find("if (!(await fetchIsAdmin()))")
|
||
listener = history_js.find('addEventListener("bor:view-refresh"')
|
||
assert 0 <= gate < listener, (
|
||
"the listener is armed only in the ADMIN branch (after the gate)"
|
||
)
|
||
assert re.search(r"if \(started\)\s+loadChats\(\)", history_js), (
|
||
"the listener is gated on the first load (started)"
|
||
)
|
||
# Re-entrancy: a re-load drops the data rows (except the hidden
|
||
# empty-state row) before fetching — the list is replaced, not
|
||
# duplicated.
|
||
load = history_js.find("async function loadChats()")
|
||
assert load != -1, "loadChats must exist"
|
||
load_body = history_js[load : history_js.find("\n }", load)]
|
||
assert "tr !== emptyRow" in load_body and "tr.remove()" in load_body, (
|
||
"loadChats must remove the data rows (the empty row stays) first"
|
||
)
|
||
clear_i = load_body.find("tr !== emptyRow")
|
||
fetch_i = load_body.find('fetch("/api/chats")')
|
||
assert 0 <= clear_i < fetch_i, "the row clearing precedes the fetch"
|
||
|
||
|
||
# ---------- phase 77 task 02: RAG / Sources / Tuning re-fetch; chat stays out ----------
|
||
|
||
|
||
def _asset(name: str) -> str:
|
||
path = ASSETS / name
|
||
assert path.is_file(), f"missing {path}"
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _pin_refresh_listener(js: str, gate: str, listener_call: str, name: str) -> None:
|
||
"""Shared shape of the task-02 pin: the view module listens for
|
||
``bor:view-refresh`` on its own root, the listener re-runs the
|
||
view's existing load, and the listener is armed ONLY in the ADMIN
|
||
branch — after the whoami gate (anonymous never fetches)."""
|
||
listener = js.find('addEventListener("bor:view-refresh"')
|
||
assert listener != -1, f"{name} must listen for the refresh event on the view root"
|
||
gate_i = js.find(gate)
|
||
assert 0 <= gate_i < listener, (
|
||
f"{name}: the listener must be armed in the ADMIN branch (after {gate!r})"
|
||
)
|
||
assert listener_call in js[listener : listener + 120], (
|
||
f"{name}: the listener must re-run the view's load ({listener_call!r})"
|
||
)
|
||
|
||
|
||
def test_rag_view_refetches_on_reshow() -> None:
|
||
"""Phase 77 task 02: the RAG (knowledge base) view re-fetches on a
|
||
user-initiated re-show — sources.js listens and re-runs
|
||
``loadDocs()``. ``loadDocs`` is now re-entrant: the tbody's rows
|
||
are cleared at the TOP, before the fetch (the History pattern from
|
||
task 01), so a refresh from a populated list into an empty result
|
||
replaces the list instead of leaving ghost rows."""
|
||
js = _asset("sources.js")
|
||
_pin_refresh_listener(
|
||
js, "const admin = await fetchIsAdmin();", "() => loadDocs()", "sources.js"
|
||
)
|
||
load = js.find("async function loadDocs()")
|
||
assert load != -1, "loadDocs must exist"
|
||
body = js[load : js.find("\n }", load)]
|
||
clear_i = body.find("tbody.replaceChildren()")
|
||
fetch_i = body.find('fetch("/api/docs")')
|
||
assert 0 <= clear_i < fetch_i, (
|
||
"the row clearing must precede the fetch (a populated → empty refresh "
|
||
"must not leave ghost rows)"
|
||
)
|
||
|
||
|
||
def test_git_sources_view_refetches_on_reshow() -> None:
|
||
"""Phase 77 task 02: the Sources (git-sources) view re-fetches on a
|
||
user-initiated re-show — git-sources.js listens and re-runs
|
||
``loadSources()``. A re-call resets ALL THREE list states: the
|
||
populated render (renderSources replaces the tbody + re-syncs the
|
||
empty state) and the load error (``hideLoadError()`` runs on the
|
||
success path BEFORE rendering, so an error followed by a
|
||
successful refresh clears it)."""
|
||
js = _asset("git-sources.js")
|
||
_pin_refresh_listener(
|
||
js, "const admin = await fetchIsAdmin();", "() => loadSources()", "git-sources.js"
|
||
)
|
||
load = js.find("async function loadSources()")
|
||
assert load != -1, "loadSources must exist"
|
||
body = js[load : js.find("\n }", load)]
|
||
hide_i = body.find("hideLoadError()")
|
||
render_i = body.find("renderSources(")
|
||
assert 0 <= hide_i < render_i, (
|
||
"the success path must clear the load error before rendering "
|
||
"(an error followed by a successful refresh clears the error)"
|
||
)
|
||
render = js.find("function renderSources(")
|
||
assert render != -1, "renderSources must exist"
|
||
render_body = js[render : js.find("\n }", render)]
|
||
assert "tbody.replaceChildren()" in render_body, (
|
||
"a re-render replaces the list (the populated state resets)"
|
||
)
|
||
|
||
|
||
def test_tuning_view_refetches_on_reshow() -> None:
|
||
"""Phase 77 task 02: the Tuning view re-fetches on a user-initiated
|
||
re-show — tuning.js listens and re-runs ``loadNotes()``. A re-call
|
||
replaces the list (renderNotes clears it first); a FAILED refresh
|
||
keeps the last rendered list — loadNotes's documented contract
|
||
(progressive enhancement, never a blanked panel), unchanged by the
|
||
listener (it just calls the function)."""
|
||
js = _asset("tuning.js")
|
||
_pin_refresh_listener(
|
||
js, "if (await fetchIsAdmin())", "() => loadNotes()", "tuning.js"
|
||
)
|
||
render = js.find("function renderNotes(")
|
||
assert render != -1, "renderNotes must exist"
|
||
render_body = js[render : js.find("\n }", render)]
|
||
assert 'tuneList.textContent = ""' in render_body, (
|
||
"a re-render clears the list first (the re-call replaces it)"
|
||
)
|
||
|
||
|
||
def test_chat_view_does_not_listen_for_view_refresh() -> None:
|
||
"""Negative pin: app.js (the chat view) must NOT listen for
|
||
``bor:view-refresh`` — the in-flight SSE stream and the local
|
||
conversation survive EVERY switch (the phase-76 contract the
|
||
stream E2E pins). The exclusion is a contract, not an oversight;
|
||
the deliberate-exclusion comment lives at the chat view's
|
||
module-scope state in app.js."""
|
||
js = _asset("app.js")
|
||
assert 'addEventListener("bor:view-refresh"' not in js, (
|
||
"the chat view must NOT listen for the refresh event — its "
|
||
"in-flight stream and local conversation must survive every "
|
||
"switch (phase 76)"
|
||
)
|
||
assert "bor:view-refresh" in js, (
|
||
"the exclusion is documented at the chat view's module-scope state"
|
||
)
|
||
|
||
|
||
# ---------- phase 77 task 03: the History refresh button ----------
|
||
|
||
|
||
def _history_view(html: str) -> str:
|
||
"""The shell's History view section (the test_history_page pattern):
|
||
from the #view-history open tag to the container main's close
|
||
(the view is the shell's LAST view section)."""
|
||
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 test_history_refresh_button_markup_lives_in_the_page_head() -> None:
|
||
"""Phase 77 task 03 (TODO.md L3): the History page-head carries the
|
||
explicit refresh control — #history-refresh, a ``type="button"``
|
||
``.history-refresh`` with the accessible name
|
||
``aria-label="Refresh saved chats"``, the house inline-SVG refresh
|
||
glyph (aria-hidden) and the visible "Refresh" label (the phase-46
|
||
auth-link convention: label visible ≥640px, icon-only below — the
|
||
aria-label keeps the name in both). It sits INSIDE the view's
|
||
.page-head and BEFORE the table wrap (outside it — the button must
|
||
stay reachable while the empty state is showing)."""
|
||
view = _history_view(_html())
|
||
btn_i = view.find('id="history-refresh"')
|
||
assert btn_i != -1, "the #history-refresh button must exist"
|
||
tag_start = view.rfind("<button", 0, btn_i)
|
||
tag_end = view.find(">", btn_i)
|
||
tag = view[tag_start:tag_end]
|
||
assert 'type="button"' in tag, "a plain button (no form submit)"
|
||
assert 'class="history-refresh"' in tag
|
||
assert 'aria-label="Refresh saved chats"' in tag, ("the accessible name")
|
||
tail = view[tag_end:tag_end + 600]
|
||
assert 'aria-hidden="true"' in tail, "the refresh glyph must be aria-hidden"
|
||
assert '<span class="history-refresh-label">Refresh</span>' in tail, (
|
||
"the visible Refresh label (icon-only below 640px, label above)"
|
||
)
|
||
head_i = view.find('class="page-head"')
|
||
wrap_i = view.find('id="history-table-wrap"')
|
||
assert -1 < head_i < btn_i < wrap_i, (
|
||
"the button sits in the page-head, before (OUTSIDE) the table wrap"
|
||
)
|
||
|
||
|
||
def test_history_refresh_button_binding_admin_only_with_outcome_lines() -> None:
|
||
"""Phase 77 task 03: history.js binds #history-refresh in the ADMIN
|
||
branch only — the anonymous branch HIDES the button (the gate is
|
||
what anonymous sees; no dead control beside the sign-in gate) and
|
||
still fetches nothing. The click handler disables the button
|
||
BEFORE the fetch (no double-fire while in flight) and delegates to
|
||
the re-entrant load; the re-enable sits in a ``finally`` (success
|
||
AND failure — a click can never leave the button stuck disabled).
|
||
The success line is ``Saved chats refreshed.``; the failure lines
|
||
live INSIDE ``loadChats`` itself — the house copy (network:
|
||
"is the app reachable?"; non-2xx: "try again.") — so every caller
|
||
of a failed load (the mount's first load, a re-show, the button)
|
||
sees the outcome in #history-status."""
|
||
js = _asset("history.js")
|
||
gate = js.find("if (!(await fetchIsAdmin()))")
|
||
assert gate != -1
|
||
branch = js[gate:js.find("return;", gate)]
|
||
assert "refreshBtn.hidden = true" in branch, (
|
||
"the anonymous branch hides the button (no dead control)"
|
||
)
|
||
admin_after = js[js.find("return;", gate):]
|
||
bind = admin_after.find('refreshBtn.addEventListener("click"')
|
||
assert bind != -1, "the refresh binding must exist in the admin branch"
|
||
handler = admin_after[bind:admin_after.find(");", bind)]
|
||
assert "refreshBtn.disabled = true" in handler, (
|
||
"the click disables the button before the fetch (no double-fire)"
|
||
)
|
||
# refreshChats is defined alongside loadChats (before the gate) —
|
||
# its BODY is pinned on the whole file, its BINDING on the admin
|
||
# branch above (the hoisted function is only reachable from the
|
||
# admin-branch binding: the anonymous branch never references it).
|
||
fn = js.find("async function refreshChats()")
|
||
assert fn != -1, "refreshChats must exist"
|
||
fn_body = js[fn:js.find("\n }", fn)]
|
||
assert "loadChats()" in fn_body, "the button re-runs the (re-entrant) load"
|
||
assert "finally" in fn_body and "refreshBtn.disabled = false" in fn_body, (
|
||
"the button re-enables on success AND failure (the finally)"
|
||
)
|
||
assert '"Saved chats refreshed."' in fn_body, "the exact success line"
|
||
load = js.find("async function loadChats()")
|
||
load_body = js[load:js.find("\n }", load)]
|
||
assert "Couldn't load saved chats — is the app reachable?" in load_body, (
|
||
"the network-error line lives in loadChats (every caller sees it)"
|
||
)
|
||
assert "Couldn't load saved chats — try again." in load_body, (
|
||
"the non-2xx line lives in loadChats (every caller sees it)"
|
||
)
|
||
|
||
|
||
def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
|
||
"""Phase 77 task 03 (styles.css): .history-refresh reuses the
|
||
.new-chat-btn visual language — the solid brand pill (--bg text on
|
||
--brand, 5.2:1 ≥ WCAG 4.5:1), the ≥44px target, the lightened
|
||
hover fill, the dimmed :disabled (the in-flight state), the glyph
|
||
hidden on desktop (the label carries the pill) — and the global
|
||
:focus-visible ring applies (no button-scoped focus override).
|
||
The page-head flex row is SCOPED to #view-history (the other four
|
||
views' page-heads are untouched). Below 640px the pill goes full
|
||
width with the label visible (the icon-only squeeze is gone —
|
||
the glyph joins the label)."""
|
||
css = _asset("styles.css")
|
||
block = re.search(r"\.history-refresh \{([\s\S]*?)\n\}", css)
|
||
assert block, "styles.css must style .history-refresh"
|
||
body = block.group(1)
|
||
assert "background: var(--brand)" in body, "the .new-chat-btn brand fill"
|
||
assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)"
|
||
assert "min-height: 44px" in body, "the comfortable touch target"
|
||
assert "border-radius: 999px" in body and "border: 0" in body, "the pill"
|
||
assert ".history-refresh:hover { background: #f55a72; color: var(--bg); }" in css
|
||
assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, (
|
||
"the in-flight disabled state is dimmed (the house language)"
|
||
)
|
||
assert ".history-refresh svg { width: 16px; height: 16px; display: none; }" in css, (
|
||
"desktop: the label carries the pill (the glyph is hidden)"
|
||
)
|
||
row = re.search(r"#view-history \.page-head \{([\s\S]*?)\n\}", css)
|
||
assert row and "display: flex" in row.group(1), (
|
||
"the page-head flex row is scoped to the History view"
|
||
)
|
||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
|
||
assert mobile, "the 640px media query must exist"
|
||
mbody = mobile.group(1)
|
||
assert ".history-refresh { width: 100%; }" in mbody, (
|
||
"full width below 640px (the icon-only squeeze is gone)"
|
||
)
|
||
assert ".history-refresh svg { display: block; }" in mbody, (
|
||
"the glyph joins the visible label below 640px"
|
||
)
|
||
|
||
|
||
# ---------- phase 79 task 06: the Tokens view (generate · list · revoke) ----------
|
||
|
||
|
||
def test_tokens_view_module_contract() -> None:
|
||
"""Phase 79 task 06: tokens.js follows the phase-76 view-module
|
||
contract — ``export async function mount(root)`` is the entry, the
|
||
whoami gate (``fetchIsAdmin``) runs in mount and the anonymous
|
||
branch shows the gate + hides the table + RETURNS with NO
|
||
/api/tokens request (the router 403s anonymous), the
|
||
``bor:view-refresh`` listener is armed ONLY in the ADMIN branch
|
||
(after the gate) and re-runs the re-entrant ``loadTokens()``
|
||
(gated on the ``started`` flag), and ``loadTokens`` hides the
|
||
once-block and clears the data rows BEFORE the fetch — the
|
||
plaintext is never re-shown and the list is replaced, not
|
||
duplicated. Every cell is textContent: the file never touches
|
||
innerHTML (XSS-safe by construction)."""
|
||
js = _asset("tokens.js")
|
||
assert "export async function mount(root)" in js, (
|
||
"mount(root) must be the module's entry (the phase-76 fold)"
|
||
)
|
||
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)")
|
||
gate_i = js.find("if (!(await fetchIsAdmin()))")
|
||
assert 0 <= mount_i < gate_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 re-show refresh: armed in the ADMIN branch only (after the
|
||
# gate), gated on the first load (started), re-running loadTokens.
|
||
listener = js.find('addEventListener("bor:view-refresh"')
|
||
assert 0 <= gate_i < listener, (
|
||
"the refresh listener is armed only in the ADMIN branch (after the gate)"
|
||
)
|
||
assert re.search(r"if \(started\)\s+loadTokens\(\)", js), (
|
||
"the listener is gated on the first load (started)"
|
||
)
|
||
# loadTokens: re-entrant — the once-block hides and the data rows
|
||
# (except the hidden empty-state row) are dropped BEFORE the fetch.
|
||
load = js.find("async function loadTokens()")
|
||
assert load != -1, "loadTokens must exist"
|
||
load_body = js[load:js.find("\n }", load)]
|
||
hide_i = load_body.find("onceBlock.hidden = true")
|
||
clear_i = load_body.find("tr !== emptyRow")
|
||
fetch_i = load_body.find('fetch("/api/tokens")')
|
||
assert 0 <= hide_i < clear_i < fetch_i, (
|
||
"once-block hide + row clearing must precede the fetch "
|
||
"(a re-render never re-shows the plaintext; the list is replaced)"
|
||
)
|
||
assert "innerHTML" not in js, (
|
||
"every cell is textContent — no innerHTML anywhere (XSS-safe)"
|
||
)
|
||
|
||
|
||
def test_tokens_view_scaffold_in_the_shell() -> None:
|
||
"""Phase 79 task 06: the shell carries the #view-tokens section —
|
||
hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) —
|
||
with the page-head (h1 \"Access tokens\"), the #tokens-gate (the
|
||
#history-gate pattern, ship-hidden, its Sign in returning to the
|
||
Tokens view), the role=\"status\" live region, the create row
|
||
(label input + Generate — ship-hidden, anonymous-safe), the
|
||
#token-once block (ship-hidden — only a 201 reveals it), and the
|
||
full-width table (AGENTS.md rule 5) with the visually-hidden
|
||
Actions header + the hidden #tokens-empty-row."""
|
||
html = _html()
|
||
view = html.find('<section class="view" id="view-tokens"')
|
||
assert view != -1, "the #view-tokens section must be in the shell"
|
||
tag_end = html.find(">", view)
|
||
tag = html[view:tag_end]
|
||
assert "hidden" in tag and "inert" in tag, (
|
||
"the folded view ships hidden AND inert"
|
||
)
|
||
assert 'tabindex="-1"' in tag, "the target view is focusable"
|
||
main_end = html.find("</main>", view)
|
||
assert view < main_end, "the view section lives inside the single main"
|
||
body = html[view:main_end]
|
||
assert "<h1>Access tokens</h1>" in body
|
||
gate = re.search(r'<section[^>]*id="tokens-gate"[^>]*>', body)
|
||
assert gate and "hidden" in gate.group(0), "#tokens-gate must ship hidden"
|
||
assert 'href="/login.html?next=/tokens.html"' in body, (
|
||
"the gate's Sign in returns to the Tokens view (no-JS fallback)"
|
||
)
|
||
assert re.search(r'<span[^>]*id="tokens-status"[^>]*role="status"[^>]*>', body)
|
||
create = re.search(r'<div[^>]*id="token-create"[^>]*>', body)
|
||
assert create and "hidden" in create.group(0), (
|
||
"the create row ships hidden (anonymous-safe)"
|
||
)
|
||
assert re.search(r'<input[^>]*id="token-label"[^>]*>', body)
|
||
assert re.search(r'<button[^>]*id="token-generate"[^>]*>', body)
|
||
once = re.search(r'<div[^>]*id="token-once"[^>]*>', body)
|
||
assert once and "hidden" in once.group(0), (
|
||
"the once-block ships hidden (only a 201 reveals it)"
|
||
)
|
||
assert re.search(r'<input[^>]*id="token-once-value"[^>]*readonly[^>]*>', body)
|
||
assert re.search(r'<button[^>]*id="token-once-copy"[^>]*>', body)
|
||
wrap = re.search(r'<div[^>]*id="tokens-table-wrap"[^>]*>', body)
|
||
assert wrap and 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
|
||
assert 'id="tokens-tbody"' in body
|
||
assert re.search(r'<tr[^>]*id="tokens-empty-row"[^>]*hidden>', body)
|
||
# The Actions column header is visually-hidden (the row buttons
|
||
# carry their own aria-labels — the history-table convention).
|
||
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
|