All gates verified green — no defects found, nothing to fix. Final report:
## Phase 101 — final verification pass (all tasks already complete; verified, not re-implemented)
**Verified against the working tree** (task files 01–04 present in `complete/`):
- Active/revoked table split (Status column gone, section hidden while 0 revoked, per-table search with query state surviving re-renders) in `frontend/index.html` + `tokens.js`
- `generateToken()` refuses blank/whitespace names (`Give the token a name first.`, no request); `|| "token"` fallback deleted
- Atomic rotation: `regenerate_token` service + `POST /api/tokens/{id}/regenerate` (201 new-plaintext-once / 404 / 409 / router-wide 403) + `TokenListItem.revoked_at` (D5)
- Regenerate two-step confirm UI + CSS (`.token-regenerate`, neutral hover, no new hue); A4 pins intact
**Test / lint / coverage results:**
- `uv run pytest` → **2065 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed** (isolation, DB up)
- Regression, each in isolation: `test_api_tokens.py` **9 passed**, `test_admin_auth.py` **6 passed**, `test_shared_header.py` **6 passed**, `test_theme_semantic_completion.py` **8 passed** (its revoked-pill pin was correctly re-scoped to the revoked table in this phase)
**Completion criteria:** 1 ✓ split+search (E2E 1–2) · 2 ✓ required name (E2E 3 + source pin) · 3 ✓ rotation end-to-end, old token refused at gate (E2E 4 + API 404/409 pinned) · 4 ✓ A4 holds (list carries no plaintext/hashes) · 5 ✓ suite/coverage/lint green · 6 ✓ E2E + regressions green in isolation · 7 commit left to the harness per executor rules (all changes uncommitted in the working tree)
**Deviations:** none. Next pending phase: `98_sync_summary_visibility`.
1513 lines
75 KiB
Python
1513 lines
75 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
|
||
catalog load is re-entrant: the phase-97 ``loadTree`` (one fetch of
|
||
``GET /api/docs/tree``) renders through ``renderLevel``, which clears
|
||
BOTH row containers at the top before filling them, so a refresh from
|
||
a populated level into a sparser (or empty) one 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; phase 91 task 04: theme — all six
|
||
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"
|
||
)
|
||
assert '"/theme.html": "theme"' in view_body, (
|
||
"phase 91 task 04 folds the Theme view into the shell"
|
||
)
|
||
# The view names are the #view-<name> section slugs in index.html.
|
||
for name in ("chat", "tuning", "history", "tokens", "theme"):
|
||
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 'theme: () => import("./theme.js")' in mods_body, (
|
||
"the Theme view module is lazy-imported on first show (phase 91)"
|
||
)
|
||
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
|
||
assert 'theme: "Theme · Brain of Reese"' in js
|
||
assert (
|
||
"Set the palette and branding — the theme is baked into every served page, "
|
||
"live on the first paint."
|
||
) 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"
|
||
# The Theme nav link (phase 91 task 04) 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).
|
||
theme_match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', html)
|
||
assert theme_match, "the shell must carry the #nav-theme nav link"
|
||
theme_link = theme_match.group(0)
|
||
assert "hidden" in theme_link, "#nav-theme ships hidden (admin-only)"
|
||
assert "is-active" not in theme_link, "no static active stamp on the Theme 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 (+ phase 97 task 04): the RAG (knowledge base)
|
||
view re-fetches on a user-initiated re-show — sources.js listens
|
||
and re-runs ``loadTree()`` (the phase-97 catalog load: ONE fetch of
|
||
``GET /api/docs/tree``). The load is race-tokened (phase 79) and
|
||
the RE-ENTRANT render — ``renderLevel`` clears BOTH row containers
|
||
at the TOP before filling them (the History pattern from task 01,
|
||
extended to the folders table) — so a refresh from a populated
|
||
level into a sparser (or empty) result replaces the rows instead
|
||
of leaving ghost rows."""
|
||
js = _asset("sources.js")
|
||
_pin_refresh_listener(
|
||
js, "const admin = await fetchIsAdmin();", "() => loadTree()", "sources.js"
|
||
)
|
||
load = js.find("async function loadTree()")
|
||
assert load != -1, "loadTree must exist"
|
||
body = js[load : js.find("\n }", load)]
|
||
assert 'fetch("/api/docs/tree")' in body, "the load must fetch the tree endpoint"
|
||
assert "++loadSeq" in body, "the race token stays (phase 79)"
|
||
render = js.find("function renderLevel()")
|
||
assert render != -1, "renderLevel must exist"
|
||
render_body = js[render : js.find("\n }", render)]
|
||
for container in ("foldersTbody", "tbody"):
|
||
clear_i = render_body.find(f"{container}.replaceChildren()")
|
||
append_i = render_body.find(f"{container}.appendChild")
|
||
assert 0 <= clear_i < append_i, (
|
||
f"{container}: the clear must precede the fill (a populated → "
|
||
"sparser 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: var(--brand-hover); 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
|
||
|
||
|
||
# ---------- phase 101 task 02: the tokens split (active/revoked
|
||
# tables, the per-table search, the required name) ----------
|
||
|
||
|
||
def test_tokens_view_split_scaffold_in_the_shell() -> None:
|
||
"""Phase 101 task 02 (D1/D3/D4): the #view-tokens skeleton is the
|
||
SPLIT — the active table is FOUR columns (the Status ``<th>`` is
|
||
gone, the Actions header stays visually-hidden), its empty row is
|
||
``colspan=4``, and a NEW ship-hidden #tokens-no-match-row
|
||
(``colspan=4``, empty ``<td>`` — the text is JS-filled) ships in
|
||
the same tbody; the per-table search inputs (#token-search-active
|
||
between the once-block and the active table wrap, #token-search-
|
||
revoked in the revoked section) ship hidden with ``type=search``
|
||
+ aria-label + the .token-search class; the NEW revoked section
|
||
(heading + search + the four-column table with the Revoked ``<th>``
|
||
+ the ship-hidden no-match row) ships hidden and sits AFTER the
|
||
active table's wrap; and the create row's input is the REQUIRED
|
||
name (aria-label "Token name", placeholder
|
||
"e.g. alice — required")."""
|
||
html = _html()
|
||
view = html.find('<section class="view" id="view-tokens"')
|
||
assert view != -1, "the #view-tokens section must be in the shell"
|
||
main_end = html.find("</main>", view)
|
||
body = html[view:main_end]
|
||
# The active table: EXACTLY four columns — and no Status header
|
||
# anywhere in the view (D1: the table IS the status).
|
||
table_i = body.find('<table class="tokens-table" id="tokens-table">')
|
||
assert table_i != -1, "the active #tokens-table must exist"
|
||
thead = body[table_i:body.find("</thead>", table_i)]
|
||
assert thead.count('<th scope="col">') == 4, (
|
||
"the active table is four columns (Label | Created | Last used | Actions)"
|
||
)
|
||
assert '<th scope="col">Status</th>' not in body, (
|
||
"the Status column is gone from BOTH tables (D1)"
|
||
)
|
||
assert '<th scope="col">Revoked</th>' in body, (
|
||
"the revoked table carries the Revoked column"
|
||
)
|
||
# The active tbody: the empty row (colspan=4) + the NEW no-match
|
||
# row (ship-hidden, colspan=4, its <td> text JS-filled).
|
||
empty = re.search(r'<tr[^>]*id="tokens-empty-row"[^>]*hidden>.*?</tr>', body, re.S)
|
||
assert empty and 'colspan="4"' in empty.group(0), (
|
||
"the active empty row is colspan=4"
|
||
)
|
||
no_match = re.search(r'<tr[^>]*id="tokens-no-match-row"[^>]*hidden>', body)
|
||
assert no_match, "the active no-match row ships hidden"
|
||
no_match_td = re.search(
|
||
r'<tr[^>]*id="tokens-no-match-row"[^>]*hidden>\s*<td colspan="4">\s*</td>\s*</tr>',
|
||
body,
|
||
)
|
||
assert no_match_td, "the no-match row is colspan=4 with an EMPTY <td>"
|
||
# The per-table search inputs (D4): type=search, aria-labeled, the
|
||
# house .token-search class, BOTH ship hidden.
|
||
for sid, label in (
|
||
("token-search-active", "Search active tokens"),
|
||
("token-search-revoked", "Search revoked tokens"),
|
||
):
|
||
m = re.search(rf'<input[^>]*id="{sid}"[^>]*>', body, re.S)
|
||
assert m, f"missing the #{sid} search input"
|
||
tag = m.group(0)
|
||
assert 'type="search"' in tag, f"#{sid} must be a search input"
|
||
assert f'aria-label="{label}"' in tag, f"#{sid} must be labeled"
|
||
assert 'class="token-search"' in tag, f"#{sid} must carry the house class"
|
||
assert "hidden" in tag, f"#{sid} ships hidden (anonymous-safe)"
|
||
# The create row: the name is REQUIRED (D3) — the new aria-label +
|
||
# placeholder.
|
||
label_in = re.search(r'<input[^>]*id="token-label"[^>]*>', body, re.S)
|
||
assert label_in, "the create row's name input must exist"
|
||
assert 'aria-label="Token name"' in label_in.group(0)
|
||
assert 'placeholder="e.g. alice — required"' in label_in.group(0)
|
||
# The active search sits BETWEEN the once-block and the active
|
||
# table's wrap.
|
||
assert (
|
||
body.find('id="token-once"')
|
||
< body.find('id="token-search-active"')
|
||
< body.find('id="tokens-table-wrap"')
|
||
), "the active search is between the once-block and the table wrap"
|
||
# The revoked section (D1): heading + search + wrap — ALL ship
|
||
# hidden — and it sits BELOW the active table's wrap.
|
||
heading = re.search(
|
||
r'<h2[^>]*id="tokens-revoked-heading"[^>]*>Revoked tokens</h2>', body
|
||
)
|
||
assert heading and "hidden" in heading.group(0), (
|
||
"the revoked sub-heading ships hidden with its visible text"
|
||
)
|
||
assert 'class="tokens-revoked-heading"' in heading.group(0)
|
||
wrap = re.search(r'<div[^>]*id="tokens-revoked-wrap"[^>]*>', body)
|
||
assert wrap and "hidden" in wrap.group(0), ("the revoked wrap ships hidden")
|
||
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
|
||
assert 'aria-label="Revoked tokens"' in wrap.group(0)
|
||
assert 'id="tokens-revoked-tbody"' in body
|
||
assert "Revoked tokens — newest first" in body, "the revoked table caption"
|
||
assert re.search(
|
||
r'<tr[^>]*id="tokens-revoked-no-match-row"[^>]*hidden>', body
|
||
), "the revoked no-match row ships hidden"
|
||
assert body.find('id="tokens-table-wrap"') < body.find(
|
||
'id="tokens-revoked-heading"'
|
||
), "the revoked section sits BELOW the active table's wrap"
|
||
|
||
|
||
def test_tokens_js_split_search_and_required_name() -> None:
|
||
"""Phase 101 task 02 (D1/D3/D4) in tokens.js: the module state
|
||
carries the persistent per-table queries (initialized ""), the
|
||
scoped lookups cover the new ids, ``loadTokens`` SPLITS the
|
||
fetched list by ``tok.revoked`` (the server order kept per table),
|
||
shows the active empty row iff zero ACTIVE rows, shows the revoked
|
||
section (heading + search + wrap) iff ≥1 revoked row via the
|
||
``setRevokedSectionVisible`` helper, and RE-APPLIES both filters
|
||
after every render (a load never loses the queries); ``makeRow``
|
||
takes the table (the revoked variant renders the revoked_at date,
|
||
no actions); ``applyFilter`` is pure DOM (case-insensitive label
|
||
substring over the data rows — the state rows excluded — the
|
||
no-match copy quotes the ORIGINAL query in textContent); the input
|
||
listeners are armed in the ADMIN branch (after the whoami gate) and
|
||
set the module query + applyFilter with NO fetch; and a blank name
|
||
is refused client-side — the exact announce line, the re-focus, the
|
||
early return BEFORE any fetch, and the deleted "token" fallback."""
|
||
js = _asset("tokens.js")
|
||
# Module state: the persistent queries, initialized once.
|
||
assert re.search(r"let activeQuery = \"\";", js), (
|
||
"the active search query is module state (initialized '')"
|
||
)
|
||
assert re.search(r"let revokedQuery = \"\";", js), (
|
||
"the revoked search query is module state (initialized '')"
|
||
)
|
||
# The new scoped lookups (the phase-76 root-scoping contract).
|
||
for sid in (
|
||
"tokens-no-match-row",
|
||
"token-search-active",
|
||
"tokens-revoked-heading",
|
||
"token-search-revoked",
|
||
"tokens-revoked-wrap",
|
||
"tokens-revoked-tbody",
|
||
"tokens-revoked-no-match-row",
|
||
):
|
||
assert f'querySelector("#{sid}")' in js, f"missing the #{sid} lookup"
|
||
# makeRow: the table parameter + the revoked variant (the
|
||
# revoked_at date cell, no actions).
|
||
assert "function makeRow(tok, table)" in js, "makeRow takes the table"
|
||
assert 'table === "revoked"' in js, "the revoked variant branches on the table"
|
||
assert "revoked_at" in js, "the Revoked cell renders tok.revoked_at"
|
||
# loadTokens: the split (per-table appends, server order kept),
|
||
# the section helper call, the BOTH filters re-applied after the
|
||
# render.
|
||
load = js.find("async function loadTokens()")
|
||
assert load != -1, "loadTokens must exist"
|
||
load_body = js[load:js.find("\n }", load)]
|
||
assert 'makeRow(tok, "active")' in load_body, "active rows render into the active table"
|
||
assert 'makeRow(tok, "revoked")' in load_body, "revoked rows render into the revoked table"
|
||
split_i = load_body.find(".revoked")
|
||
assert split_i != -1, "the split keys off tok.revoked (the D5 bool)"
|
||
assert "setRevokedSectionVisible(revoked.length)" in load_body, (
|
||
"the revoked section shows iff ≥1 revoked row"
|
||
)
|
||
fetch_i = load_body.find('fetch("/api/tokens")')
|
||
active_apply = load_body.find("applyFilter(tbody, noMatchRow, activeQuery)")
|
||
revoked_apply = load_body.find(
|
||
"applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery)"
|
||
)
|
||
assert 0 <= fetch_i < active_apply < revoked_apply, (
|
||
"BOTH filters re-apply after the fetch + render (D4: a re-render "
|
||
"never loses the queries)"
|
||
)
|
||
# The section show/hide helper: heading + search + wrap together.
|
||
helper = js.find("function setRevokedSectionVisible")
|
||
assert helper != -1, "the setRevokedSectionVisible(n) helper must exist"
|
||
helper_body = js[helper:js.find("\n }", helper)]
|
||
for name in ("revokedHeading.hidden", "searchRevoked.hidden", "revokedWrap.hidden"):
|
||
assert name in helper_body, f"the section helper must toggle {name}"
|
||
# applyFilter: pure DOM — case-insensitive label substring over
|
||
# the data rows (the no-match/empty state rows excluded), the
|
||
# no-match row visible ⟺ non-empty query + zero visible rows, its
|
||
# <td> textContent carries the ORIGINAL query in quotes.
|
||
f = js.find("function applyFilter(")
|
||
assert f != -1, "applyFilter must exist"
|
||
f_body = js[f:js.find("\n }", f)]
|
||
assert ".toLowerCase()" in f_body, "the match is case-insensitive"
|
||
assert "tr === targetNoMatchRow" in f_body, (
|
||
"the no-match row is never treated as a data row"
|
||
)
|
||
assert "emptyRow" in f_body, "the empty-state row is not a data row"
|
||
assert ".tokens-label-cell" in f_body, (
|
||
"the label cell is the filter's data source"
|
||
)
|
||
assert 'No tokens match "${' in f_body, (
|
||
"the no-match copy (the user's original query in quotes, textContent)"
|
||
)
|
||
assert "innerHTML" not in f_body, "applyFilter is textContent-only"
|
||
# The input listeners: armed in the ADMIN branch only (after the
|
||
# whoami gate), they set the module query + applyFilter — NO
|
||
# fetch (D4 is client-side).
|
||
gate_i = js.find("if (!(await fetchIsAdmin()))")
|
||
assert gate_i != -1
|
||
for i, var in (
|
||
(js.find('searchActive.addEventListener("input"'), "activeQuery"),
|
||
(js.find('searchRevoked.addEventListener("input"'), "revokedQuery"),
|
||
):
|
||
assert i != -1, f"the {var} input listener must be armed"
|
||
assert gate_i < i, f"the {var} listener is armed in the ADMIN branch (after the gate)"
|
||
seg = js[i:js.find("});", i)]
|
||
assert f"{var} =" in seg, f"the listener writes the {var} module state"
|
||
assert "fetch(" not in seg, "the search is client-side (no fetch)"
|
||
branch = js[gate_i:js.find("return;", gate_i)]
|
||
assert "searchActive.hidden = true" in branch, (
|
||
"the anonymous branch keeps the active search hidden"
|
||
)
|
||
assert "searchActive.hidden = false" in js, (
|
||
"the admin branch reveals the active search (with the create row)"
|
||
)
|
||
# The required name (D3): the exact line + re-focus + the early
|
||
# return BEFORE any fetch; the old fallback is GONE from the file.
|
||
gen = js.find("async function generateToken()")
|
||
assert gen != -1, "generateToken must exist"
|
||
gen_body = js[gen:]
|
||
check_i = gen_body.find("if (!label)")
|
||
fetch_i = gen_body.find('fetch("/api/tokens"')
|
||
assert 0 <= check_i < fetch_i, (
|
||
"the blank-name check runs BEFORE any request (D3: the request "
|
||
"simply doesn't happen)"
|
||
)
|
||
assert 'announce("Give the token a name first.")' in gen_body, (
|
||
"the exact D3 live-region line"
|
||
)
|
||
assert "labelInput.focus()" in gen_body, "the name input re-focuses"
|
||
assert '|| "token"' not in js, (
|
||
"the old blank-label 'token' fallback is DELETED (D3)"
|
||
)
|
||
|
||
|
||
def test_tokens_split_css_pins() -> None:
|
||
"""Phase 101 task 02: styles.css carries the .token-search surface
|
||
(full width, the ≥44px target, the house input family — --line
|
||
hairline, --surface fill, ink text — no new hue) and the
|
||
.tokens-revoked-heading sub-heading (the phase-97 .kb-level h2
|
||
voice: mono, 1rem, brand-ink); the empty/no-match rows' styling
|
||
stays CLASS-based (.tokens-empty-row — both tables covered)."""
|
||
css = _asset("styles.css")
|
||
block = re.search(r"\.token-search \{([\s\S]*?)\n\}", css)
|
||
assert block, "styles.css must style .token-search"
|
||
body = block.group(1)
|
||
assert "width: 100%" in body, "the search input is full width"
|
||
assert "min-height: 44px" in body, "the ≥44px touch target"
|
||
assert "border: 1px solid var(--line)" in body, "the house input hairline"
|
||
assert "background: var(--surface)" in body, "the house input surface"
|
||
heading = re.search(r"\.tokens-revoked-heading \{([\s\S]*?)\n\}", css)
|
||
assert heading, "styles.css must style .tokens-revoked-heading"
|
||
hbody = heading.group(1)
|
||
assert "font-family: var(--mono)" in hbody, "the .kb-level h2 voice (mono)"
|
||
assert "font-size: 1rem" in hbody
|
||
assert "color: var(--brand-ink)" in hbody, "brand-ink — AA on the page background"
|
||
assert re.search(r"\.tokens-empty-row td \{", css), (
|
||
"the empty/no-match rows' styling is CLASS-based (both tables)"
|
||
)
|
||
|
||
|
||
# ---------- phase 101 task 03: the Regenerate control (rotation) ----------
|
||
|
||
|
||
def test_tokens_js_regenerate_control() -> None:
|
||
"""Phase 101 task 03 (D2) in tokens.js: the active row's Actions
|
||
cell appends the Regenerate control BEFORE the Revoke control, and
|
||
EACH control owns its OWN .tokens-actions wrapper span (a confirm
|
||
in one never clobbers the other — the shared cell hosts two
|
||
independent confirm scopes); makeRegenerateControl is a structural
|
||
mirror of makeRevokeControl — the .token-regenerate button (label
|
||
"Regenerate", aria-label "Regenerate token: <label>"), the first
|
||
click swaps the cell to the confirm pair (the EXACT text
|
||
"Regenerate? The current token is revoked.", the
|
||
history-confirm-yes/no classes, focus to Yes), No / a failure
|
||
restore via restoreRegenerate (the button back, focus restored);
|
||
confirmRegenerate disables the Yes button while in flight, POSTs
|
||
/api/tokens/<id>/regenerate (no body), and on 201 runs
|
||
loadTokens() FIRST, THEN reveals the once-block (the value-only
|
||
contract), THEN announces the D2 line; a 404 removes the row +
|
||
re-fetches + the house 404 line; a 409 re-fetches + the same
|
||
line; any other failure / network error announces the neutral
|
||
copy and restores (retryable)."""
|
||
js = _asset("tokens.js")
|
||
# The Actions cell order: Regenerate FIRST (the primary lifecycle
|
||
# action), both controls appended in the active variant.
|
||
row_i = js.find("function makeRow(tok, table)")
|
||
assert row_i != -1, "makeRow must exist"
|
||
row_body = js[row_i:js.find("\n }", row_i)]
|
||
assert "actionsTd.append(" in row_body, (
|
||
"the Actions cell hosts BOTH controls"
|
||
)
|
||
regen_i = row_body.find("makeRegenerateControl(tok, tr)")
|
||
revoke_i = row_body.find("makeRevokeControl(tok, tr)")
|
||
assert 0 <= regen_i < revoke_i, (
|
||
"the active Actions cell appends Regenerate BEFORE Revoke (D2)"
|
||
)
|
||
# The per-control wrapper spans: EACH control owns its OWN
|
||
# .tokens-actions span (the two swap-scopes are independent).
|
||
mk_i = js.find("function makeRegenerateControl(")
|
||
assert mk_i != -1, "makeRegenerateControl must exist"
|
||
mk_body = js[mk_i:js.find("\n }", mk_i)]
|
||
assert 'cell.className = "tokens-actions"' in mk_body, (
|
||
"the Regenerate control owns its OWN .tokens-actions wrapper span"
|
||
)
|
||
mkr_i = js.find("function makeRevokeControl(")
|
||
assert mkr_i != -1, "makeRevokeControl must exist"
|
||
mkr_body = js[mkr_i:js.find("\n }", mkr_i)]
|
||
assert 'cell.className = "tokens-actions"' in mkr_body, (
|
||
"the Revoke control keeps its OWN .tokens-actions wrapper span"
|
||
)
|
||
# The button: the .token-regenerate class, the label, the
|
||
# aria-label (the row buttons carry their own aria-labels — the
|
||
# house convention).
|
||
assert 'regenBtn.className = "token-regenerate"' in mk_body, (
|
||
"the button carries the .token-regenerate class"
|
||
)
|
||
assert 'regenBtn.textContent = "Regenerate"' in mk_body, (
|
||
"the button is labeled Regenerate"
|
||
)
|
||
assert "Regenerate token: ${tok.label}" in mk_body, (
|
||
"the aria-label names the token (Regenerate token: <label>)"
|
||
)
|
||
# The two-step swap: the EXACT confirm text, the history-confirm-*
|
||
# pair, focus to Yes.
|
||
assert (
|
||
'label.textContent = "Regenerate? The current token is revoked."'
|
||
in mk_body
|
||
), "the confirm text is EXACT (the D2 copy)"
|
||
assert 'label.className = "history-confirm-text"' in mk_body
|
||
assert 'yes.className = "history-confirm-yes"' in mk_body, (
|
||
"the Yes button reuses the house confirm class"
|
||
)
|
||
assert 'no.className = "history-confirm-no"' in mk_body, (
|
||
"the No button reuses the house confirm class"
|
||
)
|
||
assert "yes.focus()" in mk_body, "focus moves to Yes (keyboard confirm)"
|
||
# The restore path: No and a failure bring the button back, focus
|
||
# restored (the revoke control's restore pattern, copied).
|
||
assert 'no.addEventListener("click", restoreRegenerate)' in mk_body, (
|
||
"No restores the Regenerate button"
|
||
)
|
||
assert "cell.replaceChildren(regenBtn)" in mk_body, (
|
||
"the restore swaps the cell back to the button"
|
||
)
|
||
assert "regenBtn.focus()" in mk_body, "the restore returns the focus"
|
||
# confirmRegenerate: the disabled-while-in-flight Yes, the POST
|
||
# path (JSON, NO body — the revoke control's request shape).
|
||
cr_i = js.find("async function confirmRegenerate(")
|
||
assert cr_i != -1, "confirmRegenerate must exist"
|
||
body = js[cr_i:js.find("\n }", cr_i)]
|
||
dis_i = body.find("yesBtn.disabled = true")
|
||
fetch_i = body.find("/regenerate")
|
||
assert 0 <= dis_i < fetch_i, (
|
||
"the Yes button disables BEFORE the request (no double-fire)"
|
||
)
|
||
assert (
|
||
'fetch(`/api/tokens/${tok.id}/regenerate`, { method: "POST" })'
|
||
in body
|
||
), "the POST path is /api/tokens/<id>/regenerate with NO body"
|
||
# The 201 sequence (pinned order): the JSON parse, the re-entrant
|
||
# loadTokens() FIRST (the relocation), the once-block reveal
|
||
# (value only — the A4 contract), THEN the D2 live-region line.
|
||
json_i = body.find("const data = await r.json()")
|
||
load_i = body.find("await loadTokens()", json_i)
|
||
reveal_i = body.find("onceValue.value = data.token", json_i)
|
||
show_i = body.find("onceBlock.hidden = false", json_i)
|
||
ann_i = body.find('Regenerated "${tok.label}"', json_i)
|
||
assert 0 <= json_i < load_i < reveal_i < show_i < ann_i, (
|
||
"on 201 the load runs FIRST, then the once-block reveal, then "
|
||
"the D2 line (D2's pinned sequence)"
|
||
)
|
||
assert (
|
||
'Regenerated "${tok.label}" — copy the new token now; '
|
||
"it won't be shown again."
|
||
) in body, "the D2 live-region line is EXACT"
|
||
# The 404: the row vanished — row.remove() + the re-fetch
|
||
# (reconciliation) + the house 404 line (the revoke control's
|
||
# existing copy — one house message for the one common case).
|
||
i404 = body.find("r.status === 404")
|
||
i409 = body.find("r.status === 409")
|
||
iok = body.find("if (!r.ok)")
|
||
assert 0 <= i404 < i409 < iok, (
|
||
"the 404/409 branches precede the generic failure"
|
||
)
|
||
seg404 = body[i404:i409]
|
||
assert "row.remove()" in seg404, "the 404 removes the vanished row"
|
||
assert "await loadTokens()" in seg404, (
|
||
"the 404 re-fetches (the reconciliation)"
|
||
)
|
||
assert 'announce("That token was already revoked.")' in seg404, (
|
||
"the 404 reuses the house line"
|
||
)
|
||
seg409 = body[i409:iok]
|
||
assert "await loadTokens()" in seg409, (
|
||
"the 409 re-fetches (the row was revoked between render and click)"
|
||
)
|
||
assert 'announce("That token was already revoked.")' in seg409, (
|
||
"the 409 lands the SAME house line"
|
||
)
|
||
# The retryable failures: the neutral two-line house copy + the
|
||
# restore (the button back) — network and non-2xx alike.
|
||
assert (
|
||
'announce(`Couldn\'t regenerate "${tok.label}" — is the app reachable?`)' in body
|
||
), "the network line is the house two-line convention"
|
||
assert (
|
||
'announce(`Couldn\'t regenerate "${tok.label}" — try again.`)' in body
|
||
), "the non-2xx line is the neutral retry copy"
|
||
assert body.count("restoreRegenerate();") == 2, (
|
||
"BOTH the network and the non-2xx failures restore the button (retryable)"
|
||
)
|
||
assert "innerHTML" not in body, "textContent only (XSS-safe by construction)"
|
||
|
||
|
||
def test_tokens_regenerate_css_pins() -> None:
|
||
"""Phase 101 task 03: styles.css carries the .token-regenerate
|
||
button — the .token-revoke's structural twin (the same ≥44px
|
||
target, --line hairline, radius, transparent fill, ink-soft text)
|
||
with the NEUTRAL action's hover (brand-soft / brand-ink — NOT the
|
||
revoke's error hover: a rotation is a hand-out, not a deletion) and
|
||
a :disabled rule consistent with the revoke button's (the dimmed
|
||
in-flight state) — no new hue (the phase-92 monochrome invariant);
|
||
the confirm pair reuses the existing .history-confirm-* rules
|
||
unchanged."""
|
||
css = _asset("styles.css")
|
||
block = re.search(r"\.token-regenerate \{([\s\S]*?)\n\}", css)
|
||
assert block, "styles.css must style .token-regenerate"
|
||
body = block.group(1)
|
||
assert "min-height: 44px" in body, (
|
||
"the ≥44px touch target (the .token-revoke twin)"
|
||
)
|
||
assert "border: 1px solid var(--line)" in body, "the house ghost hairline"
|
||
assert "border-radius: var(--radius-sm)" in body, "the house radius"
|
||
assert "background: transparent" in body, "the transparent fill"
|
||
assert "color: var(--ink-soft)" in body, "the ghost text (ink-soft)"
|
||
assert "var(--err-" not in body, "no new hue — the monochrome invariant"
|
||
hover = re.search(r"\.token-regenerate:hover:not\(:disabled\) \{([\s\S]*?)\}", css)
|
||
assert hover, "the neutral hover rule (gated like the revoke's)"
|
||
hbody = hover.group(1)
|
||
assert "var(--brand-soft)" in hbody, "the hover fill is the NEUTRAL brand-soft"
|
||
assert "var(--brand-ink)" in hbody, "the hover text is brand-ink"
|
||
assert "var(--err-" not in hbody, (
|
||
"the hover is NOT the revoke's error hover (rotation ≠ deletion)"
|
||
)
|
||
disabled = re.search(r"\.token-regenerate:disabled \{([\s\S]*?)\}", css)
|
||
assert disabled, "the :disabled state (the in-flight look)"
|
||
dbody = disabled.group(1)
|
||
assert "opacity: 0.5" in dbody and "cursor: wait" in dbody, (
|
||
"consistent with the revoke button's disabled rule"
|
||
)
|
||
|
||
|
||
# ---------- phase 91 task 04: the Theme view (skeleton) ----------
|
||
|
||
|
||
def test_theme_view_scaffold_in_the_shell() -> None:
|
||
"""Phase 91 task 04: the shell carries the #view-theme section —
|
||
hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) —
|
||
with the #theme-gate (the EXACT #sources-gate pattern, ship-hidden,
|
||
its Sign in returning to the Theme view via ?next=/theme.html) and
|
||
the ship-hidden #theme-content (the #git-sources-content pattern)
|
||
holding the STATIC form skeleton: the page-head (h1 "Theme"), the
|
||
#theme-form with the 3 labeled branding text inputs (maxlength=300
|
||
— the server re-validates) + the 17 labeled type=color palette
|
||
inputs (the 9 identity variables, then the 8 semantic state
|
||
variables in the State colors fieldset — phase 93 — in the
|
||
theming.COLOR_FIELDS order), the
|
||
#theme-save (primary) + #theme-reset (secondary) — BOTH type="button"
|
||
(no real submit), and the three task-05 feedback lines: #theme-error
|
||
(role=alert), #theme-result (role=status), #theme-contrast
|
||
(role=alert) — all ship hidden. The editor behavior (populate,
|
||
live preview, Save/Reset, the contrast warnings) lands in task 05;
|
||
this pin keeps the E2E-stable skeleton from drifting."""
|
||
html = _html()
|
||
view = html.find('<section class="view" id="view-theme"')
|
||
assert view != -1, "the #view-theme 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]
|
||
# The gate: the exact #sources-gate pattern (class + ship-hidden +
|
||
# its ?next= returning to the Theme view — the no-JS fallback).
|
||
gate = re.search(r'<section[^>]*id="theme-gate"[^>]*>', body)
|
||
assert gate and "hidden" in gate.group(0), "#theme-gate must ship hidden"
|
||
assert 'class="sources-gate"' in gate.group(0), (
|
||
"the gate reuses the .sources-gate visual language"
|
||
)
|
||
assert "<h2 id=\"theme-gate-title\">Sign in to change the theme</h2>" in body
|
||
assert 'href="/login.html?next=/theme.html"' in body, (
|
||
"the gate's Sign in returns to the Theme view (no-JS fallback)"
|
||
)
|
||
# The content ships hidden (theme.js reveals it for admin only —
|
||
# the #git-sources-content pattern).
|
||
content = re.search(r'<div[^>]*id="theme-content"[^>]*>', body)
|
||
assert content and "hidden" in content.group(0), (
|
||
"#theme-content must ship hidden (anonymous-safe)"
|
||
)
|
||
# The static form skeleton (the E2E-stable-selectors house
|
||
# convention): the 3 labeled branding text inputs (maxlength=300)
|
||
# and the 17 labeled type=color palette inputs (the 9 identity
|
||
# variables, then the 8 semantic state variables — phase 93 —
|
||
# one per theming.COLOR_FIELDS field).
|
||
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
|
||
"the #theme-form must be STATIC markup in the shell"
|
||
)
|
||
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
||
assert re.search(
|
||
rf'<label[^>]*for="{field_id}"[^>]*>', body
|
||
), f"missing the visible label for #{field_id}"
|
||
assert re.search(
|
||
rf'<input[^>]*id="{field_id}"[^>]*maxlength="300"[^>]*>', body
|
||
), f"#{field_id} must be a text input with maxlength=300"
|
||
for field_id in (
|
||
"theme-bg",
|
||
"theme-surface",
|
||
"theme-ink",
|
||
"theme-ink-soft",
|
||
"theme-line",
|
||
"theme-grid-line",
|
||
"theme-brand",
|
||
"theme-brand-soft",
|
||
"theme-brand-ink",
|
||
# The 8 semantic state pickers (phase 93 — the State colors
|
||
# fieldset after the palette fieldset).
|
||
"theme-ok-bg",
|
||
"theme-ok-ink",
|
||
"theme-err-bg",
|
||
"theme-err-ink",
|
||
"theme-err-line",
|
||
"theme-accent-bg",
|
||
"theme-accent-ink",
|
||
"theme-accent-line",
|
||
):
|
||
assert re.search(
|
||
rf'<label[^>]*for="{field_id}"[^>]*>', body
|
||
), f"missing the visible label for #{field_id}"
|
||
assert re.search(
|
||
rf'<input[^>]*id="{field_id}"[^>]*type="color"[^>]*>', body
|
||
), f"#{field_id} must be a type=color input"
|
||
# Save (primary) + Reset (secondary) — BOTH type="button" (no real
|
||
# submit; theme.js owns the onsubmit handling + the §7.4 lifecycle).
|
||
save = re.search(r'<button[^>]*id="theme-save"[^>]*>', body)
|
||
assert save and 'type="button"' in save.group(0), (
|
||
"#theme-save must be a type=button (no real submit)"
|
||
)
|
||
reset = re.search(r'<button[^>]*id="theme-reset"[^>]*>', body)
|
||
assert reset and 'type="button"' in reset.group(0), (
|
||
"#theme-reset must be a type=button (no real submit)"
|
||
)
|
||
assert "Save theme" in body, "the Save button's label"
|
||
assert "Reset to defaults" in body, "the Reset button's label"
|
||
# The three task-05 feedback lines, all ship hidden.
|
||
assert re.search(r'<[^>]*id="theme-error"[^>]*role="alert"[^>]*hidden', body)
|
||
assert re.search(r'<[^>]*id="theme-result"[^>]*role="status"[^>]*hidden', body)
|
||
assert re.search(r'<[^>]*id="theme-contrast"[^>]*role="alert"[^>]*hidden', body)
|
||
|
||
|
||
def test_theme_nav_link_ships_on_every_page_header() -> None:
|
||
"""Phase 91 task 04: the phase-34 one-bar contract — the SAME nav
|
||
ships on every page (test_nav_consistency pins the header inventory
|
||
PARITY across the shell pages, the document viewer, and the login
|
||
page), so #nav-theme (ship-hidden, admin-only) must be in the
|
||
#app-nav of EVERY header-bearing page: the shell + document.html +
|
||
login.html + shared.html. The doc-edit flow page ships the reduced
|
||
header (no admin links at all) and is out of the contract."""
|
||
for page in (
|
||
FRONTEND / "index.html",
|
||
FRONTEND / "document.html",
|
||
FRONTEND / "login.html",
|
||
FRONTEND / "shared.html",
|
||
):
|
||
text = page.read_text(encoding="utf-8")
|
||
match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', text)
|
||
assert match, f"{page.name} must carry the #nav-theme nav link (one-bar)"
|
||
link = match.group(0)
|
||
assert 'href="/theme.html"' in link, f"{page.name}: the Theme link's href"
|
||
assert "hidden" in link, (
|
||
f"{page.name}: #nav-theme ships hidden (admin-only)"
|
||
)
|
||
assert "is-active" not in link, (
|
||
f"{page.name}: no static active stamp on the Theme link"
|
||
)
|
||
|
||
|
||
def test_header_js_reveals_the_theme_link_for_admin_only() -> None:
|
||
"""Phase 91 task 04: header.js reveals #nav-theme for role admin —
|
||
the same ship-hidden / reveal-for-admin contract as the other
|
||
admin-only links: the two-line reveal (`hidden = !admin`) sits in
|
||
initSharedHeader, null-safe (a page without the link is a no-op),
|
||
and the gate is the `admin` flag (role === "admin") — a token user
|
||
(role "user") never sees the link."""
|
||
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
|
||
fn = header_js.find("export async function initSharedHeader")
|
||
assert fn != -1, "initSharedHeader must exist"
|
||
body = header_js[fn:]
|
||
lookup = body.find('document.querySelector("#nav-theme")')
|
||
assert lookup != -1, "header.js must look up #nav-theme"
|
||
reveal = body.find("navTheme.hidden = !admin")
|
||
assert 0 <= lookup < reveal, (
|
||
"the reveal must be the two-line pattern: null-safe lookup, "
|
||
"then hidden = !admin (the admin flag — role === \"admin\")"
|
||
)
|
||
# The lookup + reveal sit AFTER the whoami resolution (the admin
|
||
# flag exists only once fetchWhoami has settled).
|
||
whoami = body.find("const whoami = await fetchWhoami()")
|
||
admin_flag = body.find('const admin = whoami.role === "admin"')
|
||
assert 0 <= whoami < admin_flag < lookup, (
|
||
"the reveal keys off the resolved admin flag"
|
||
)
|