"""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 non-chat New Chat bindings, and the viewer-bar CSS — so a silent regression is caught 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" STYLES_CSS = ASSETS / "styles.css" INDEX_HTML = FRONTEND / "index.html" SOURCES_HTML = FRONTEND / "sources.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_three_functions() -> None: """header.js must export the three functions every page script imports (fetchIsAdmin / initSharedHeader / clearChatStorage).""" js = _text(HEADER_JS) assert "export function fetchIsAdmin" 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 `adminPromise` marker — 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: a failure resolves to false.""" js = _text(HEADER_JS) assert re.search(r"let\s+adminPromise\s*=\s*null", js), ( "module-level adminPromise marker missing" ) assert 'fetch("/api/whoami")' in js assert "if (!adminPromise)" in js, "fetchIsAdmin must reuse the stored promise" assert "return adminPromise" in js assert ".catch(() => false)" in js, "network failure must resolve to anonymous" 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)] assert "await fetchIsAdmin()" in body for selector in ('#sign-in-link', '#sign-out-btn', '#nav-sources'): assert f'querySelector("{selector}")' 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-btn click binding (disable → POST /api/logout → reload) is owned by header.js at module import — exactly one implementation for every page that loads it.""" js = _text(HEADER_JS) assert "querySelector(\"#sign-out-btn\")" in js assert "signOutBtn.addEventListener" in js assert "signOutBtn.disabled = true" in js assert 'fetch("/api/logout", { method: "POST" })' in js assert "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): the Sources nav link is hidden for anonymous — so it SHIPS with the hidden attribute (anonymous-safe default) on every page that has a nav (chat, sources, login).""" for html in (INDEX_HTML, SOURCES_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_nav_sources_is_absent_from_the_viewer() -> None: """The document viewer has no nav — no #nav-sources element there (the module's missing-element no-op keeps it out).""" assert 'id="nav-sources"' not in _text(DOCUMENT_HTML) def test_sources_and_viewer_carry_the_shared_controls() -> None: """Sources AND the document viewer gain the New Chat button + the Sign in / Sign out pair (both starting hidden — initSharedHeader reveals exactly one after whoami), and they load header.js.""" for html in (SOURCES_HTML, DOCUMENT_HTML): text = _text(html) assert 'id="new-chat-btn"' in text assert re.search(r'id="sign-in-link"[^>]*\bhidden\b', text) assert re.search(r'id="sign-out-btn"[^>]*\bhidden\b', text) assert "header.js" in text, "page must load the shared header module" # Each page's Sign in link returns to ITS OWN page after login. assert 'href="/login.html?next=/sources.html"' in _text(SOURCES_HTML) assert 'href="/login.html?next=/document.html"' in _text(DOCUMENT_HTML) def test_header_module_loads_before_the_page_script() -> None: """Every page loads header.js (type=module) BEFORE its page script, so the sign-out binding and the whoami cache exist when the page script boots.""" cases = [ (INDEX_HTML, "app.js"), (SOURCES_HTML, "sources.js"), (DOCUMENT_HTML, "document.js"), (LOGIN_HTML, "login.js"), ] for html, page_script in cases: srcs = _script_srcs(html) header_idx = [i for i, s in enumerate(srcs) if "header.js" in s] page_idx = [i for i, s in enumerate(srcs) if page_script in s] assert header_idx, f"{html.name}: must load header.js" assert page_idx, f"{html.name}: must load {page_script}" assert header_idx[0] < page_idx[0], ( f"{html.name}: header.js must load before {page_script}" ) def test_login_page_carries_no_chat_controls() -> None: """Noted boundary (owner-confirmed): the login page is the auth page, not an app page — no New Chat / Sign in / Sign out controls there; header.js only toggles the Sources link.""" text = _text(LOGIN_HTML) assert "new-chat-btn" not in text assert "sign-in-link" not in text assert "sign-out-btn" not in text # ---------- page-script adaptations ---------- def test_app_js_delegates_the_shared_controls_to_header_module() -> None: """app.js imports the shared module, runs initSharedHeader() at boot (BEFORE the phase-14 restore), takes its isAdmin from the cached fetchIsAdmin(), and owns NO whoami fetch and NO sign-out binding anymore (both moved to header.js).""" js = _text(APP_JS) assert 'from "/assets/header.js"' in js assert "fetchIsAdmin" in js and "initSharedHeader" in js assert "signOutBtn.addEventListener" not in js, ( "the sign-out binding moved to header.js" ) assert 'fetch("/api/whoami")' not in js, ( "app.js must not fetch whoami itself — header.js caches it (one request/page)" ) assert "loadAuthState" not in js, "loadAuthState was deleted in phase 19" assert "function applyAuthState" in js, "chat-page tuning gate stays" assert "isAdmin = await fetchIsAdmin();" in js init_idx = js.find("await initSharedHeader();") restore_idx = js.find("restoreConversation();") assert -1 < init_idx < restore_idx, ( "header init must run before the phase-14 restore" ) def test_login_js_uses_the_shared_fetch_is_admin() -> None: """login.js switches its whoami check to the shared cached promise (one request per page) and calls initSharedHeader for the Sources link; its already-admin → redirect behavior is unchanged.""" js = _text(LOGIN_JS) assert 'from "/assets/header.js"' in js assert "fetchIsAdmin" in js assert "fetchIsAdmin()" in js assert "initSharedHeader()" in js assert 'fetch("/api/whoami")' not in js assert "window.location.replace(safeNext())" in js def test_non_chat_pages_bind_new_chat_to_the_chat_page() -> None: """On sources and the viewer, New Chat means "go to the chat, fresh": the binding clears the phase-14 key (clearChatStorage) and navigates to "/" — and both pages run initSharedHeader() at boot on the shared cached whoami.""" for js_file in (SOURCES_JS, DOCUMENT_JS): js = _text(js_file) assert 'from "/assets/header.js"' in js assert "initSharedHeader()" in js btn_idx = js.find("new-chat-btn") clear_idx = js.find("clearChatStorage();") nav_idx = js.find('window.location.href = "/"') assert -1 < btn_idx < clear_idx < nav_idx, ( f"{js_file.name}: #new-chat-btn must clear storage then navigate to '/'" ) assert 'fetch("/api/whoami")' not in js, ( f"{js_file.name}: whoami goes through the shared cached promise" ) # ---------- viewer-bar CSS ---------- def test_viewer_bar_css_pushes_actions_right_and_title_clips() -> None: """styles.css defines .doc-header-actions (margin-left:auto flex cluster) and the title block keeps min-width: 0 so #doc-title/#doc-meta clip instead of overflowing the --header-h bar.""" css = _text(STYLES_CSS) block = re.search(r"\.doc-header-actions\s*\{([^}]*)\}", css) assert block, "styles.css must define .doc-header-actions" body = block.group(1) assert "margin-left: auto" in body assert "display: flex" in body assert re.search(r"\.doc-title-block\s*\{[^}]*min-width:\s*0", css), ( "the title block must keep clipping while the pills fit" )