"""Unit: the shared header module contract (phase 19). The browser behavior is E2E-covered (tests/e2e/test_shared_header.py); here we pin the source-level wiring — the header.js exports, the cached whoami promise, the per-page HTML ids (anonymous-safe hidden-by-default controls), the sign-out binding move out of app.js, the SINGLE module-owned New chat binding (phase 34 task 02) + the sign-in ?next= rewrite, and the viewer-bar CSS — so a silent regression is catched without a browser. """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" ASSETS = FRONTEND / "assets" HEADER_JS = ASSETS / "header.js" APP_JS = ASSETS / "app.js" SOURCES_JS = ASSETS / "sources.js" DOCUMENT_JS = ASSETS / "document.js" LOGIN_JS = ASSETS / "login.js" TUNING_JS = ASSETS / "tuning.js" HISTORY_JS = ASSETS / "history.js" STYLES_CSS = ASSETS / "styles.css" # Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both # views are folded into the shell (index.html); both files are deleted. INDEX_HTML = FRONTEND / "index.html" DOCUMENT_HTML = FRONTEND / "document.html" LOGIN_HTML = FRONTEND / "login.html" def _text(path: Path) -> str: assert path.is_file(), f"missing frontend file: {path}" return path.read_text(encoding="utf-8") def _script_srcs(path: Path) -> list[str]: return re.findall(r']*src="([^"]+)"', _text(path)) # ---------- header.js: the module itself ---------- def test_header_module_exports_the_header_functions() -> None: """header.js must export the functions every page script imports (fetchWhoami — the phase-79 canonical call — fetchIsAdmin, its phase-16/19 backward-compatible delegation, initSharedHeader, clearChatStorage) plus resetWhoami (the phase-79 cache invalidation the token gate uses after a mid-page auth).""" js = _text(HEADER_JS) assert "export function fetchWhoami" in js assert "export function fetchIsAdmin" in js assert "export function resetWhoami" in js assert "export async function initSharedHeader" in js assert "export function clearChatStorage" in js def test_whoami_fetch_is_cached_in_a_module_level_promise() -> None: """The whoami fetch is cached in the module-level `whoamiPromise` marker (phase 79, task 05: it stores the FULL response — { authenticated, role } — not just the admin flag) — first call stores the promise, later calls return it, so a page makes exactly ONE /api/whoami request per load no matter how many consumers await it. Anonymous-safe: non-2xx / network failure / malformed body all resolve to { authenticated: false, role: "anonymous" }. The string `fetch("/api/whoami")` appears in this file EXACTLY ONCE — the single-request contract (the rest of the frontend goes through fetchWhoami/fetchIsAdmin).""" js = _text(HEADER_JS) assert re.search(r"let\s+whoamiPromise\s*=\s*null", js), ( "module-level whoamiPromise marker missing" ) assert js.count('fetch("/api/whoami")') == 1, ( "the SINGLE /api/whoami call site lives in header.js exactly once" ) assert "if (!whoamiPromise)" in js, "fetchWhoami must reuse the stored promise" assert "return whoamiPromise" in js assert 'role: "anonymous"' in js, "the anonymous fallback carries the role" assert ".catch(() => ANONYMOUS_WHOAMI)" in js, ( "network failure must resolve to the anonymous role" ) def test_fetch_is_admin_delegates_to_fetch_whoami() -> None: """Phase 79 (task 05): fetchIsAdmin() is a thin delegation — fetchWhoami().then(w => w.role === "admin"): SAME single request, all phase-16/19 callers keep working, and a token user (role "user") reads FALSE here (the admin-only surfaces key off role === "admin", never off `authenticated`).""" js = _text(HEADER_JS) fn = js.find("export function fetchIsAdmin") assert fn != -1 body = js[fn : js.find("\n}", fn)] assert "fetchWhoami().then((w) => w.role === \"admin\")" in body def test_reset_whoami_clears_the_module_cache() -> None: """Phase 79 (task 05): the token gate changes the session MID-PAGE (silent re-auth / interactive login) — resetWhoami() drops the cached promise so the NEXT fetchWhoami() is a fresh post-auth request (a boot-fired pre-auth whoami would still say anonymous).""" js = _text(HEADER_JS) fn = js.find("export function resetWhoami") assert fn != -1 body = js[fn : js.find("\n}", fn)] assert "whoamiPromise = null" in body def test_init_shared_header_toggles_only_elements_that_exist() -> None: """initSharedHeader awaits the cached whoami, toggles ONLY the controls present on the page (querySelector, null-safe), and returns the admin flag for reuse.""" js = _text(HEADER_JS) fn = js.find("function initSharedHeader") assert fn != -1 body = js[fn : js.find("\n}", fn)] # Phase 79 (task 05): the header boots on the FULL whoami — the # auth pair keys off the authenticated role (admin OR token user), # the admin-only surfaces off role === "admin". assert "await fetchWhoami()" in body assert 'whoami.role === "admin"' in body for selector in ("#nav-sources", "#nav-git-sources", "#nav-tuning"): assert f'querySelector("{selector}")' in body # Sign in: both the bar copy AND the mobile dropdown copy (phase 46) # carry .sign-in-link — one class-based toggle (with the ?next= href # rewrite on every copy) covers both. assert 'querySelectorAll(".sign-in-link")' in body # Sign out: both the bar copy AND the mobile dropdown copy (phase 46) # carry .sign-out-btn — one class-based toggle covers both. assert 'querySelectorAll(".sign-out-btn")' in body assert "return admin" in body, "callers may reuse the flag" def test_clear_chat_storage_removes_the_phase14_key_silently() -> None: """clearChatStorage removes the SAME phase-14 key as app.js, inside a try/catch (private mode / storage errors are swallowed — the navigation still happens).""" js = _text(HEADER_JS) fn = js.find("function clearChatStorage") assert fn != -1 body = js[fn : js.find("\n}", fn)] assert 'localStorage.removeItem("bor.chat.v1")' in body assert "try" in body and "catch" in body def test_sign_out_binding_lives_in_the_shared_module() -> None: """The sign-out click binding (disable → POST /api/logout → reload) is owned by header.js at module import — exactly one implementation for every page that loads it. It binds to ALL .sign-out-btn elements (the bar copy for desktop + the mobile dropdown copy for ≤640px, phase 46), so both copies log out.""" js = _text(HEADER_JS) assert js.count('querySelectorAll(".sign-out-btn")') >= 2, ( "the boot toggle + the click binding both use the class (bar + mobile copy)" ) assert "btn.addEventListener(\"click\"" in js assert "btn.disabled = true" in js assert 'fetch("/api/logout", { method: "POST" })' in js assert "window.location.reload()" in js # ---------- HTML wiring: one shared bar on every page ---------- def test_nav_sources_ships_hidden_on_every_nav_page() -> None: """Phase 19 UX revision (owner permission 2026-08-23), completed on all five pages by phase 34 task 03 (owner confirmation 2026-08-26): the RAG nav link is hidden for anonymous — so it SHIPS with the hidden attribute (anonymous-safe default) on every page (they all carry the nav now, viewer included). Phase 76 (task 01): the page list is the post-shell set — the folded Tuning view's header copy is gone with tuning.html (the shell's ONE header is INDEX_HTML's). Phase 76 (task 02): the RAG + Sources view files drop out too (the shell's ONE header covers all its views); task 03 drops History.""" for html in (INDEX_HTML, DOCUMENT_HTML, LOGIN_HTML): text = _text(html) assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), ( f"{html.name}: #nav-sources must ship hidden" ) def test_shell_and_standalone_pages_share_the_header_control_order() -> None: """Phase 34 task 03 (owner confirmation 2026-08-26) + phase 35/46 (the sixth page, git-sources): every page ships the IDENTICAL header control inventory in the IDENTICAL order — brand, the mobile hamburger, nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning], Sign in, Sign out (the #steering-toggle was removed from the navbar at owner request, 2026-08-28) — inside the shared .header-inner row (the document viewer's row 1). The #sync-btn (RAG view only) and the #new-chat-btn (chat view only, moved from the navbar at owner request 2026-08-28) are NOT part of the shared bar anymore. Only the current-page is-active nav marker and the static ?next= fallback may differ per page (task 05's story E2E pins the rendered result). Phase 76 (task 02): the post-shell set — the folded views' header copies are gone (the shell's ONE header covers all its views); the RAG/Sources files are deleted, task 03 drops History.""" markers = ( 'class="brand"', '