feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
This commit is contained in:
@@ -36,6 +36,45 @@ active stamps) are gone with the four folded view documents, so
|
||||
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
|
||||
|
||||
@@ -64,8 +103,9 @@ def _html() -> str:
|
||||
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 —
|
||||
all four non-chat navbar views are in)."""
|
||||
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"
|
||||
@@ -80,8 +120,11 @@ def test_view_map_covers_the_shell_paths() -> None:
|
||||
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"):
|
||||
for name in ("chat", "tuning", "history", "tokens"):
|
||||
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
|
||||
|
||||
|
||||
@@ -172,6 +215,9 @@ def test_only_non_chat_views_have_lazy_modules() -> None:
|
||||
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"
|
||||
|
||||
@@ -227,6 +273,8 @@ def test_router_writes_active_state_title_and_meta() -> None:
|
||||
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
|
||||
@@ -291,6 +339,15 @@ def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
|
||||
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 ----------
|
||||
@@ -352,3 +409,456 @@ def test_boot_order_is_brand_app_router() -> None:
|
||||
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."""
|
||||
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)")
|
||||
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
|
||||
icon-only (the phase-46 auth-link convention — the aria-label
|
||||
keeps the accessible name)."""
|
||||
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-label { display: none; }" in mbody, (
|
||||
"icon-only below 640px (the phase-46 convention)"
|
||||
)
|
||||
assert ".history-refresh svg { display: block; }" in mbody, (
|
||||
"the glyph is the whole control 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
|
||||
|
||||
Reference in New Issue
Block a user