249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""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'<script[^>]*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"
|
|
)
|