Files
brain-of-reese/tests/unit/test_frontend_brand.py
T
ducoterra ffa919b8bf 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/.
2026-09-06 06:31:31 -04:00

237 lines
11 KiB
Python

"""Unit: the phase-39 brand layer (BOR_APP_NAME → window.BOR_BRAND).
No Python logic exists for this task — the behavior lives in
``frontend/assets/brand.js`` + the templates + the page scripts, and it
is E2E-gated by the story suite (task 03). Like the other
frontend-adjacent unit files, this module pins the JS markers the story
depends on, so a silent regression in the brand layer is caught without
a browser.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ROOT = FRONTEND.parent
BRAND_JS = FRONTEND / "assets" / "brand.js"
APP_JS = FRONTEND / "assets" / "app.js"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
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",
"document.html",
"login.html",
"shared.html", # phase 51: the anonymous shared-conversation page
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
)
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_brand_js_is_a_classic_script_with_synchronous_default() -> None:
"""brand.js is NOT a module (its top level must run at parse time,
before the page's module scripts evaluate) and sets window.BOR_BRAND
synchronously, BEFORE the /api/config fetch starts."""
js = _text(BRAND_JS)
assert not re.search(r"^\s*import\s", js, re.M), (
"brand.js must be a classic script — no import statements"
)
assert not re.search(r"^\s*export\s", js, re.M), (
"brand.js must be a classic script — no export statements"
)
default_idx = js.find('window.BOR_BRAND = "Brain of Reese"')
fetch_idx = js.find('fetch("/api/config"')
assert 0 <= default_idx < fetch_idx, (
"the default name must be set at top level before the fetch"
)
def test_brand_js_fetches_api_config_no_store() -> None:
"""The single source of the name is GET /api/config, never cached —
a renamed app must not serve a stale name from the HTTP cache — and
a fetch failure only warns (the loadHealth house style: the page
never breaks)."""
js = _text(BRAND_JS)
assert 'fetch("/api/config", { cache: "no-store" })' in js
assert "console.warn" in js
def test_brand_js_defers_dom_application_until_ready() -> None:
"""The DOM passes run on DOMContentLoaded while parsing, otherwise
immediately (the script sits at the end of <body>)."""
js = _text(BRAND_JS)
assert 'document.readyState === "loading"' in js
assert 'document.addEventListener("DOMContentLoaded", applyBrand)' in js
def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None:
"""The four DOM passes (phase 39 task 02): the document title,
every .brand-text (bold split for "Brain of …" names, plain text
otherwise — the name is HTML-escaped before innerHTML), a
TreeWalker over the text nodes (script/style nodes rejected — the
page source is never rewritten), and the aria-label / placeholder /
meta content attributes."""
js = _text(BRAND_JS)
assert "document.title.replaceAll" in js
assert ".brand-text" in js
assert 'name.startsWith("Brain of ")' in js
assert "escapeHTML" in js, "the name must be escaped before innerHTML"
assert "el.textContent = name" in js, "non-'Brain of ' names render plain"
assert "document.createTreeWalker" in js
assert "NodeFilter.SHOW_TEXT" in js
assert 'tag === "SCRIPT" || tag === "STYLE"' in js
assert "replaceAll(BRAND_LITERAL, name)" in js
for marker in ('"aria-label"', '"placeholder"', "meta[content]"):
assert marker in js, f"the attribute pass must cover {marker}"
def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
"""Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
/api/config answer also drives the three customization keys — the
#message-input placeholder, every .footer-text node, and the theme
stylesheet link (inserted right after the styles.css link, guarded
by #theme-override, degrading with a console.warn on 404 — A5).
No second network call: the keys ride the existing boot fetch."""
js = _text(BRAND_JS)
assert js.count('= fetch("/api/config"') == 1, (
"the three keys must ride the existing boot fetch — no new call"
)
# 5. The composer placeholder (chat page only — the null guard
# no-ops on every other page).
assert 'document.querySelector("#message-input")' in js
assert "input_placeholder" in js
# 6. The footer line on all 9 pages (the phase-61 hook) — via
# textContent: an operator string can't inject markup.
assert 'document.querySelectorAll(".footer-text")' in js
assert "footer_text" in js
# 7. The theme link: /assets/themes/<name>, inserted right after
# the styles.css link, tagged #theme-override (the idempotency
# guard), with the A5 degradation warn.
assert '"/assets/themes/"' in js
assert 'link.id = "theme-override"' in js
assert 'getElementById("theme-override")' in js
assert 'insertAdjacentElement("afterend", link)' in js
assert "link.onerror" in js
assert '"brand: theme " + themeName' in js
# The styles.css finder must survive the phase-33/54 cache-bust
# rewrite: the SERVED HTML carries the asset ref with a
# ?v=<token> query (and el.href is the absolute URL), so the match
# has to run on the RAW attribute path with query/fragment
# stripped — el.href.endsWith(…) would silently skip the insertion
# (the theme never applied; found by the task-05 E2E).
assert 'getAttribute("href")' in js
assert "split(/[?#]/)[0]" in js
assert 'endsWith("styles.css")' in js
assert "el.href.endsWith" not in js
# The empty-skip no-op contract: each key is guarded before any
# DOM write, so an unset deployment stays byte-identical.
for guard in ("if (placeholder) {", "if (footerText) {", "if (themeName) {"):
assert guard in js, (
f"an empty value must skip its application ({guard})"
)
# Independence: the phase-62 block sits AFTER the app_name passes
# in the same .then — never gated by the name.
assert js.index("// 4. Attributes:") < js.index("// Phase 62"), (
"the customization keys must apply after the app_name block, "
"even when the name is the default/empty"
)
# The app_name literal default pin still holds.
assert 'window.BOR_BRAND = "Brain of Reese"' in js
def test_page_scripts_keep_the_default_literal_exactly_once() -> None:
"""The fallback literal lives in the page scripts' brand() reads —
exactly one copy per file (a second copy could drift out of sync)."""
assert _text(APP_JS).count('"Brain of Reese"') == 1, (
"app.js: the literal must appear only in the brand() fallback"
)
assert _text(DOCUMENT_JS).count('"Brain of Reese"') == 1, (
"document.js: the literal must appear only in the brand() fallback"
)
def test_app_js_labels_resolve_the_name_at_call_time() -> None:
"""Phase 39 task 02: the status labels, the typing label, the
elapsed-seconds hint and the tool labels read window.BOR_BRAND
through brand() — at CALL time, so a label set after /api/config
lands carries the configured name (a module-level const would
freeze the default)."""
js = _text(APP_JS)
assert 'const brand = () => window.BOR_BRAND || "Brain of Reese";' in js
assert "`${brand()} is thinking`" in js
assert "`${brand()} is answering`" in js
assert "`${brand()} is still thinking (${secs}s)`" in js
assert "`${brand()} is reading ${argument}`" in js
assert "`${brand()} is listing documents`" in js
# The frozen module-level literals must be gone (stale-name bug).
assert '"Brain of Reese is thinking"' not in js
assert '"Brain of Reese is answering"' not in js
def test_document_js_titles_resolve_the_name() -> None:
"""The viewer page titles (render + not-found) carry the resolved
name — the module sets them itself, so it must use brand() (the
static document.html title is handled by the brand.js title pass)."""
js = _text(DOCUMENT_JS)
assert 'document.title = `${doc.title} · ${brand()}`' in js
assert 'document.title = `Document not found · ${brand()}`' in js
assert 'window.BOR_BRAND || "Brain of Reese"' in js
def test_every_page_loads_brand_js_before_its_module_script() -> None:
"""All pages load brand.js as a CLASSIC script, before the page's
module script (modules execute after parsing — the synchronous
default must be set first)."""
for page in HTML_PAGES:
html = _text(FRONTEND / page)
# Optional leading slash: root-level pages use "assets/brand.js",
# but the shared page is served from the NESTED /shared/<token>
# route, where a relative ref would 404 — it uses "/assets/…".
m = re.search(r'<script src="(?:/)?assets/brand\.js"></script>', html)
assert m, f"{page}: brand.js must load"
brand_idx = m.start()
module_idx = html.find('<script type="module"')
assert module_idx != -1, f"{page}: the page module must load"
assert brand_idx < module_idx, (
f"{page}: brand.js must load BEFORE the module script"
)
assert 'type="module"' not in html[brand_idx : brand_idx + 60], (
f"{page}: brand.js must be a classic script"
)
def test_containerfile_builds_brand_js_like_its_classic_sibling() -> None:
"""The image build minifies brand.js (classic script — minify only,
no --bundle, exactly like markdown.js) so the container serves it."""
cf = _text(CONTAINERFILE)
lines = [ln for ln in cf.splitlines() if "brand.js" in ln]
assert len(lines) == 1, "one esbuild line for brand.js"
line = lines[0]
assert "esbuild ./assets/brand.js" in line
assert "--minify" in line
assert "--bundle" not in line, (
"classic script: minify only (the markdown.js pattern)"
)
assert "--outfile=/out/assets/brand.js" in line
def test_no_cdn_in_the_brand_layer() -> None:
"""AGENTS.md rule 6: the brand layer adds no external reference."""
js = _text(BRAND_JS)
assert "http://" not in js and "https://" not in js
for page in HTML_PAGES:
html = _text(FRONTEND / page)
assert 'src="https://' not in html and 'href="https://' not in html