fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the five navbar views (Chat, RAG, Sources, Tuning, History) were separate HTML documents, so a navbar click was a REAL cross-document navigation — the chat page unloaded, the in-flight SSE fetch was aborted, and the phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled") stopped the model. Observed: send question -> click RAG mid-stream -> click Chat -> the answer never finished: no `query_log` row, and on return a dangling question with no brain record (the pre-token pagehide partial persist skips because `acc` is empty). Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06, flagged per AGENTS.md rule 3, not silently deviated): "real navigation cancels the fetch" now means LEAVING THE APP — tab close, external/other-document navigation, the Stop button. In-app navbar switches are client-side view switches and no longer cancel. Fix — Option A (SPA shell), chosen over B (Service Worker owns the stream) and C (server-side turn registry + resume): - frontend/index.html is the shell: ONE `<main id="main">` holds the five `<section class="view">` blocks; hidden views carry BOTH `hidden` and `inert` (WCAG — no focus/keyboard traversal). The shared header, the single `doc-modal-*` skeleton, and the `#app-version` footer each exist exactly once; the per-view copies from the four folded pages are dropped. - New frontend/assets/router.js (vanilla module — no framework, no bundler, No-CDN rule intact): lazy-imports a view module on FIRST show only (mount-once, hide-forever — the chat view's in-flight SSE reader persists across switches; that persistence IS the fix); intercepts same-shell navbar links with preventDefault + history.pushState (never a document load); handles popstate; single writer of `.nav-link` active state (is-active + aria-current), document.title, and the per-view meta description (values carried over from the old pages' heads, brand-resolved at write time). - Each folded page's JS becomes `export async function mount(root)` — root-scoped queries; `initSharedHeader()` dropped (the header boots once in the shell via the chat module; the admin flag comes from the same cached `fetchIsAdmin()` promise — zero extra requests). - app/main.py: a small list-driven route factory serves the shell for /tuning.html, /sources.html, /git-sources.html, /history.html — registered AFTER the API routers and BEFORE the static catch-all (routes-first). The phase-33 caching middleware applies no-cache + `?v=` rewriting unchanged; app/core/caching.py needed NO change (the view paths did not change — pinned by the integration tests). - The four old view .html files are DELETED (one source of truth); deep links to the old URLs keep working (the router picks the view from the pathname); `/?chat=<id>` is unaffected; the Containerfile bundles router.js (inlining the lazy view modules) and drops the folded page files. - app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell keeps long saved answers in the chat, and the old cap (stricter than the 24_000-char total history budget) 422-rejected any second turn in such a chat (found by the phase-42 E2E suite on the shell). Boundaries: login.html, shared.html, doc-edit.html, document.html REMAIN separate documents (flow pages, not navbar tabs); a mid-stream navigation to doc-edit/document.html still cancels per phase 48 (follow-up candidate, out of scope). The SSE API is unchanged. Real departures still cancel the turn — phase 48 intact (pinned by tests/e2e/test_stop_generation.py, unchanged, and by the new suite's real-departure control). Tests: - Phase-20 suite REWRITTEN to the new semantics (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer cancels — the stream survives the switch and the FULL answer settles; the pagehide partial persist REMAINS for real departures (the partial's exact shape — first streamed chunk prefix, no done metadata — is still pinned there). - NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock LLM): the owner repro (send -> RAG mid-stream -> Chat: window sentinel survives = same document, FULL answer, exactly one brain turn in bor.chat.v1, exactly one settled query_log row, auto-saved row matches) + the same mid-stream switch against the other three views + the real-departure-still-cancels control + the no-switch baseline. - tests/unit/test_frontend_router.py: source-level pins of the router invariants (click interceptor targets ONLY same-shell view paths, pushState-only switches, mount-once guard, hidden+inert pair, single-writer active state/title); shell-route integration tests (each folded path serves the shell with no-cache + `?v=` body; a non-view path still 404s); the file-reading unit pins re-pointed at the shell (the four view files are gone — the shell is the source of truth). Verification (this commit): full suite green — 1565 unit+integration tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the phase's E2E suites green in isolation (house protocol, AGENTS.md rule 9). Owner repro verified in a real browser against the real LLM (dev server :8010, headful Chromium): "tell me about everquest" -> RAG mid-stream -> Chat — the answer completed with one brain bubble and no error banner, `query_log` gained exactly one settled row (deflected=True: the dev KB holds no EverQuest docs — the settle, not the topic, is the proof), zero "chat: turn cancelled" lines for that turn; the control (real navigation to /shared.html mid-stream) still cancelled (no settled row, the cancel line logged, the partial persisted on return). Screenshots: .agents/screenshots/76_manual_*. Phase 76 (76_spa_nav_shell) complete — moved to .agents/phases/complete/.
This commit is contained in:
@@ -26,11 +26,10 @@ from pathlib import Path
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
SHARED_HTML = FRONTEND / "shared.html"
|
||||
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
@@ -182,7 +181,16 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
|
||||
relocated the .chat-actions row from the top of the column to the
|
||||
bottom: the button now sits BELOW the #messages section, directly
|
||||
above the composer (the single module binding + the no-op guard are
|
||||
pinned in test_shared_header.py)."""
|
||||
pinned in test_shared_header.py).
|
||||
|
||||
Phase 76 (task 01) — the final shell form, converted in ONE step so
|
||||
the later fold tasks (02/03) leave this pin alone: the file-level
|
||||
"only on the chat page" semantics die with the shell — the button
|
||||
must sit inside the shell's CHAT VIEW section (#view-chat; the
|
||||
hidden views' elements remain in the DOM, so a per-file negative
|
||||
check is meaningless inside the shell), and it must be absent from
|
||||
the SURVIVING separate documents (the flow pages that are not
|
||||
navbar views)."""
|
||||
html = _index()
|
||||
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
|
||||
assert btn, "index.html must contain #new-chat-btn"
|
||||
@@ -191,6 +199,14 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
|
||||
assert 'aria-label="New chat"' in tag
|
||||
main_idx = html.find('main id="main"')
|
||||
assert main_idx != -1 and btn.start() > main_idx, "the button belongs inside <main>"
|
||||
# The button sits inside the shell's chat view section (phase 76):
|
||||
# after the #view-chat open, before the #view-tuning section starts
|
||||
# (the chat section closes before it — hidden views are separate).
|
||||
view_chat_idx = html.find('id="view-chat"')
|
||||
view_tuning_idx = html.find('id="view-tuning"')
|
||||
assert -1 < view_chat_idx < btn.start() < view_tuning_idx, (
|
||||
"the button belongs inside the #view-chat section"
|
||||
)
|
||||
shell_idx = html.find('class="container chat-shell"')
|
||||
assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell"
|
||||
messages_idx = html.find('id="messages"')
|
||||
@@ -199,10 +215,12 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
|
||||
assert -1 < messages_idx < messages_end < btn.start() < composer_idx, (
|
||||
"the button sits below the #messages section, above the composer"
|
||||
)
|
||||
# No other page carries the button anymore (owner request 2026-08-28).
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
|
||||
# No SURVIVING separate document carries the button (owner request
|
||||
# 2026-08-28, final shell form: the folded navbar views are GONE
|
||||
# from this check — they are views of the shell now).
|
||||
for other in (DOCUMENT_HTML, LOGIN_HTML, SHARED_HTML, DOC_EDIT_HTML):
|
||||
assert 'id="new-chat-btn"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the New chat button is chat-page only"
|
||||
f"{other.name}: the New chat button is chat-view only"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -41,8 +41,7 @@ SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
||||
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||||
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
|
||||
MODAL_JS = FRONTEND / "assets" / "document-modal.js" # phase 26: the modal owner
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
INDEX_HTML = FRONTEND / "index.html" # the ONE-document shell (phase 76: sources.html folded in)
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
@@ -294,10 +293,12 @@ def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
|
||||
def test_render_document_exported_and_modal_imports_it() -> None:
|
||||
"""Phase 26: document.js EXPORTS renderDocument(doc, { … }) — the
|
||||
exact renderer the standalone page and the modal share (no drift).
|
||||
The modal module imports it relatively, and BOTH page scripts import
|
||||
the modal module relatively — no direct <script> tag (the header.js
|
||||
single-evaluation design: esbuild inlines it into the page bundle,
|
||||
one module instance per page)."""
|
||||
The modal module imports it relatively, and BOTH view scripts
|
||||
(app.js — the chat view at shell boot — and sources.js — the RAG
|
||||
view module) import the modal module relatively — no direct
|
||||
<script> tag (the header.js single-evaluation design: esbuild
|
||||
inlines it into the bundle, one module instance per document).
|
||||
"""
|
||||
doc_js = _read(DOCUMENT_JS)
|
||||
# Task 03 signature: the page passes its #doc-title / #doc-meta /
|
||||
# #doc-content elements under exactly these names.
|
||||
@@ -315,12 +316,11 @@ def test_render_document_exported_and_modal_imports_it() -> None:
|
||||
assert 'from "./document-modal.js"' in js, (
|
||||
f"{name}: must import the modal module relatively"
|
||||
)
|
||||
for page in (INDEX_HTML, SOURCES_HTML):
|
||||
text = _read(page)
|
||||
assert not re.search(r"<script[^>]*document-modal\.js", text), (
|
||||
f"{page.name}: no direct document-modal.js <script> tag "
|
||||
"(single-evaluation design — the page script imports it)"
|
||||
)
|
||||
text = _read(INDEX_HTML)
|
||||
assert not re.search(r"<script[^>]*document-modal\.js", text), (
|
||||
"index.html: no direct document-modal.js <script> tag "
|
||||
"(single-evaluation design — the view scripts import it)"
|
||||
)
|
||||
|
||||
|
||||
def test_document_js_page_init_is_import_safe() -> None:
|
||||
@@ -338,37 +338,43 @@ def test_document_js_page_init_is_import_safe() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_both_pages_carry_the_modal_skeleton() -> None:
|
||||
"""Phase 26: chat AND Sources ship the same modal skeleton (the
|
||||
task-01 markup) — the a11y frame included: role=dialog +
|
||||
aria-modal, a labelled close control, a focusable content target
|
||||
(tabindex=-1), and a role=status announcer. Hidden by default —
|
||||
inert until JS opens it."""
|
||||
for page in (INDEX_HTML, SOURCES_HTML):
|
||||
text = _read(page)
|
||||
assert '<div class="doc-modal" id="doc-modal" hidden>' in text, page.name
|
||||
assert 'id="doc-modal-backdrop"' in text, page.name
|
||||
assert 'id="doc-modal-panel"' in text, page.name
|
||||
assert 'role="dialog"' in text and 'aria-modal="true"' in text, page.name
|
||||
assert 'id="doc-modal-title"' in text, page.name
|
||||
assert 'id="doc-modal-meta"' in text, page.name
|
||||
assert 'id="doc-modal-desc"' in text, page.name
|
||||
assert 'id="doc-modal-open"' in text, page.name
|
||||
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text), page.name
|
||||
assert re.search(
|
||||
r'id="doc-modal-close"[^>]*aria-label="Close document"', text
|
||||
), page.name
|
||||
def test_shell_carries_the_single_modal_skeleton() -> None:
|
||||
"""Phase 26 + phase 76 (task 02) dedup pin: the shell ships the
|
||||
modal skeleton EXACTLY ONCE (the chat's, body level) — the RAG
|
||||
view's second copy was dropped in the fold; BOTH view scripts
|
||||
(app.js chat chips, sources.js RAG row links) open documents
|
||||
through openDocumentModal(...) against that single instance
|
||||
(document-modal.js resolves it by document-level querySelector at
|
||||
import). The a11y frame is included: role=dialog + aria-modal, a
|
||||
labelled close control, a focusable content target (tabindex=-1),
|
||||
and a role=status announcer. Hidden by default — inert until JS
|
||||
opens it."""
|
||||
text = _read(INDEX_HTML)
|
||||
assert text.count('id="doc-modal"') == 1, (
|
||||
"the shell must carry exactly ONE modal skeleton (the fold dedup)"
|
||||
)
|
||||
assert '<div class="doc-modal" id="doc-modal" hidden>' in text
|
||||
assert 'id="doc-modal-backdrop"' in text
|
||||
assert 'id="doc-modal-panel"' in text
|
||||
assert 'role="dialog"' in text and 'aria-modal="true"' in text
|
||||
assert 'id="doc-modal-title"' in text
|
||||
assert 'id="doc-modal-meta"' in text
|
||||
assert 'id="doc-modal-desc"' in text
|
||||
assert 'id="doc-modal-open"' in text
|
||||
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text)
|
||||
assert re.search(r'id="doc-modal-close"[^>]*aria-label="Close document"', text)
|
||||
|
||||
|
||||
def test_sources_page_loads_markdown_before_its_module() -> None:
|
||||
"""Phase 26: the modal renders md documents on the Sources page too —
|
||||
so sources.html loads the classic markdown.js (global renderMarkdown)
|
||||
via a relative <script src> BEFORE its module script, exactly like
|
||||
index.html does."""
|
||||
html = _read(SOURCES_HTML)
|
||||
def test_shell_loads_markdown_before_its_modules() -> None:
|
||||
"""Phase 26 + phase 76 (task 02): the modal renders md documents
|
||||
in the RAG view too — so the shell loads the classic markdown.js
|
||||
(global renderMarkdown) via a relative <script src> BEFORE its
|
||||
module scripts (app.js — the chat view — and the lazy view
|
||||
modules' modal imports), exactly as the old sources.html did."""
|
||||
html = _read(INDEX_HTML)
|
||||
assert re.search(r'<script src="assets/markdown\.js"></script>', html)
|
||||
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
|
||||
"sources.html: markdown.js must load before the module script"
|
||||
"index.html: markdown.js must load before the module scripts"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,14 +21,16 @@ CONTAINERFILE = ROOT / "Containerfile"
|
||||
|
||||
#: Every page in the app ships the brand layer (phase 39: "every visible
|
||||
#: brand string on every page resolves from one place").
|
||||
#: Phase 76 (task 01): the Tuning page is folded into the shell (its
|
||||
#: file is deleted) — the shell (index.html) stands in for it here.
|
||||
#: Phase 76 (task 02): the RAG + Sources pages are folded too (both
|
||||
#: files deleted — the shell stands in for them). Phase 76 (task 03):
|
||||
#: history.html is folded too (deleted — the shell stands in for the
|
||||
#: History view; all four folded view files are gone).
|
||||
HTML_PAGES = (
|
||||
"index.html",
|
||||
"sources.html",
|
||||
"tuning.html",
|
||||
"document.html",
|
||||
"login.html",
|
||||
"git-sources.html",
|
||||
"history.html", # phase 50: the admin saved-chats page
|
||||
"shared.html", # phase 51: the anonymous shared-conversation page
|
||||
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""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.
|
||||
"""
|
||||
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 —
|
||||
all four 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"
|
||||
)
|
||||
# The view names are the #view-<name> section slugs in index.html.
|
||||
for name in ("chat", "tuning", "history"):
|
||||
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 '"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
|
||||
# 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."""
|
||||
js = _js()
|
||||
fn = js.find("async function switchTo")
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
flag = body.rfind("if (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"
|
||||
|
||||
|
||||
# ---------- 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
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
The browser behavior is E2E-covered (tests/e2e/test_sync_upload_progress.py,
|
||||
task 06); here we pin the source-level wiring in sources.js, styles.css,
|
||||
and sources.html — the fmtSyncLabel contract (both kinds, file
|
||||
and the shell's RAG view markup (index.html — sources.html / git-
|
||||
sources.html folded in, phase 76 task 02) — the fmtSyncLabel contract
|
||||
(both kinds, file
|
||||
present/absent, counts only when total > 0), enterSyncRunningState
|
||||
writing the full untruncated path to the button title + #sync-result,
|
||||
the two-job tick decision tree (sync running > upload running > sync
|
||||
@@ -29,9 +31,11 @@ from pathlib import Path
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
# Phase 76 (task 02): both HTML pages are folded into the ONE-document
|
||||
# shell — the pinned comments now live in the RAG / Sources view
|
||||
# sections of index.html.
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
@@ -43,7 +47,7 @@ def _css() -> str:
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
return SOURCES_HTML.read_text(encoding="utf-8")
|
||||
return SHELL_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _gjs() -> str:
|
||||
@@ -51,27 +55,38 @@ def _gjs() -> str:
|
||||
|
||||
|
||||
def _ghtml() -> str:
|
||||
return GIT_SOURCES_HTML.read_text(encoding="utf-8")
|
||||
return SHELL_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _gfn(js: str, name: str) -> str:
|
||||
"""The source of the first `function <name>` in git-sources.js
|
||||
(up to the first line-leading closing brace — the house pin
|
||||
pattern)."""
|
||||
(brace balanced — since phase 76 task 02 the functions live inside
|
||||
mount(root), so the closing brace is indented, not line-leading).
|
||||
"""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must be defined in git-sources.js"
|
||||
return js[fn : js.find("\n}", fn)]
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
def _utick(js: str) -> str:
|
||||
"""The upload poll tick inside startUploadPolling — from `const
|
||||
tick = async () => {` to the next top-level function
|
||||
(initUploadStatus), so the whole decision tree is in the slice."""
|
||||
tick = async () => {` to the next function (initUploadStatus), so
|
||||
the whole decision tree is in the slice."""
|
||||
fn = js.find("function startUploadPolling")
|
||||
assert fn != -1, "startUploadPolling must be defined in git-sources.js"
|
||||
tick = js.find("const tick = async () => {", fn)
|
||||
assert tick != -1, "the tick must live inside startUploadPolling"
|
||||
end = js.find("\nasync function initUploadStatus", tick)
|
||||
end = js.find("async function initUploadStatus", tick)
|
||||
assert end != -1, "initUploadStatus must follow startUploadPolling"
|
||||
return js[tick:end]
|
||||
|
||||
@@ -88,23 +103,34 @@ def _usubmit(js: str) -> str:
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of the first `function <name>` in js (up to the first
|
||||
line-leading closing brace — the house pin pattern from
|
||||
tests/unit/test_sync_button.py)."""
|
||||
"""The source of the first `function <name>` in js (brace balanced —
|
||||
since phase 76 task 02 the functions live inside mount(root), so
|
||||
the closing brace is indented, not line-leading; the house pin
|
||||
pattern from tests/unit/test_sync_button.py)."""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must be defined in sources.js"
|
||||
return js[fn : js.find("\n}", fn)]
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
def _tick(js: str) -> str:
|
||||
"""The poll tick inside startSyncPolling — from `const tick = async
|
||||
() => {` to the next top-level function (startSync), so the whole
|
||||
two-job decision tree is in the slice."""
|
||||
() => {` to the next function (startSync), so the whole two-job
|
||||
decision tree is in the slice."""
|
||||
fn = js.find("function startSyncPolling")
|
||||
assert fn != -1, "startSyncPolling must be defined in sources.js"
|
||||
tick = js.find("const tick = async () => {", fn)
|
||||
assert tick != -1, "the tick must live inside startSyncPolling"
|
||||
end = js.find("\nasync function startSync", tick)
|
||||
end = js.find("async function startSync", tick)
|
||||
assert end != -1, "startSync must follow startSyncPolling"
|
||||
return js[tick:end]
|
||||
|
||||
@@ -309,8 +335,9 @@ def test_reattach_adopts_a_running_upload_only() -> None:
|
||||
assert '"upload", upload.current_file, upload.files_done, upload.files_total' in branch
|
||||
assert 'emitSyncStatus({ state: "running" })' in branch
|
||||
assert "startSyncPolling()" in branch
|
||||
# the idle settle is the fall-through (the last statement)
|
||||
assert body.rstrip().endswith("applySyncIdle(status);")
|
||||
# the idle settle is the fall-through (the last statement — the
|
||||
# brace-balanced body ends with the closing brace)
|
||||
assert body.rstrip().removesuffix("}").rstrip().endswith("applySyncIdle(status);")
|
||||
|
||||
|
||||
# ---------- the section header + the page comment ----------
|
||||
@@ -332,10 +359,11 @@ def test_section_header_documents_the_two_job_contract() -> None:
|
||||
|
||||
|
||||
def test_sources_html_comment_documents_the_live_announcer() -> None:
|
||||
"""The #sync-result comment in sources.html documents the phase-64
|
||||
dual role: the live file label (both kinds) while either job runs,
|
||||
untruncated for the aria-live announcer, and empty after an upload
|
||||
settles (A3 — the upload's counts live on the Sources page)."""
|
||||
"""The #sync-result comment in the shell's RAG view (formerly
|
||||
sources.html) documents the phase-64 dual role: the live file
|
||||
label (both kinds) while either job runs, untruncated for the
|
||||
aria-live announcer, and empty after an upload settles (A3 — the
|
||||
upload's counts live on the Sources page)."""
|
||||
html = _html()
|
||||
idx = html.find('id="sync-result"')
|
||||
assert idx != -1
|
||||
@@ -634,23 +662,27 @@ def test_boot_reattach_branches() -> None:
|
||||
assert "status.error" in fail
|
||||
assert "uploadError.hidden = false" in fail
|
||||
assert 'status.state === "idle"' not in body, "idle does nothing — no branch"
|
||||
# The boot IIFE: after the list loads, the re-attach runs (admin
|
||||
# branch only — the anonymous path returns before it).
|
||||
# The mount's tail (phase 76 task 02 — the boot IIFE is gone):
|
||||
# after the list loads, the re-attach runs (admin branch only — the
|
||||
# anonymous path returns before it), and it is the LAST statement
|
||||
# of mount(root).
|
||||
i_boot = js.rfind("await loadSources();")
|
||||
tail = js[i_boot:i_boot + 400]
|
||||
assert "await initUploadStatus();" in tail
|
||||
assert "})();" in tail
|
||||
assert "})();" not in js, "the top-level boot IIFE is gone (mount owns boot)"
|
||||
assert tail.rstrip().removesuffix("}").rstrip().endswith("await initUploadStatus();")
|
||||
|
||||
|
||||
# ---------- the page comment ----------
|
||||
|
||||
|
||||
def test_git_sources_html_comment_documents_the_202_contract() -> None:
|
||||
"""The #archive-upload-form comment in git-sources.html documents
|
||||
the phase-64 202 contract (the phase-49 synchronous paragraph
|
||||
marked superseded): the 202 = "safely on disk" + the JS-created
|
||||
toast (no markup), the live "Processing…" label via the status
|
||||
poll, and the 409 re-attach without an error banner."""
|
||||
"""The #archive-upload-form comment in the shell's Sources view
|
||||
(formerly git-sources.html) documents the phase-64 202 contract
|
||||
(the phase-49 synchronous paragraph marked superseded): the 202 =
|
||||
"safely on disk" + the JS-created toast (no markup), the live
|
||||
"Processing…" label via the status poll, and the 409 re-attach
|
||||
without an error banner."""
|
||||
html = _ghtml()
|
||||
idx = html.find('id="archive-upload-form"')
|
||||
assert idx != -1
|
||||
|
||||
@@ -45,16 +45,14 @@ STYLES_CSS = ASSETS / "styles.css"
|
||||
#: The app's pages (phase 46: the shared bar contract extends to the
|
||||
#: phase-35 git-sources page — the hamburger is part of that bar; the
|
||||
#: phase-50 History page and the phase-51 shared page carry the same
|
||||
#: bar — the full seven-page set). The two pages added after phase 46
|
||||
#: keep the identical header block, so they pin here too.
|
||||
#: bar). Phase 76 (task 02): the folded RAG + Sources files are gone —
|
||||
#: the shell (index.html) stands in for both views. Phase 76 (task
|
||||
#: 03): the History file is gone too — the shell stands in for the
|
||||
#: History view (all four folded view files deleted).
|
||||
PAGES = (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "sources.html",
|
||||
FRONTEND / "document.html",
|
||||
FRONTEND / "git-sources.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "tuning.html",
|
||||
FRONTEND / "history.html",
|
||||
FRONTEND / "shared.html",
|
||||
)
|
||||
|
||||
@@ -122,10 +120,10 @@ def _rule_block(css: str, selector: str) -> str:
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ---------- markup: the identical toggle + labeled nav on all six pages ----------
|
||||
# ---------- markup: the identical toggle + labeled nav on the pages ----------
|
||||
|
||||
|
||||
def test_all_six_pages_carry_the_hamburger_toggle() -> None:
|
||||
def test_all_pages_carry_the_hamburger_toggle() -> None:
|
||||
"""Every page carries the #nav-toggle button with the full aria
|
||||
contract: a real button (type=button), aria-expanded defaulting to
|
||||
"false", aria-controls pointing at the nav, the accessible name
|
||||
@@ -176,7 +174,7 @@ def test_toggle_lives_in_the_shared_bar_right_before_the_nav() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_all_six_pages_carry_the_labeled_nav_with_id() -> None:
|
||||
def test_all_pages_carry_the_labeled_nav_with_id() -> None:
|
||||
"""The nav keeps its single element + label and gains ONLY the id
|
||||
(the whoami reveal targets the same four links — no duplicated
|
||||
markup, so the phase-19/35 visibility rules apply inside the menu
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"""Unit: phase-66 text pins — the History tab describes the auto-save
|
||||
model, not the retired Save button.
|
||||
|
||||
House pattern (``test_stale_ui_copy.py``): read ``frontend/history.html``
|
||||
as text and assert substrings — no browser. The browser-visible layer is
|
||||
House pattern (``test_stale_ui_copy.py``): read the frontend files as
|
||||
text and assert substrings — no browser. The browser-visible layer is
|
||||
gated by the dedicated story suite (``tests/e2e/test_history_copy.py``);
|
||||
these pins catch a silent regression in the template without it.
|
||||
|
||||
Phase 76 (task 03): the History view is a view of the shell —
|
||||
``frontend/history.html`` is deleted, the view copy lives in the
|
||||
shell's ``#view-history`` section, and the per-view meta description is
|
||||
the router's (``frontend/assets/router.js`` — the single writer of the
|
||||
client-side ``<meta name="description">``, value carried over from the
|
||||
old page's ``<head>``). The pins follow the copy to its new home.
|
||||
|
||||
Locked decision (owner-locked A3, 2026-09-01): every conversation saves
|
||||
itself automatically — there is NO Save button (retired phase 55, owner-
|
||||
locked A2, pinned by ``test_save_share_ux.py::test_anonymous_auto_save``),
|
||||
@@ -18,6 +25,9 @@ from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
ROUTER_JS = FRONTEND / "assets" / "router.js"
|
||||
|
||||
# --- the locked auto-save copy (owner-locked A3) -----------------------
|
||||
|
||||
META = "Saved chats — every conversation is saved automatically, one click back."
|
||||
@@ -43,8 +53,24 @@ STATE_STRINGS = (
|
||||
)
|
||||
|
||||
|
||||
def _text() -> str:
|
||||
return (FRONTEND / "history.html").read_text(encoding="utf-8")
|
||||
def _shell() -> str:
|
||||
assert SHELL_HTML.is_file(), f"missing {SHELL_HTML}"
|
||||
return SHELL_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _router() -> str:
|
||||
assert ROUTER_JS.is_file(), f"missing {ROUTER_JS}"
|
||||
return ROUTER_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _view(html: str) -> str:
|
||||
"""The shell's History view section (the view is the shell's LAST
|
||||
view section — the slice runs to the container main's close)."""
|
||||
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
|
||||
return html[start:end]
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
@@ -54,26 +80,32 @@ def _norm(text: str) -> str:
|
||||
|
||||
|
||||
def test_manual_save_copy_is_gone() -> None:
|
||||
"""history.html: the three retired manual-save strings are GONE —
|
||||
the copy no longer tells the visitor to press a Save button."""
|
||||
html = _text()
|
||||
for frag in (OLD_META, OLD_PAGE_SUB, OLD_EMPTY_ROW):
|
||||
assert frag not in html, f"retired manual-save copy still present: {frag!r}"
|
||||
"""The shell (which carries the History view) and the router (which
|
||||
carries the per-view meta): the three retired manual-save strings
|
||||
are GONE — the copy no longer tells the visitor to press a Save
|
||||
button."""
|
||||
for text in (_view(_shell()), _router()):
|
||||
for frag in (OLD_META, OLD_PAGE_SUB, OLD_EMPTY_ROW):
|
||||
assert frag not in text, f"retired manual-save copy still present: {frag!r}"
|
||||
|
||||
|
||||
def test_locked_auto_save_copy_present_exactly_once() -> None:
|
||||
"""The three locked (A3) auto-save strings, each exactly once in
|
||||
history.html (a second copy could drift out of sync)."""
|
||||
html = _norm(_text())
|
||||
assert html.count(META) == 1, "the locked meta description"
|
||||
assert html.count(PAGE_SUB) == 1, "the locked page-sub"
|
||||
assert html.count(EMPTY_ROW) == 1, "the locked empty-row string"
|
||||
"""The three locked (A3) auto-save strings, each exactly once (a
|
||||
second copy could drift out of sync). Phase 76 (task 03): the meta
|
||||
description lives in the router's DESCRIPTIONS table (the router is
|
||||
the single writer of the client-side meta — the value is carried
|
||||
over from the old page's <head>), and the page-sub + empty-row
|
||||
strings live in the shell's History view."""
|
||||
assert _router().count(META) == 1, "the locked meta description (router-owned)"
|
||||
view = _norm(_view(_shell()))
|
||||
assert view.count(PAGE_SUB) == 1, "the locked page-sub"
|
||||
assert view.count(EMPTY_ROW) == 1, "the locked empty-row string"
|
||||
|
||||
|
||||
def test_state_language_survivors_are_untouched() -> None:
|
||||
"""Proof of NO over-deletion: the strings where "saved" is a state —
|
||||
the <h1>, the anonymous gate title, and the gate sub — stay exactly
|
||||
as phase 50 wrote them."""
|
||||
html = _text()
|
||||
as phase 50 wrote them (in the shell's History view)."""
|
||||
view = _view(_shell())
|
||||
for frag in STATE_STRINGS:
|
||||
assert frag in html, f"state-language survivor deleted: {frag!r}"
|
||||
assert frag in view, f"state-language survivor deleted: {frag!r}"
|
||||
|
||||
+168
-90
@@ -1,8 +1,14 @@
|
||||
"""Unit: the phase-50 task-04 History-page contract.
|
||||
"""Unit: the phase-50 task-04 History contract.
|
||||
|
||||
The browser behavior itself is E2E-gated by the story suite (task 05);
|
||||
like the other frontend-adjacent unit files, this module pins the
|
||||
JS/CSS/HTML markers the History page depends on, so a silent
|
||||
Phase 76 (task 03): the History view is a view of the shell —
|
||||
``frontend/history.html`` is deleted, its content lives in the shell's
|
||||
``#view-history`` section, and ``history.js`` is a ``mount(root)`` view
|
||||
module the router lazy-imports. This module pins the JS/CSS/HTML
|
||||
markers the view depends on accordingly (the JS pins against
|
||||
``history.js`` — brace-balanced slices, since phase 76 task 03 the
|
||||
functions live inside ``mount(root)`` — the HTML pins against the
|
||||
shell, scoped to the view where view-scoped); the browser behavior
|
||||
itself is E2E-gated by the story suite (task 05), and a silent
|
||||
regression is caught without a browser:
|
||||
|
||||
* the anonymous no-fetch gate (the gate in, the table out, and the
|
||||
@@ -14,19 +20,21 @@ regression is caught without a browser:
|
||||
(owner-locked 2026-08-29: no native confirm dialog on this page);
|
||||
* the ``/?chat=<id>`` Open-link href shape (TODO.md L5 — "return to
|
||||
that history with a click");
|
||||
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
|
||||
+ ``header.js``'s reveal-for-admin block;
|
||||
* ``#nav-history`` on ALL surviving pages (the phase-34 one-bar
|
||||
contract — phase 76: the four folded navbar-view files are gone
|
||||
(tasks 01–03), so the page list is the shell + the surviving
|
||||
documents) + ``header.js``'s reveal-for-admin block;
|
||||
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
|
||||
the empty-state row;
|
||||
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
|
||||
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
|
||||
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
|
||||
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
|
||||
between Updated and Share in ``history.html``, and the ``.stale-pill``
|
||||
rose-family CSS in ``styles.css``.
|
||||
between Updated and Share in the shell's History view, and the
|
||||
``.stale-pill`` rose-family CSS in ``styles.css``.
|
||||
|
||||
The Containerfile stage-1 coverage (history.html copied, history.js
|
||||
bundled) is pinned dynamically by
|
||||
The Containerfile stage-1 coverage (the shell copied, the view modules
|
||||
bundled into the router) is pinned dynamically by
|
||||
``tests/integration/test_containerfile_assets.py`` — a page or module
|
||||
missing from stage 1 fails there.
|
||||
"""
|
||||
@@ -39,27 +47,25 @@ FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
HISTORY_HTML = FRONTEND / "history.html"
|
||||
SHARED_HTML = FRONTEND / "shared.html" # phase 51: the anonymous shared page
|
||||
HISTORY_JS = ASSETS / "history.js"
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
#: The phase-34 one-bar contract + the History page + the shared
|
||||
#: page: EIGHT pages.
|
||||
#: The phase-34 one-bar contract + the shared page.
|
||||
#: Phase 76 (task 01): the post-shell set — TUNING_HTML dropped (the
|
||||
#: Tuning view is folded into the shell; its file is deleted).
|
||||
#: Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped (both
|
||||
#: views folded into the shell; both files deleted).
|
||||
#: Phase 76 (task 03): HISTORY_HTML dropped (the History view is
|
||||
#: folded into the shell; its file is deleted) — the shell + the
|
||||
#: surviving documents.
|
||||
ALL_PAGES = (
|
||||
INDEX_HTML,
|
||||
SOURCES_HTML,
|
||||
GIT_SOURCES_HTML,
|
||||
TUNING_HTML,
|
||||
DOCUMENT_HTML,
|
||||
LOGIN_HTML,
|
||||
HISTORY_HTML,
|
||||
SHARED_HTML,
|
||||
)
|
||||
|
||||
@@ -77,11 +83,41 @@ def _css() -> str:
|
||||
return _text(STYLES_CSS)
|
||||
|
||||
|
||||
def _shell() -> str:
|
||||
return _text(INDEX_HTML)
|
||||
|
||||
|
||||
def _view(html: str) -> str:
|
||||
"""The shell's History view section — from the #view-history open
|
||||
tag to the container main's close (the view is the shell's LAST
|
||||
view section, so the slice ends at the first ``</main>`` after
|
||||
it)."""
|
||||
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 _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level ``function <name>(...)`` (to its close)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in history.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
"""The source of the first ``function <name>`` in history.js (brace
|
||||
balanced — since phase 76 task 03 the functions live inside
|
||||
mount(root), so the closing brace is indented, not line-leading;
|
||||
the house pin pattern from tests/unit/test_frontend_sync_upload.py).
|
||||
"""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must be defined in history.js"
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
def _nav_history_tag(html: str) -> str:
|
||||
@@ -90,14 +126,18 @@ def _nav_history_tag(html: str) -> str:
|
||||
return tag.group(0)
|
||||
|
||||
|
||||
# ---------- the #nav-history link: all seven pages ----------
|
||||
# ---------- the #nav-history link: all surviving pages ----------
|
||||
|
||||
|
||||
def test_nav_history_present_on_all_seven_pages() -> None:
|
||||
def test_nav_history_present_on_all_surviving_pages() -> None:
|
||||
"""The phase-34 one-bar contract extended by phase 50: the admin-only
|
||||
History link SHIPS hidden (revealed by header.js for admin) on every
|
||||
page, after the Tuning link, pointing at /history.html. The page's
|
||||
own link is the active one (is-active + aria-current)."""
|
||||
standalone page (the shell carries it once for its views — phase 76),
|
||||
after the Tuning link, pointing at /history.html. NO page's link is
|
||||
statically stamped active: in the shell the router is the SINGLE
|
||||
WRITER of the active state (client-side, per view — the old
|
||||
history.html page-level stamp is gone with the file), and the
|
||||
surviving documents keep their pre-shell no-stamp state."""
|
||||
for html in ALL_PAGES:
|
||||
text = _text(html)
|
||||
tag = _nav_history_tag(text)
|
||||
@@ -107,29 +147,26 @@ def test_nav_history_present_on_all_seven_pages() -> None:
|
||||
assert text.find('id="nav-tuning"') < text.find('id="nav-history"'), (
|
||||
f"{html.name}: #nav-history must follow #nav-tuning"
|
||||
)
|
||||
# The history page is the only one whose link is active.
|
||||
for html in ALL_PAGES:
|
||||
tag = _nav_history_tag(_text(html))
|
||||
if html.name == "history.html":
|
||||
assert 'class="nav-link is-active"' in tag
|
||||
assert 'aria-current="page"' in tag
|
||||
else:
|
||||
assert "is-active" not in tag, (
|
||||
f"{html.name}: no nav link is current there"
|
||||
)
|
||||
assert "is-active" not in tag, (
|
||||
f"{html.name}: no statically-current nav link (in the shell the "
|
||||
"active state is the router's single-writer job)"
|
||||
)
|
||||
assert 'aria-current="page"' not in tag
|
||||
|
||||
|
||||
def test_nav_history_count_is_exactly_eight_pages() -> None:
|
||||
def test_nav_history_count_is_exactly_four_pages() -> None:
|
||||
"""The pin counting occurrences across ``frontend/*.html`` — exactly
|
||||
one ``id="nav-history"`` per page, eight pages (phase 51: + the
|
||||
shared page), no duplicates and no extra page that forgot (or
|
||||
added twice)."""
|
||||
one ``id="nav-history"`` per page, FOUR pages (phase 51: + the shared
|
||||
page; phase 76: the four folded navbar-view files are gone — task 01
|
||||
− Tuning, task 02 − RAG + Sources, task 03 − History — the shell's
|
||||
ONE link covers all its views), no duplicates and no extra page
|
||||
that forgot (or added twice)."""
|
||||
total = 0
|
||||
for html in sorted(FRONTEND.glob("*.html")):
|
||||
count = html.read_text(encoding="utf-8").count('id="nav-history"')
|
||||
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
|
||||
total += count
|
||||
assert total == 8, f"expected #nav-history on 8 pages, found {total}"
|
||||
assert total == 4, f"expected #nav-history on 4 pages, found {total}"
|
||||
|
||||
|
||||
def test_header_js_reveals_nav_history_for_admin() -> None:
|
||||
@@ -145,41 +182,56 @@ def test_header_js_reveals_nav_history_for_admin() -> None:
|
||||
assert "navHistory.hidden = !admin" in body
|
||||
|
||||
|
||||
# ---------- history.html: the page scaffold ----------
|
||||
# ---------- the shell's History view: the scaffold ----------
|
||||
|
||||
|
||||
def test_history_page_scaffold_and_landmarks() -> None:
|
||||
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
|
||||
shared header, the steering panel + announcer (phase 34 — ships on
|
||||
every page), the page-head, the gate (ship-hidden), the
|
||||
role="status" live region, and the table inside the
|
||||
.table-wrap card. Footer with the version span (the index.html
|
||||
shape)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
def test_history_view_scaffold_and_landmarks() -> None:
|
||||
"""The shell's History view (formerly history.html — phase 76 task
|
||||
03): the shell's standard landmarks (skip link, the shared header,
|
||||
the steering panel + announcer — the shell's ONE header-owned pair,
|
||||
the view's copies dropped with the move) + the view section
|
||||
(hidden AND inert + focusable — the WCAG pair, AGENTS.md rule 5),
|
||||
the page-head, the gate (ship-hidden), the role="status" live
|
||||
region, the table inside the .table-wrap card, and the shell's ONE
|
||||
footer with the version span (the history page's footer copy is
|
||||
dropped — no duplicate #app-version)."""
|
||||
html = _shell()
|
||||
assert '<a class="skip-link" href="#main">' in html
|
||||
assert 'class="app-header"' in html
|
||||
assert 'nav class="app-nav" id="app-nav" aria-label="Primary"' in html
|
||||
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
|
||||
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
|
||||
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
|
||||
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
|
||||
assert '<h1>Saved chats</h1>' in html
|
||||
assert html.count('id="steering-panel"') == 1, (
|
||||
"the shell carries its ONE steering panel (the view's copy is dropped)"
|
||||
)
|
||||
assert html.count('id="steering-announcer"') == 1
|
||||
assert html.count('<main id="main" class="app-main" tabindex="-1">') == 1
|
||||
# The view section: hidden AND inert (the WCAG pair) + focusable.
|
||||
view = re.search(r'<section[^>]*id="view-history"[^>]*>', html)
|
||||
assert view, "the #view-history section must be in the shell"
|
||||
view_tag = view.group(0)
|
||||
assert "hidden" in view_tag and "inert" in view_tag, (
|
||||
"the folded view ships hidden AND inert"
|
||||
)
|
||||
assert 'tabindex="-1"' in view_tag, "the target view is focusable"
|
||||
body = _view(html)
|
||||
assert '<h1>Saved chats</h1>' in body
|
||||
# The anonymous gate — the #sources-gate pattern, ship-hidden.
|
||||
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', html)
|
||||
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', body)
|
||||
assert gate and "hidden" in gate.group(0), "#history-gate must ship hidden"
|
||||
assert 'href="/login.html?next=/history.html"' in html, (
|
||||
"the gate's Sign in returns to the History page (no-JS fallback)"
|
||||
assert 'href="/login.html?next=/history.html"' in body, (
|
||||
"the gate's Sign in returns to the History view (no-JS fallback)"
|
||||
)
|
||||
# The action-feedback live region.
|
||||
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', html)
|
||||
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', body)
|
||||
# The table wrapper: the .table-wrap card (scrollable) with its
|
||||
# own id, a labeled region, focusable.
|
||||
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', html)
|
||||
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', body)
|
||||
assert wrap, "the table must live in the .table-wrap card"
|
||||
assert 'id="history-table-wrap"' in wrap.group(0)
|
||||
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
|
||||
# Footer with the version span.
|
||||
# The shell keeps its ONE footer with the version span (the history
|
||||
# page's footer copy is dropped — no duplicate #app-version).
|
||||
assert 'class="footer-version" id="app-version"' in html
|
||||
assert html.count('id="app-version"') == 1
|
||||
|
||||
|
||||
def test_history_table_skeleton() -> None:
|
||||
@@ -187,8 +239,9 @@ def test_history_table_skeleton() -> None:
|
||||
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
|
||||
Actions (the Actions header text is visually-hidden — the row
|
||||
buttons carry their own aria-labels) — and the empty-state row
|
||||
(ship-hidden, the exact copy)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
(ship-hidden, the exact copy). Phase 76 (task 03): scoped to the
|
||||
shell's History view section."""
|
||||
html = _view(_shell())
|
||||
assert '<table class="history-table">' in html
|
||||
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
|
||||
'<th scope="col">Updated</th>', '<th scope="col">Stale</th>',
|
||||
@@ -224,16 +277,22 @@ def test_history_table_skeleton() -> None:
|
||||
) in html
|
||||
|
||||
|
||||
def test_history_page_scripts_and_no_cdn() -> None:
|
||||
"""Script load order (the house pattern): brand.js classic FIRST,
|
||||
the history.js module second, NO direct header.js <script> tag
|
||||
(single-evaluation design — history.js imports it relatively).
|
||||
No-CDN rule (AGENTS.md rule 6): no external script/link tags."""
|
||||
html = _text(HISTORY_HTML)
|
||||
def test_shell_scripts_and_no_cdn() -> None:
|
||||
"""Shell script load order (the house pattern): brand.js classic
|
||||
FIRST, the app.js (chat) + router.js modules, NO direct history.js
|
||||
<script> tag (single-evaluation design — the router lazy-imports
|
||||
the view module on first show; in the image the Containerfile's
|
||||
esbuild stage inlines it into the router bundle). history.js keeps
|
||||
its relative shared-header import (it uses the cached whoami
|
||||
promise). No-CDN rule (AGENTS.md rule 6): no external
|
||||
script/link tags."""
|
||||
html = _shell()
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert srcs == ["assets/brand.js", "/assets/history.js"], (
|
||||
f"history.html must load brand.js (classic, first) + the history.js "
|
||||
f"module, got {srcs}"
|
||||
assert srcs[0] == "assets/brand.js", "brand.js (classic) must load first"
|
||||
assert "/assets/app.js" in srcs, "the shell loads the chat module"
|
||||
assert "/assets/router.js" in srcs, "the shell loads the router module"
|
||||
assert [s for s in srcs if "history.js" in s] == [], (
|
||||
"no direct history.js tag — the router lazy-imports the view module"
|
||||
)
|
||||
js = _js()
|
||||
assert 'from "./header.js"' in js, (
|
||||
@@ -249,12 +308,17 @@ def test_history_page_scripts_and_no_cdn() -> None:
|
||||
|
||||
|
||||
def test_anonymous_boot_makes_no_chats_request() -> None:
|
||||
"""The whoami gate in the boot IIFE: ``initSharedHeader()`` first
|
||||
(shared-header contract), then the anonymous branch hides the
|
||||
table, shows the gate, and RETURNS — no ``/api/chats`` request on
|
||||
the wire (the router 403s anonymous; the story E2E pins the
|
||||
request log). Only the admin path reaches ``loadChats()``. The
|
||||
single ``fetch("/api/chats")`` in the file lives in loadChats."""
|
||||
"""The whoami gate in ``mount(root)`` (phase 76 task 03 — the shell
|
||||
view module form): the view module does NOT boot the shared header
|
||||
(the shell's header boots exactly once, via the chat module
|
||||
(app.js) at shell boot — no call site, and the import carries ONLY
|
||||
the cached whoami promise), so the anonymous branch gates on
|
||||
``fetchIsAdmin()`` alone (the SAME cached /api/whoami request —
|
||||
zero extra), hides the table, shows the gate, and RETURNS — no
|
||||
``/api/chats`` request on the wire (the router 403s anonymous; the
|
||||
story E2E pins the request log). Only the admin path reaches
|
||||
``loadChats()``. The single ``fetch("/api/chats")`` in the file
|
||||
lives in loadChats."""
|
||||
js = _js()
|
||||
assert js.count('fetch("/api/chats")') == 1, (
|
||||
"exactly ONE list fetch — the anonymous path must never add one"
|
||||
@@ -262,19 +326,33 @@ def test_anonymous_boot_makes_no_chats_request() -> None:
|
||||
load = _fn(js, "loadChats")
|
||||
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
|
||||
|
||||
boot = js[js.find("(async () => {"):]
|
||||
assert boot, "the boot IIFE must exist"
|
||||
assert "await initSharedHeader()" in boot
|
||||
gate_i = boot.find("if (!(await fetchIsAdmin()))")
|
||||
assert gate_i != -1, "the whoami gate must run in boot"
|
||||
# Phase 76 (task 03): the view module never boots the shell's
|
||||
# header — no CALL to the header boot (a docstring may name it;
|
||||
# a call may not) and no initSharedHeader in the import — the
|
||||
# shell's header boots via the chat module (app.js) at shell boot.
|
||||
assert "await initSharedHeader()" not in js, (
|
||||
"the view must not re-boot the shell's header"
|
||||
)
|
||||
import_lines = [line for line in js.splitlines() if line.strip().startswith("import")]
|
||||
assert all("initSharedHeader" not in line for line in import_lines), (
|
||||
"the view must not import the header boot — the shell's header boots "
|
||||
"via the chat module (app.js) at shell boot"
|
||||
)
|
||||
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)")
|
||||
assert mount_i != -1, "mount(root) must be the module's entry"
|
||||
gate_i = js.find("if (!(await fetchIsAdmin()))")
|
||||
assert gate_i > mount_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 = boot[gate_i : boot.find("return;", gate_i)]
|
||||
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 admin path: the gate hides, then the list loads.
|
||||
after = boot[boot.find("return;", gate_i):]
|
||||
after = js[js.find("return;", gate_i):]
|
||||
assert "gateEl.hidden = true" in after
|
||||
assert "loadChats();" in after
|
||||
|
||||
@@ -346,9 +424,9 @@ def test_two_step_delete_confirm_pair() -> None:
|
||||
yes_swap = fn.find("cell.replaceChildren(label, yes, no)")
|
||||
assert fn.find("yes.focus()", yes_swap) > 0, "focus moves to Yes after the swap"
|
||||
# No (and the restore helper) bring the Delete button back, focused.
|
||||
restore_start = fn.find("function restoreDelete")
|
||||
restore_end = fn.find("\n }", restore_start)
|
||||
restore = fn[restore_start:restore_end]
|
||||
# (Brace-balanced — since phase 76 task 03 the helper lives inside
|
||||
# mount(root), so a line-leading-brace slice would not find it.)
|
||||
restore = _fn(js, "restoreDelete")
|
||||
assert "cell.replaceChildren(del)" in restore
|
||||
assert "del.focus()" in restore
|
||||
assert 'no.addEventListener("click", restoreDelete)' in fn
|
||||
|
||||
@@ -32,7 +32,8 @@ source-level contract a silent regression would break:
|
||||
in-modal alert line + dialog stays open, network →
|
||||
the fixed reachable? line, re-enable in the finally);
|
||||
* the stale "prunes on the next sync" removal copy is GONE from
|
||||
``git-sources.js`` + ``git-sources.html``; the new hint copy is
|
||||
``git-sources.js`` + the shell (phase 76 task 02 folded git-
|
||||
sources.html into index.html's Sources view); the new hint copy is
|
||||
PRESENT (the README pins are task 03's);
|
||||
* styles.css — the modal classes on the house dark-tech palette
|
||||
(phase-08 tokens only, no CDN, no blur), ≥44px buttons, the
|
||||
@@ -44,7 +45,10 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
HTML = FRONTEND / "git-sources.html"
|
||||
# Phase 76 (task 02): git-sources.html is folded into the ONE-document
|
||||
# shell — the dialog markup now lives in the Sources view section of
|
||||
# index.html (moved verbatim, ids unchanged).
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
JS = FRONTEND / "assets" / "git-sources.js"
|
||||
CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
@@ -121,7 +125,7 @@ def _element_block(html: str, id_attr: str, tag: str = "div") -> str:
|
||||
a <p>)."""
|
||||
marker = f'id="{id_attr}"'
|
||||
i = html.find(marker)
|
||||
assert i != -1, f"missing id={id_attr} in git-sources.html"
|
||||
assert i != -1, f"missing id={id_attr} in the shell's Sources view"
|
||||
opens = [m.start() for m in re.finditer(rf"<{tag}\b", html[:i])]
|
||||
assert opens, f"no <{tag}> owns id={id_attr}"
|
||||
open_i = opens[-1]
|
||||
@@ -161,7 +165,7 @@ def test_dialog_markup_is_the_locked_alertdialog() -> None:
|
||||
selectors); all six child ids present; the error line is
|
||||
role="alert"; both buttons are real type="button"; the title is
|
||||
the locked h2; the modal copy is the locked paragraph verbatim."""
|
||||
html = _text(HTML)
|
||||
html = _text(SHELL_HTML)
|
||||
frag = _element_block(html, "remove-confirm-dialog")
|
||||
open_tag = frag[: frag.find(">") + 1]
|
||||
assert 'role="alertdialog"' in open_tag
|
||||
@@ -349,10 +353,10 @@ def test_confirm_runs_the_inflight_never_stale_lifecycle() -> None:
|
||||
def test_stale_next_sync_removal_copy_is_gone() -> None:
|
||||
"""The phase-35 "prunes on the next sync" removal contract is
|
||||
superseded: the retired confirm copy AND any 'prune(s/d) … next
|
||||
sync' shape are absent from git-sources.js + git-sources.html
|
||||
(code AND comments — the docstring copy moved with the flow).
|
||||
The README pins are task 03's."""
|
||||
for path in (JS, HTML):
|
||||
sync' shape are absent from git-sources.js + the shell (code AND
|
||||
comments — the docstring copy moved with the flow). The README
|
||||
pins are task 03's."""
|
||||
for path in (JS, SHELL_HTML):
|
||||
raw = _text(path)
|
||||
norm = _norm(raw)
|
||||
assert OLD_STAY_INDEXED not in raw, (
|
||||
@@ -368,7 +372,7 @@ def test_new_hint_copy_is_present_in_the_html() -> None:
|
||||
local directories are never touched), and the Sync button still
|
||||
mirrors the remaining sources (upstream churn is pruned on that
|
||||
run — not 'on the next sync')."""
|
||||
hint = _norm(_element_block(_text(HTML), "git-sources-hint", tag="p"))
|
||||
hint = _norm(_element_block(_text(SHELL_HTML), "git-sources-hint", tag="p"))
|
||||
for frag in (HINT_TOTAL_REMOVAL, HINT_MODAL_SPELLS_OUT, HINT_FOREVER_SAFE):
|
||||
assert frag in hint, f"the new hint copy is missing: {frag!r}"
|
||||
assert "pruned on that run" in hint, "the Sync-mirror clause (upstream churn)"
|
||||
|
||||
@@ -63,12 +63,11 @@ from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
# Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both
|
||||
# views are folded into the shell (index.html); both files deleted.
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
@@ -423,8 +422,11 @@ def test_share_button_ships_visible_beside_new_chat() -> None:
|
||||
"#messages, above the composer (phase 65)"
|
||||
)
|
||||
assert messages_end < new_idx, ("the row moved below the #messages section")
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
# Phase 76 (task 02): the folded view files are gone (the shell's
|
||||
# chat view is the one and only carrier of the button — pinned
|
||||
# above); the standalone pages carry none (task 03 dropped the
|
||||
# last folded file, history.html).
|
||||
for other in (DOCUMENT_HTML, LOGIN_HTML):
|
||||
assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the Share button is chat-page only"
|
||||
)
|
||||
@@ -729,8 +731,11 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
||||
and "<section" not in after
|
||||
and "<form" not in after
|
||||
), ("nothing but the composer comment lands between the row and the composer")
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
# Phase 76 (task 02): the folded view files are gone (the shell's
|
||||
# chat view is the one and only carrier of the row — pinned above);
|
||||
# the standalone pages carry none (task 03 dropped the last folded
|
||||
# file, history.html).
|
||||
for other in (DOCUMENT_HTML, LOGIN_HTML):
|
||||
assert "chat-actions" not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the action row is chat-page only"
|
||||
)
|
||||
@@ -823,8 +828,11 @@ def test_stale_banner_html_after_kb_banner() -> None:
|
||||
# the kb-banner warning triangle) — aria-hidden decoration.
|
||||
lead = block[: btn.start()]
|
||||
assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
# Phase 76 (task 02): the folded view files are gone (the shell's
|
||||
# chat view is the one and only carrier of the banner — pinned
|
||||
# above); the standalone pages carry none (task 03 dropped the last
|
||||
# folded file, history.html).
|
||||
for other in (DOCUMENT_HTML, LOGIN_HTML):
|
||||
assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the stale banner is chat-page only"
|
||||
)
|
||||
|
||||
@@ -22,14 +22,14 @@ 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"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
@@ -126,27 +126,37 @@ def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
|
||||
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)."""
|
||||
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
|
||||
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_all_six_pages_share_the_header_control_order() -> None:
|
||||
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 (Sources page only)
|
||||
and the #new-chat-btn (chat page only, moved from the navbar at
|
||||
(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)."""
|
||||
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"',
|
||||
'<nav class="app-nav"',
|
||||
@@ -159,12 +169,9 @@ def test_all_six_pages_share_the_header_control_order() -> None:
|
||||
)
|
||||
for html in (
|
||||
INDEX_HTML,
|
||||
SOURCES_HTML,
|
||||
GIT_SOURCES_HTML,
|
||||
DOCUMENT_HTML,
|
||||
TUNING_HTML,
|
||||
LOGIN_HTML,
|
||||
):
|
||||
): # phase 76 (tasks 01/02): the folded view files are gone — the shell's ONE header stands in
|
||||
text = _text(html)
|
||||
start = text.find('<div class="container header-inner">')
|
||||
assert start != -1, f"{html.name}: missing the shared .header-inner row"
|
||||
@@ -185,8 +192,14 @@ def test_all_five_pages_carry_the_steering_panel() -> None:
|
||||
"""Phase 34 task 03: the #steering-panel section (+ the
|
||||
#steering-announcer live region) ships on every page — after
|
||||
#kb-banner in the chat shell, first child of <main> on the other
|
||||
four pages — ship hidden, driven by assets/header.js."""
|
||||
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, TUNING_HTML, LOGIN_HTML):
|
||||
four pages — ship hidden, driven by assets/header.js.
|
||||
|
||||
Phase 76 (task 01): the folded Tuning view's panel COPY is dropped
|
||||
(the shell keeps the chat's ONE instance — duplicate ids are not
|
||||
legal). Phase 76 (task 02): the folded RAG + Sources view copies
|
||||
are dropped with their files (the shell's ONE panel stands in);
|
||||
task 03 drops History. The page list is the post-shell set."""
|
||||
for html in (INDEX_HTML, DOCUMENT_HTML, LOGIN_HTML):
|
||||
text = _text(html)
|
||||
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', text)
|
||||
assert tag, f"{html.name}: missing the #steering-panel section"
|
||||
@@ -200,23 +213,32 @@ def test_all_five_pages_carry_the_steering_panel() -> None:
|
||||
assert text.find('id="steering-panel"') < text.find('id="steering-announcer"')
|
||||
|
||||
|
||||
def test_nav_tuning_ships_hidden_on_the_tuning_page() -> None:
|
||||
"""Phase 27: the Global Tuning page reuses the shared header — the
|
||||
"Tuning" nav link is admin-only, so it SHIPS hidden (revealed by
|
||||
initSharedHeader once whoami says admin), is the page's active link
|
||||
(is-active + aria-current), and the page loads markdown.js (classic)
|
||||
+ the tuning.js module with NO direct header.js <script> tag
|
||||
(single-evaluation design)."""
|
||||
text = _text(TUNING_HTML)
|
||||
def test_nav_tuning_ships_hidden_and_unstamped_on_the_shell() -> None:
|
||||
"""Phase 27 + phase 76 (task 01) — the shell form of this pin: the
|
||||
"Tuning" nav link is admin-only, so it SHIPS hidden in the shell's
|
||||
ONE header (revealed by initSharedHeader once whoami says admin).
|
||||
The old page-level active stamp (is-active + aria-current on
|
||||
tuning.html's own link) is GONE — the router is the SINGLE WRITER
|
||||
of the active state (client-side, per view); only the Chat link
|
||||
may carry a static stamp. The shell loads markdown.js (classic) +
|
||||
app.js + router.js (modules) with NO direct header.js <script> tag
|
||||
(single-evaluation design), and no direct tuning.js tag (the router
|
||||
lazy-imports it on first show — mount-once)."""
|
||||
text = _text(INDEX_HTML)
|
||||
tag = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', text)
|
||||
assert tag, "tuning.html must carry the #nav-tuning nav link"
|
||||
assert 'class="nav-link is-active"' in tag.group(0), "the Tuning link is the active one"
|
||||
assert 'aria-current="page"' in tag.group(0)
|
||||
assert tag, "the shell must carry the #nav-tuning nav link"
|
||||
assert "hidden" in tag.group(0), "#nav-tuning must ship hidden (admin-only)"
|
||||
srcs = _script_srcs(TUNING_HTML)
|
||||
assert "is-active" not in tag.group(0), (
|
||||
"no static active stamp — the router is the single writer"
|
||||
)
|
||||
assert 'aria-current="page"' not in tag.group(0)
|
||||
srcs = _script_srcs(INDEX_HTML)
|
||||
assert [s for s in srcs if "header.js" in s] == [], "no direct header.js <script> tag"
|
||||
assert [s for s in srcs if "markdown.js" in s]
|
||||
assert [s for s in srcs if "tuning.js" in s]
|
||||
assert [s for s in srcs if "router.js" in s], "the shell loads the router module"
|
||||
assert [s for s in srcs if "tuning.js" in s] == [], (
|
||||
"no direct tuning.js tag — the router lazy-imports the view module"
|
||||
)
|
||||
|
||||
|
||||
def test_viewer_carries_the_standard_nav() -> None:
|
||||
@@ -253,27 +275,52 @@ def test_viewer_carries_the_standard_nav() -> None:
|
||||
assert back, "the back link keeps its /sources.html no-JS fallback"
|
||||
|
||||
|
||||
def test_sources_and_viewer_carry_the_shared_controls() -> None:
|
||||
"""Sources AND the document viewer carry the Sign in / Sign out pair
|
||||
(both starting hidden — initSharedHeader reveals exactly one after
|
||||
whoami), and their page scripts load header.js (phase 23: via the
|
||||
page script's relative import, not a direct script tag). The New
|
||||
chat button is NOT on non-chat pages — it moved from the navbar to
|
||||
the chat page at owner request (2026-08-28; the single binding is
|
||||
pinned in test_new_chat_binding_is_single_and_module_owned)."""
|
||||
for html in (SOURCES_HTML, DOCUMENT_HTML):
|
||||
def test_sources_view_and_viewer_carry_the_shared_controls() -> None:
|
||||
"""Sources (the shell's RAG view, phase 76 task 02) AND the document
|
||||
viewer carry the Sign in / Sign out pair (both starting hidden —
|
||||
initSharedHeader reveals exactly one after whoami), and their
|
||||
scripts load header.js (phase 23: via the relative import, not a
|
||||
direct script tag). The New chat button is chat-view only — in the
|
||||
shell its ONE instance lives in the chat view (hidden + inert on
|
||||
every other view); the viewer carries none (the single binding is
|
||||
pinned in test_new_chat_binding_is_single_and_module_owned).
|
||||
The RAG view module does NOT boot the shell's header (it boots once
|
||||
via app.js) — it keeps fetchIsAdmin() for its admin gate (zero
|
||||
extra requests)."""
|
||||
for html in (INDEX_HTML, DOCUMENT_HTML):
|
||||
text = _text(html)
|
||||
assert 'id="new-chat-btn"' not in text, (
|
||||
f"{html.name}: the New chat button is chat-page only (owner request)"
|
||||
)
|
||||
assert re.search(r'id="sign-in-link"[^>]*\bhidden\b', text)
|
||||
assert re.search(r'id="sign-out-btn"[^>]*\bhidden\b', text)
|
||||
for js_file in (SOURCES_JS, DOCUMENT_JS):
|
||||
assert 'from "./header.js"' in _text(js_file), (
|
||||
f"{js_file.name}: page script must load the shared header module"
|
||||
)
|
||||
# The New chat button: exactly ONE instance in the shell (the chat
|
||||
# view's) — none on the viewer.
|
||||
assert _text(INDEX_HTML).count('id="new-chat-btn"') == 1
|
||||
assert 'id="new-chat-btn"' not in _text(DOCUMENT_HTML), (
|
||||
"the New chat button is chat-view only (owner request)"
|
||||
)
|
||||
assert 'from "./header.js"' in _text(DOCUMENT_JS), (
|
||||
"document.js: page script must load the shared header module"
|
||||
)
|
||||
# The RAG view module (the phase-76 view-module branch):
|
||||
# relative import, NO header boot, the fetchIsAdmin gate.
|
||||
rag_js = _text(SOURCES_JS)
|
||||
assert 'from "./header.js"' in rag_js
|
||||
import_lines = [
|
||||
line for line in rag_js.splitlines() if line.strip().startswith("import")
|
||||
]
|
||||
assert all("initSharedHeader" not in line for line in import_lines), (
|
||||
"the view must not import the header boot — the shell's header boots "
|
||||
"via the chat module (app.js) at shell boot"
|
||||
)
|
||||
assert "await initSharedHeader()" not in rag_js, (
|
||||
"the view must not re-boot the shell's header"
|
||||
)
|
||||
assert "fetchIsAdmin()" in rag_js, "the admin gate keeps the shared cached promise"
|
||||
assert "new-chat-btn" not in rag_js, (
|
||||
"no #new-chat-btn binding (the module owns the single instance)"
|
||||
)
|
||||
assert 'fetch("/api/whoami")' not in rag_js
|
||||
# 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=/sources.html"' in _text(INDEX_HTML)
|
||||
assert 'href="/login.html?next=/document.html"' in _text(DOCUMENT_HTML)
|
||||
|
||||
|
||||
@@ -288,7 +335,6 @@ def test_header_module_loads_before_the_page_script() -> None:
|
||||
would double-bind the sign-out listener)."""
|
||||
cases = [
|
||||
(INDEX_HTML, "app.js"),
|
||||
(SOURCES_HTML, "sources.js"),
|
||||
(DOCUMENT_HTML, "document.js"),
|
||||
(LOGIN_HTML, "login.js"),
|
||||
]
|
||||
@@ -307,6 +353,20 @@ def test_header_module_loads_before_the_page_script() -> None:
|
||||
assert 'from "/assets/header.js"' not in js, (
|
||||
f"{page_script}: absolute header import would break the esbuild bundle"
|
||||
)
|
||||
# Phase 76 (task 02): the folded RAG view module is NOT directly
|
||||
# loaded by the shell — the router lazy-imports it on first show
|
||||
# (mount-once, like the Tuning view module); it still imports
|
||||
# header.js relatively (single-evaluation design).
|
||||
shell_srcs = _script_srcs(INDEX_HTML)
|
||||
assert [s for s in shell_srcs if "sources.js" in s] == [], (
|
||||
"no direct sources.js <script> tag — the router lazy-imports the view"
|
||||
)
|
||||
assert [s for s in shell_srcs if "router.js" in s], (
|
||||
"the router is the loader of the folded view modules"
|
||||
)
|
||||
js = _text(SOURCES_JS)
|
||||
assert 'from "./header.js"' in js
|
||||
assert 'from "/assets/header.js"' not in js
|
||||
|
||||
|
||||
def test_login_page_carries_the_full_header() -> None:
|
||||
@@ -403,16 +463,71 @@ def test_new_chat_binding_is_single_and_module_owned() -> None:
|
||||
assert 'window.location.href = "/"' not in js, (
|
||||
"no navigate-to-chat branch left (the button left the navbar)"
|
||||
)
|
||||
for js_file in (SOURCES_JS, DOCUMENT_JS, TUNING_JS):
|
||||
page_js = _text(js_file)
|
||||
assert 'from "./header.js"' in page_js
|
||||
assert "initSharedHeader()" in page_js
|
||||
assert "new-chat-btn" not in page_js, (
|
||||
f"{js_file.name}: no #new-chat-btn binding (the module owns it)"
|
||||
)
|
||||
assert 'fetch("/api/whoami")' not in page_js, (
|
||||
f"{js_file.name}: whoami goes through the shared cached promise"
|
||||
)
|
||||
doc_js = _text(DOCUMENT_JS)
|
||||
assert 'from "./header.js"' in doc_js
|
||||
assert "initSharedHeader()" in doc_js
|
||||
assert "new-chat-btn" not in doc_js, (
|
||||
"document.js: no #new-chat-btn binding (the module owns it)"
|
||||
)
|
||||
assert 'fetch("/api/whoami")' not in doc_js, (
|
||||
"document.js: whoami goes through the shared cached promise"
|
||||
)
|
||||
# Phase 76 (task 02): the RAG view module is the VIEW-MODULE branch
|
||||
# (like the Tuning view module below) — it does not boot the shell's
|
||||
# header, binds no #new-chat-btn, and gates on the shared cached
|
||||
# promise.
|
||||
rag_js = _text(SOURCES_JS)
|
||||
assert 'from "./header.js"' in rag_js
|
||||
assert "await initSharedHeader()" not in rag_js
|
||||
assert "fetchIsAdmin()" in rag_js, "the admin gate keeps the shared cached promise"
|
||||
assert "new-chat-btn" not in rag_js, (
|
||||
"sources.js: no #new-chat-btn binding (the module owns it)"
|
||||
)
|
||||
assert 'fetch("/api/whoami")' not in rag_js, (
|
||||
"sources.js: whoami goes through the shared cached promise"
|
||||
)
|
||||
# Phase 76 (task 01): the folded Tuning VIEW module no longer boots
|
||||
# the header — in the shell it runs exactly once, via the chat
|
||||
# module (app.js) at shell boot. The view keeps fetchIsAdmin() for
|
||||
# its admin gate (the SAME cached whoami promise — zero extra
|
||||
# requests) and imports the module relatively (single-evaluation).
|
||||
tuning_js = _text(TUNING_JS)
|
||||
assert 'from "./header.js"' in tuning_js
|
||||
# No CALL to the header boot — the import line carries no
|
||||
# initSharedHeader and no `await initSharedHeader()` call site
|
||||
# exists (a docstring may name it; a call may not).
|
||||
import_lines = [
|
||||
line for line in tuning_js.splitlines() if line.strip().startswith("import")
|
||||
]
|
||||
assert all("initSharedHeader" not in line for line in import_lines), (
|
||||
"the view must not import the header boot — the shell's header boots "
|
||||
"via the chat module (app.js) at shell boot"
|
||||
)
|
||||
assert "await initSharedHeader()" not in tuning_js, (
|
||||
"the view must not re-boot the shell's header"
|
||||
)
|
||||
assert "fetchIsAdmin()" in tuning_js, "the admin gate keeps the shared cached promise"
|
||||
assert "new-chat-btn" not in tuning_js
|
||||
assert 'fetch("/api/whoami")' not in tuning_js
|
||||
# Phase 76 (task 03): the folded History VIEW module — the same
|
||||
# view-module contract: no header boot, no #new-chat-btn binding,
|
||||
# the admin gate on the shared cached whoami promise (zero extra
|
||||
# requests), the relative import for single evaluation.
|
||||
history_js = _text(HISTORY_JS)
|
||||
assert 'from "./header.js"' in history_js
|
||||
import_lines = [
|
||||
line for line in history_js.splitlines() if line.strip().startswith("import")
|
||||
]
|
||||
assert all("initSharedHeader" not in line for line in import_lines), (
|
||||
"the view must not import the header boot — the shell's header boots "
|
||||
"via the chat module (app.js) at shell boot"
|
||||
)
|
||||
assert "await initSharedHeader()" not in history_js, (
|
||||
"the view must not re-boot the shell's header"
|
||||
)
|
||||
assert "fetchIsAdmin()" in history_js, "the admin gate keeps the shared cached promise"
|
||||
assert "new-chat-btn" not in history_js
|
||||
assert 'fetch("/api/whoami")' not in history_js
|
||||
app_js = _text(APP_JS)
|
||||
assert 'window.addEventListener("bor:new-chat", startNewChat)' in app_js
|
||||
assert "newChatBtn.addEventListener" not in app_js, (
|
||||
|
||||
@@ -19,12 +19,15 @@ CONFIG_PY = ROOT / "app" / "config.py"
|
||||
#: tuple, repeated here so this file stands alone).
|
||||
HTML_PAGES = (
|
||||
"index.html",
|
||||
"sources.html",
|
||||
"tuning.html",
|
||||
# Phase 76 (task 01): tuning.html is folded into the shell (deleted)
|
||||
# — the shell (index.html) stands in for it here.
|
||||
# Phase 76 (task 02): sources.html + git-sources.html are folded
|
||||
# too (both deleted — the shell stands in for both views).
|
||||
# Phase 76 (task 03): history.html is folded too (deleted — the
|
||||
# shell stands in for the History view; all four folded view files
|
||||
# are gone).
|
||||
"document.html",
|
||||
"login.html",
|
||||
"git-sources.html",
|
||||
"history.html",
|
||||
"shared.html",
|
||||
"doc-edit.html",
|
||||
)
|
||||
@@ -89,17 +92,21 @@ def test_chat_page_old_copy_is_gone() -> None:
|
||||
|
||||
|
||||
def test_sources_page_old_copy_is_gone() -> None:
|
||||
"""sources.html: the ~/Homelab + ~/Deployments page-sub citations and
|
||||
the old footer are retired."""
|
||||
html = _text(FRONTEND / "sources.html")
|
||||
"""The shell's RAG view (formerly sources.html, phase 76 task 02):
|
||||
the ~/Homelab + ~/Deployments page-sub citations and the old footer
|
||||
are retired (the whole shell — which carries the view — must be
|
||||
clean)."""
|
||||
html = _text(FRONTEND / "index.html")
|
||||
for frag in ("~/Homelab", "~/Deployments", OLD_FOOTER):
|
||||
assert frag not in html, f"retired sources copy still present: {frag!r}"
|
||||
|
||||
|
||||
def test_git_sources_page_old_copy_is_gone() -> None:
|
||||
"""git-sources.html: the old example repo URL (A3) and the old
|
||||
footer are retired."""
|
||||
html = _text(FRONTEND / "git-sources.html")
|
||||
"""The shell's Sources view (formerly git-sources.html, phase 76
|
||||
task 02): the old example repo URL (A3) and the old footer are
|
||||
retired (the whole shell — which carries the view — must be
|
||||
clean)."""
|
||||
html = _text(FRONTEND / "index.html")
|
||||
for frag in (OLD_GIT_EXAMPLE, OLD_FOOTER):
|
||||
assert frag not in html, f"retired git-sources copy still present: {frag!r}"
|
||||
|
||||
@@ -114,19 +121,29 @@ def test_chat_page_locked_copy_present_exactly_once() -> None:
|
||||
|
||||
|
||||
def test_sources_page_sub_is_the_locked_copy() -> None:
|
||||
"""The KB .page-sub (the string the TODO cited by name) reads the
|
||||
locked (A1) copy, including the <strong> around Sync sources —
|
||||
pinned inside the .page-sub element, not anywhere in the file."""
|
||||
html = _text(FRONTEND / "sources.html")
|
||||
m = re.search(r'<p class="page-sub">(.*?)</p>', html, re.DOTALL)
|
||||
assert m, "sources.html must keep the .page-sub"
|
||||
"""The RAG view's .page-sub (the string the TODO cited by name) reads
|
||||
the locked (A1) copy, including the <strong> around Sync sources —
|
||||
pinned inside the .page-sub element, not anywhere in the file.
|
||||
Phase 76 (task 02): scoped to the RAG view — the shell carries one
|
||||
.page-sub per view, and the earlier views' subs come first."""
|
||||
html = _text(FRONTEND / "index.html")
|
||||
i = html.find('id="view-rag"')
|
||||
j = html.find('id="view-git-sources"', i)
|
||||
assert -1 < i < j, "the RAG view section must be in the shell"
|
||||
m = re.search(r'<p class="page-sub">(.*?)</p>', html[i:j], re.DOTALL)
|
||||
assert m, "the RAG view must keep the .page-sub"
|
||||
assert _norm(m.group(1)) == PAGE_SUB
|
||||
|
||||
|
||||
def test_all_nine_footers_are_the_locked_neutral_default() -> None:
|
||||
"""Every page carries the locked (A1) footer inside exactly one
|
||||
``class="footer-text"`` span (the stable hook phase 62's
|
||||
BOR_FOOTER_TEXT env var drives)."""
|
||||
def test_all_five_footers_are_the_locked_neutral_default() -> None:
|
||||
"""Every standalone page carries the locked (A1) footer inside
|
||||
exactly one ``class="footer-text"`` span (the stable hook phase 62's
|
||||
BOR_FOOTER_TEXT env var drives). Phase 76 (task 02): the folded RAG
|
||||
+ Sources files are gone — the shell carries its footer once.
|
||||
Phase 76 (task 03): the History file is gone too — the shell's
|
||||
single (chat) footer stands in for the History view (its per-page
|
||||
footer copy, with the duplicate #app-version span, is dropped with
|
||||
the move)."""
|
||||
span = f'<span class="footer-text">{FOOTER}</span>'
|
||||
for page in HTML_PAGES:
|
||||
html = _text(FRONTEND / page)
|
||||
|
||||
@@ -6,7 +6,8 @@ the admin, removed from the DOM for anonymous. The owner asked for the
|
||||
button to go away entirely: note management now lives on the
|
||||
standalone Tuning page (``/tuning.html``, phase 27). This file pins the
|
||||
removal at source level — the toggle + count badge are ABSENT from all
|
||||
six pages, the ``#steering-panel`` section still ships hidden (kept
|
||||
standalone pages (the shell's ONE header covers its folded views —
|
||||
phase 76), the ``#steering-panel`` section still ships hidden (kept
|
||||
fresh by the chat page's per-bubble Tune form through header.js), the
|
||||
shared module owns no toggle wiring anymore, and the admin-only
|
||||
``#nav-tuning`` link — the surviving path to the notes — still ships
|
||||
@@ -22,17 +23,19 @@ ASSETS = FRONTEND / "assets"
|
||||
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
|
||||
#: All eight pages carry the shared header block (phase 34's five pages
|
||||
#: + phase 35's git-sources page + phase 50's History page +
|
||||
#: The standalone pages carry the shared header block (phase 34's five
|
||||
#: pages + phase 35's git-sources page + phase 50's History page +
|
||||
#: phase 51's shared page).
|
||||
#: Phase 76 (task 01): tuning.html is folded into the shell (deleted)
|
||||
#: — the shell (index.html) stands in for it here.
|
||||
#: Phase 76 (task 02): sources.html + git-sources.html are folded too
|
||||
#: (both deleted — the shell stands in for both views). Phase 76
|
||||
#: (task 03): history.html is folded too (deleted — the shell stands
|
||||
#: in for the History view; all four folded view files are gone).
|
||||
PAGES = (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "sources.html",
|
||||
FRONTEND / "document.html",
|
||||
FRONTEND / "git-sources.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "tuning.html",
|
||||
FRONTEND / "history.html",
|
||||
FRONTEND / "shared.html",
|
||||
)
|
||||
|
||||
@@ -52,7 +55,7 @@ def _init_body(js: str) -> str:
|
||||
# ---------- the removal: absent from every page's navbar ----------
|
||||
|
||||
|
||||
def test_steering_toggle_removed_from_all_six_pages() -> None:
|
||||
def test_steering_toggle_removed_from_all_pages() -> None:
|
||||
"""The #steering-toggle button (and its #steering-count badge) is
|
||||
gone from the navbar of EVERY page — absent, not hidden."""
|
||||
for html in PAGES:
|
||||
@@ -68,7 +71,7 @@ def test_steering_toggle_removed_from_all_six_pages() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_steering_panel_still_ships_hidden_on_all_six_pages() -> None:
|
||||
def test_steering_panel_still_ships_hidden_on_all_pages() -> None:
|
||||
"""The #steering-panel section survives the toggle removal (the chat
|
||||
page's per-bubble Tune form keeps it fresh through header.js) and
|
||||
still ships hidden, with its list / empty state / announcer."""
|
||||
@@ -117,7 +120,7 @@ def test_header_js_keeps_the_panel_contract() -> None:
|
||||
# ---------- the surviving path to the notes ----------
|
||||
|
||||
|
||||
def test_nav_tuning_still_ships_hidden_on_all_six_pages() -> None:
|
||||
def test_nav_tuning_still_ships_hidden_on_all_pages() -> None:
|
||||
"""The admin-only Tuning NAV LINK (#nav-tuning) — the surviving path
|
||||
to the steering notes now that the navbar toggle is gone — still
|
||||
ships hidden on every page (the phase-19/29/35 contract)."""
|
||||
|
||||
@@ -44,7 +44,9 @@ ASSETS = FRONTEND / "assets"
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
SOURCES_JS = ASSETS / "sources.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
# Phase 76 (task 02): sources.html is folded into the ONE-document shell
|
||||
# — the sync markup now lives in the RAG view section of index.html.
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
@@ -52,23 +54,46 @@ def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _rag_view(html: str) -> str:
|
||||
"""The RAG view section of the shell (view-scoped page-sub scope —
|
||||
the shell carries one .page-sub per view, so a whole-file match
|
||||
would hit the earlier views first)."""
|
||||
i = html.find('<section class="view" id="view-rag"')
|
||||
assert i != -1, "the RAG view section must be in the shell"
|
||||
j = html.find('<section class="view" id="view-git-sources"', i)
|
||||
assert j != -1, "the Sources view section must follow the RAG view"
|
||||
return html[i:j]
|
||||
|
||||
|
||||
def _body(js: str, fn_name: str) -> str:
|
||||
"""The source of the first top-level `function <fn_name>` in js."""
|
||||
"""The source of the first `function <fn_name>` in js (brace
|
||||
balanced — since phase 76 task 02 the functions live inside
|
||||
mount(root), so the closing brace is indented, not column 0)."""
|
||||
fn = js.find(f"function {fn_name}")
|
||||
assert fn != -1, f"{fn_name} must be defined"
|
||||
return js[fn : js.find("\n}", fn)]
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {fn_name}")
|
||||
|
||||
|
||||
# ---------- sources.html: anonymous-safe ship-hidden markup ----------
|
||||
# ---------- the shell's RAG view: anonymous-safe ship-hidden markup ----------
|
||||
|
||||
|
||||
def test_sync_button_ships_hidden_and_labeled() -> None:
|
||||
"""#sync-btn SHIPS with the hidden attribute (anonymous-safe —
|
||||
sources.js reveals it for the admin at page boot), is a real
|
||||
sources.js reveals it for the admin at view boot), is a real
|
||||
<button type="button">, and carries aria-label="Sync sources" so
|
||||
the accessible name stays stable across the label states."""
|
||||
tag = re.search(r"<button[^>]*id=\"sync-btn\"[^>]*>", _text(SOURCES_HTML))
|
||||
assert tag, "sources.html must carry the #sync-btn button"
|
||||
tag = re.search(r"<button[^>]*id=\"sync-btn\"[^>]*>", _text(SHELL_HTML))
|
||||
assert tag, "the shell must carry the #sync-btn button (RAG view)"
|
||||
attrs = tag.group(0)
|
||||
assert 'class="sync-btn"' in attrs
|
||||
assert 'type="button"' in attrs
|
||||
@@ -80,7 +105,7 @@ def test_sync_button_has_icon_and_label_span() -> None:
|
||||
"""The button body is a refresh-cycle svg (aria-hidden — decorative,
|
||||
the spin is the visible running state) + the .sync-label span with
|
||||
the idle text, so the label can be swapped by sources.js."""
|
||||
text = _text(SOURCES_HTML)
|
||||
text = _text(SHELL_HTML)
|
||||
btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))]
|
||||
assert re.search(r'<svg[^>]*class="sync-icon"[^>]*aria-hidden="true"', btn)
|
||||
# class for the module query + id for the E2E label assertions
|
||||
@@ -95,9 +120,9 @@ def test_sync_result_is_the_aria_live_announcer() -> None:
|
||||
"""#sync-result sits right after the button and is a polite live
|
||||
region (role="status" + aria-live="polite") — the last-result /
|
||||
counts announcement for screen readers."""
|
||||
text = _text(SOURCES_HTML)
|
||||
text = _text(SHELL_HTML)
|
||||
tag = re.search(r'<span[^>]*id="sync-result"[^>]*>', text)
|
||||
assert tag, "sources.html must carry the #sync-result announcer"
|
||||
assert tag, "the shell must carry the #sync-result announcer (RAG view)"
|
||||
attrs = tag.group(0)
|
||||
assert 'role="status"' in attrs
|
||||
assert 'aria-live="polite"' in attrs
|
||||
@@ -108,9 +133,9 @@ def test_sync_error_banner_is_a_hidden_alert() -> None:
|
||||
"""The failure banner uses the chat error-banner markup style
|
||||
(kb-banner + is-error) and role="alert", shipping hidden —
|
||||
sources.js un-hides it with the error text on a failed run."""
|
||||
text = _text(SOURCES_HTML)
|
||||
text = _text(SHELL_HTML)
|
||||
tag = re.search(r'<div[^>]*id="sync-error-banner"[^>]*>', text)
|
||||
assert tag, "sources.html must carry the #sync-error-banner"
|
||||
assert tag, "the shell must carry the #sync-error-banner (RAG view)"
|
||||
attrs = tag.group(0)
|
||||
assert "kb-banner" in attrs and "is-error" in attrs
|
||||
assert 'role="alert"' in attrs
|
||||
@@ -125,9 +150,10 @@ def test_page_sub_copy_mentions_the_button() -> None:
|
||||
the latest and re-import (the import CLI docs live elsewhere).
|
||||
Phase 61: the copy describes the current source model (git repos +
|
||||
local directories + uploaded archives), not the old ~/Homelab +
|
||||
~/Deployments clone."""
|
||||
sub = re.search(r'<p class="page-sub">(.*?)</p>', _text(SOURCES_HTML), re.DOTALL)
|
||||
assert sub, "sources.html must keep the .page-sub copy"
|
||||
~/Deployments clone. Phase 76 (task 02): scoped to the RAG view —
|
||||
the shell carries one .page-sub per view."""
|
||||
sub = re.search(r'<p class="page-sub">(.*?)</p>', _rag_view(_text(SHELL_HTML)), re.DOTALL)
|
||||
assert sub, "the RAG view must keep the .page-sub copy"
|
||||
copy = re.sub(r"\s+", " ", sub.group(1)) # the markup wraps lines
|
||||
assert "Press <strong>Sync sources</strong>" in copy
|
||||
assert "pull the latest and re-import" in copy
|
||||
@@ -139,22 +165,23 @@ def test_sources_page_stays_cdn_free() -> None:
|
||||
"""No-CDN rule (PLAN §7.3, A11): the new button markup adds no
|
||||
external references — same-origin assets only (the integration
|
||||
test_index_html_served_locally re-checks this on the served page)."""
|
||||
text = _text(SOURCES_HTML)
|
||||
text = _text(SHELL_HTML)
|
||||
assert 'src="https://' not in text
|
||||
assert 'href="https://' not in text
|
||||
|
||||
|
||||
# ---------- sources.js: the admin reveal (page boot) ----------
|
||||
# ---------- sources.js: the admin reveal (view boot) ----------
|
||||
|
||||
|
||||
def test_sources_js_reveals_sync_btn_on_the_admin_branch() -> None:
|
||||
"""The page boot reveals #sync-btn for the admin on the SAME cached
|
||||
whoami initSharedHeader() used (no extra fetch — header.js keeps
|
||||
the single /api/whoami call site); anonymous users never leave the
|
||||
ship-hidden default."""
|
||||
"""The view boot (mount's tail, phase 76 task 02) reveals #sync-btn
|
||||
for the admin on the SAME cached whoami fetchIsAdmin() reads (no
|
||||
extra fetch — header.js keeps the single /api/whoami call site; the
|
||||
header itself is booted exactly once, by the chat module at shell
|
||||
boot); anonymous users never leave the ship-hidden default."""
|
||||
js = _text(SOURCES_JS)
|
||||
boot = js[js.rfind("(async () => {"):]
|
||||
assert "const admin = await initSharedHeader()" in boot
|
||||
boot = js[js.find("view boot (phase 76 task 02)"):]
|
||||
assert "const admin = await fetchIsAdmin()" in boot
|
||||
assert "syncBtn.hidden = !admin" in boot, "#sync-btn must join the admin reveal"
|
||||
# The reveal must not introduce a second whoami call site.
|
||||
header = _text(HEADER_JS)
|
||||
@@ -337,16 +364,14 @@ def test_sources_js_reattaches_on_load_admin_only() -> None:
|
||||
result, idle settles retry-ready; the click binding wires
|
||||
startSync to the button."""
|
||||
js = _text(SOURCES_JS)
|
||||
fn = js.find("function initSyncButton")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
body = _body(js, "initSyncButton")
|
||||
assert "await fetchIsAdmin()" in body, "admin-only boot (no extra fetch)"
|
||||
assert 'fetch("/api/sync/status")' in body
|
||||
assert 'status.state === "running"' in body
|
||||
assert 'status.state === "success"' in body
|
||||
assert 'status.state === "failed"' in body
|
||||
assert (
|
||||
'syncBtn.addEventListener("click", startSync);\n initSyncButton();'
|
||||
'syncBtn.addEventListener("click", startSync);\n initSyncButton();'
|
||||
in js
|
||||
), "the click binding and the boot re-attach ship together, guarded on syncBtn"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user