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:
2026-09-06 06:31:31 -04:00
parent 7e567bddf3
commit ffa919b8bf
78 changed files with 5548 additions and 3244 deletions
+6 -4
View File
@@ -272,13 +272,15 @@ def test_persists_across_page_navigation(
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
# A trip to Sources (phase 16: the catalog is admin-only — the trip
# starts with a real form login). The New chat button is chat-page
# only (owner rework 2026-08-28 — it left the shared bar), so the
# sources page carries none of it (pinned in
# starts with a real form login). Phase 76 (task 02): the shell
# carries the chat view (with its New chat button) in the DOM on
# EVERY view — hidden + inert — so the button EXISTS here but must
# be HIDDEN (the view-scoped absence pattern; it left the shared
# bar at owner request, 2026-08-28 — pinned in
# tests/e2e/test_shared_header.py).
login(page, app_url, next="/sources.html")
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#new-chat-btn")).to_have_count(0)
expect(page.locator("#new-chat-btn")).to_be_hidden()
# Back to the chat: the conversation is exactly as left — both turns,
# the source chip, and the amber deflected bubble with its chips.
+8 -3
View File
@@ -86,7 +86,10 @@ def test_history_tab_shows_auto_save_copy(
# The .page-sub reads the locked (A3) page-sub string
# (whitespace-normalized — the template wraps the line).
page_sub = page.locator(".page-sub")
# Phase 76 (task 03): the shell carries one .page-sub per view
# (the hidden views' copies remain in the DOM — hidden + inert),
# so the pin is scoped to the visible History view.
page_sub = page.locator("#view-history .page-sub")
expect(page_sub).to_have_count(1)
assert re.sub(r"\s+", " ", page_sub.inner_text()).strip() == PAGE_SUB
@@ -98,8 +101,10 @@ def test_history_tab_shows_auto_save_copy(
# The no-button contract: the WHOLE rendered page reads neither
# "press(ed) Save" nor "Save button" — while the <h1> still reads
# "Saved chats" (asserted present, so the scan cannot pass by
# deleting the heading).
h1 = page.locator("h1")
# deleting the heading). Phase 76 (task 03): scoped to the
# History view — the shell carries one <h1> per view (the hidden
# views' headings remain in the DOM, hidden + inert).
h1 = page.locator("#view-history h1")
expect(h1).to_have_count(1)
expect(h1).to_have_text("Saved chats")
body_text = page.locator("body").inner_text()
+5 -3
View File
@@ -127,7 +127,9 @@ def test_sources_table_layout(
login(page, app_url) # phase 16: the catalog is admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible")
wrap = page.locator(".table-wrap")
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap —
# scope to the RAG view.
wrap = page.locator("#view-rag .table-wrap")
expect(wrap).to_be_visible()
expect(wrap).to_have_attribute("role", "region")
expect(wrap).to_have_attribute("tabindex", "0")
@@ -146,7 +148,7 @@ def test_sources_table_layout(
login(mobile, app_url) # phase 16: the catalog is admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
scroll_width, client_width = mobile.evaluate(
"() => { const el = document.querySelector('.table-wrap');"
"() => { const el = document.querySelector('#view-rag .table-wrap');"
" return [el.scrollWidth, el.clientWidth]; }"
)
assert scroll_width > client_width
@@ -163,6 +165,6 @@ def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_re
expect(page.locator("#sources-empty code")).to_have_text(
"uv run python -m scripts.import_docs"
)
expect(page.locator(".table-wrap")).to_be_hidden()
expect(page.locator("#view-rag .table-wrap")).to_be_hidden()
expect(page.locator("#stat-docs")).to_have_text("0")
expect(page.locator("#stat-chunks")).to_have_text("0")
+14 -16
View File
@@ -83,7 +83,7 @@ from pathlib import Path
from typing import Any
import pytest
from playwright.sync_api import Dialog, Page, expect
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.db import SessionLocal
@@ -498,7 +498,10 @@ def test_admin_sync_imports_fixture_prunes_after_delete_removes_row(
f"fixture doc survived the prune: {docs}"
)
# --- remove the row: accept the confirm → it disappears ------------
# --- remove the row: the phase-69 in-app confirmation modal --------
# (window.confirm is retired — the row's Remove button opens the
# #remove-confirm-dialog, which names the source and states the
# full-removal policy; "Remove source" runs the server-side DELETE.)
# Back on the manager page (the sync clicks visited the Sources page).
page.goto(app_url + GIT_SOURCES_URL)
removes: list[str] = []
@@ -508,20 +511,15 @@ def test_admin_sync_imports_fixture_prunes_after_delete_removes_row(
if r.method == "DELETE" and "/api/git-sources/" in r.url
else None,
)
def handle_dialog(dialog: Dialog) -> None:
# "Remove this local source…? Its documents stay indexed until
# the next sync prunes them." — accept it.
dialog.accept()
page.on("dialog", handle_dialog)
try:
row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
row.locator(".git-source-remove").click()
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
expect(page.locator("#git-sources-empty")).to_be_visible()
finally:
page.remove_listener("dialog", handle_dialog)
row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
row.locator(".git-source-remove").click()
dialog = page.locator("#remove-confirm-dialog")
expect(dialog).to_be_visible()
expect(dialog.locator("#remove-confirm-source")).to_contain_text(str(local_dir))
# Confirm: the full cleanup runs server-side; the row disappears.
dialog.locator("#remove-confirm-remove").click()
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
expect(page.locator("#git-sources-empty")).to_be_visible()
assert len(removes) == 1, f"expected one DELETE, saw: {removes}"
# The registry is empty again — and with no env git list, a further
# sync would fail loudly ("no sources configured (git or local)").
+84 -11
View File
@@ -72,6 +72,18 @@ Test → story mapping (Playwright Mapping Rule):
4. ``test_viewer_back_link_honors_back_param``
5. ``test_steering_surface_off_chat_on_tuning_page``
6. ``test_sync_button_present_on_sources_page_without_triggering``
7. ``test_viewer_nav_click_full_loads_the_shell_rag_view`` (phase 76
task 04 — the header is shell-owned: the surviving standalone
documents keep their header copies, and a navbar click on one is a
REAL departure that full-loads the shell, whose router renders the
target view from the pathname)
Phase 76 adaptation (tasks 01–03): chat / sources / tuning are VIEWS of
ONE shell document (index.html) — the header under test on those URLs is
the shell's single one (the router deep-links the view from the
pathname on each real goto). The per-URL inventory comparison is the
pre-phase-76 probe, kept verbatim; test 7 adds the surviving-document
side of the ownership boundary (the viewer's own header copy).
"""
from __future__ import annotations
@@ -243,15 +255,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
assert page.locator("#steering-toggle").count() == 0, (
f"{name}: the steering toggle was removed from the navbar"
)
# The Sync button is a page-specific control (Sources page only
# — owner rework 2026-08-28): visible on sources, absent from
# the shared bar everywhere else.
# The Sync button is a view-specific control (RAG view only —
# owner rework 2026-08-28): visible on sources, not visible
# anywhere else. Phase 76 (task 02): in the shell the RAG view
# (with the button) is in the DOM on every view — hidden +
# inert — so the pin is VISIBLE, not ABSENT (to_be_hidden()
# also passes on standalone pages where the button is absent).
if name == "sources":
expect(page.locator("#sync-btn")).to_be_visible()
else:
assert page.locator("#sync-btn").count() == 0, (
f"{name}: #sync-btn left the shared bar (Sources page only)"
)
expect(page.locator("#sync-btn")).to_be_hidden()
expect(page.locator("#sign-out-btn")).to_be_visible()
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
@@ -277,14 +290,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
assert page.locator("#steering-panel").count() == 0, (
f"{name}: the steering panel must be absent for anonymous"
)
# The New chat button is chat-page only (moved from the shared bar
# to index.html's .chat-shell at owner request, 2026-08-28).
# The New chat button is chat-view only (moved from the shared bar
# to the shell's .chat-shell at owner request, 2026-08-28).
# Phase 76 (task 02): in the shell the chat view (with the button)
# is in the DOM on every view — hidden + inert — so the pin is
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
# pages where the button is absent).
if name == "chat":
expect(page.locator("#new-chat-btn")).to_be_visible()
else:
assert page.locator("#new-chat-btn").count() == 0, (
f"{name}: #new-chat-btn left the shared bar (chat page only)"
)
expect(page.locator("#new-chat-btn")).to_be_hidden()
_assert_landmarks(page, name)
return _header_inventory(page)
@@ -540,3 +555,61 @@ def test_sync_button_present_on_sources_page_without_triggering(
assert btn.get_attribute("aria-busy") is None, "a fresh idle sync must not be busy"
expect(page.locator("#sync-label")).to_have_text("Sync sources")
# Deliberately NOT clicked.
# ---------------------------------------------------------------------------
# 7. Phase 76 task 04: the surviving documents keep their header copies —
# a navbar click on one is a REAL departure that full-loads the shell
# (whose router then renders the target view from the pathname)
# ---------------------------------------------------------------------------
def test_viewer_nav_click_full_loads_the_shell_rag_view(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Phase 76 task 04: the document viewer is one of the four surviving
standalone documents (login, shared, doc-edit, document) — it keeps
its own header copy, and the router does NOT run there (the router
lives in the shell). Clicking its RAG nav link is therefore a REAL,
document-level navigation — not the shell's pushState switch: it
full-loads the shell at /sources.html, and the shell's router renders
the RAG view from the pathname (the Chat view ships hidden + inert
inside that same document).
The window sentinel proves the departure in the phase-76 canonical
form, used in INVERSE: it is set in the viewer document and must be
GONE after the click (a real load wipes window globals — exactly
what distinguishes a departure from the shell's same-document
switches, where the sentinel survives)."""
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# Admin: the viewer's RAG nav link is admin-only (revealed by the
# whoami pass of its own header copy).
login(page, app_url, next=CHAT_URL)
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
page.goto(app_url + VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
_wait_settled(page, admin=True)
expect(page.locator("#nav-sources")).to_be_visible()
# The sentinel lives in the VIEWER document only.
page.evaluate("() => { window.__phase76_viewer = 'phase76'; }")
page.click("#nav-sources")
# The arrival is the SHELL at the RAG view's URL: the document loaded
# for real (the sentinel is gone), the RAG view is rendered (first
# table row visible), the Chat view is hidden AND inert in the same
# document, and the RAG link carries the router's single-writer
# active stamp.
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
assert page.evaluate("() => window.__phase76_viewer") is None, (
"a surviving document's nav click must be a real departure "
"(a fresh document load wipes window globals)"
)
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
assert page.evaluate("() => document.getElementById('view-chat').inert") is True, (
"the chat view ships hidden AND inert in the shell"
)
expect(page.locator("#nav-sources")).to_have_class(re.compile(r"\bis-active\b"))
+10 -3
View File
@@ -112,6 +112,11 @@ CURRENT_LINK: dict[str, str | None] = {
#: The four primary nav links, in their physical DOM order — the labels
#: after the phase-48 swap (ids/hrefs unchanged).
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
#: The FIFTH a.nav-link in every page header (phase 53 saved-chat
#: history — ship-hidden, revealed by header.js for admins). The DOM
#: enumeration below therefore always sees it (pre-existing since phase
#: 53; the list below now matches the real nav).
NAV_TAIL = ("History",)
#: The login.js script — route pattern for the redirect suppression.
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
@@ -191,12 +196,14 @@ def _assert_renamed_labels(page: Page, name: str) -> None:
expect(git).to_have_text("Sources")
expect(git).to_have_attribute("href", "/git-sources.html")
# The four primary nav links (class nav-link) in physical DOM order.
# The nav links (class nav-link) in physical DOM order — the four
# primaries plus the phase-53 admin-only History link.
nav_texts = page.eval_on_selector_all(
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
)
assert nav_texts == list(NAV_LABELS), (
f"{name}: nav link order/labels are {nav_texts}, expected {list(NAV_LABELS)}"
assert nav_texts == [*NAV_LABELS, *NAV_TAIL], (
f"{name}: nav link order/labels are {nav_texts}, expected "
f"{[*NAV_LABELS, *NAV_TAIL]}"
)
# …and the full anchor sequence of the nav (it also carries the
# phase-46 mobile sign-in copy) opens with the same four, in order.
+525
View File
@@ -0,0 +1,525 @@
"""Phase 76 E2E (Playwright): in-app view switches never halt a
generating answer — the owner repro, pinned against the deterministic
mock LLM.
Source: owner repro, verified in a real browser 2026-09-06 — send a
question → click **RAG** in the navbar mid-stream → click **Chat** →
the answer never finished (every navbar view was a separate document,
so the navbar click was a REAL cross-document navigation: the chat
page unloaded, the in-flight fetch was aborted, and the phase-48
teardown cancelled the turn — no ``query_log`` row, a dangling
question on return). Phase 76 folded the five navbar views into ONE
HTML shell: a navbar click is a CLIENT-SIDE view switch
(``history.pushState`` + show/hide), so the in-flight SSE reader in
the hidden chat view keeps streaming and the answer COMPLETES when
the user returns.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov
Timing is deterministic by construction: the mock's ``write a long
answer`` trigger streams a ~5400-char answer at 12 chars / 0.02 s
(~8–9 s of content), so the mid-stream switch window is wide.
The "same document" proof (the canonical pattern from the phase
overview): a ``window`` sentinel set before the nav click is still
readable after the switch — a real document load would wipe ``window``
globals. The ``performance.getEntriesByType("navigation")`` length is
deliberately NOT used: a real load resets that counter to 1 in the
fresh document, so it cannot distinguish pushState from a reload.
Test → story mapping (Playwright Mapping Rule):
1. ``test_rag_switch_mid_stream_completes`` — THE OWNER REPRO: send →
RAG mid-stream → Chat; the FULL answer completes, ``bor.chat.v1``
holds EXACTLY ONE brain turn, the server SETTLED the turn (one
``query_log`` row — no phase-48 ``turn cancelled``), and the
auto-saved row (admin, ``persistConversation``) carries the same
single full turn.
2. ``test_every_nav_view_keeps_stream`` — the same mid-stream switch
against the other three views (Sources/git-sources, Tuning,
History): one send, one switch, one return, full answer + settled
``query_log`` row each time.
3. ``test_real_departure_still_cancels`` — the phase-48 CONTROL (the
locked contract survives the phase): a genuine cross-document
departure (``/shared.html`` — a stable document for a signed-in
session; ``/login.html`` is deliberately avoided because it
auto-redirects a signed-in admin straight back into the shell)
still aborts the fetch, leaves NO ``query_log`` row, and the
page-20/73 leave-save lands the partial in the EXACT shape pinned
by ``tests/e2e/test_sources_midstream_bug.py::test_partial_answer_
survives_real_departure_midstream`` (mirrored, not re-invented).
The overlap with that suite is on purpose — different stories:
phase 20 pins the partial shape, this phase pins that the
navbar-switch path no longer cancels while real departures still
do.
4. ``test_baseline_no_switch_still_completes`` — the long question
with NO navigation completes identically (guards against the
shell fold changing the ordinary path).
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Locator, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES, long_answer
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: The phase-11 on-topic long-answer phrasing (house pattern,
#: ``test_hidden_tab_stream.py`` / ``test_stop_generation.py``): the
#: honesty gate is HIGH and the ~900-word answer streams for ~8–9 s
#: (12 chars / 0.02 s) — the guaranteed mid-stream window.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: The mock's byte-stable long answer — the EXACT string the stream
#: delivers, so "the full answer" is an exact comparison, not a
#: contains check.
LONG_ANSWER = long_answer()
#: The mock's first 12-char content slice (the same cut ``_sse_stream``
#: makes) — the real-departure partial must START with it (raw text,
#: pre-render); the rendered first line keeps the list-item form (the
#: markdown renderer converts the "1. " marker into a list item,
#: pinned by test_long_answers).
FIRST_CHUNK_RAW = re.findall(r".{1,12}", LONG_ANSWER, re.S)[0]
FIRST_LINE_DOM = "Step 1: configure node-1"
STORAGE_KEY = "bor.chat.v1"
#: The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
#: The other three navbar views (test 2): the nav link, the view's
#: URL (pushState target), and an admin-visible content marker inside
#: the view (proof the view actually showed — the RAG view gets the
#: same treatment with ``#docs-tbody tr`` in test 1).
OTHER_VIEWS: tuple[tuple[str, str, str], ...] = (
("#nav-git-sources", "/git-sources.html", "#git-sources-content"),
("#nav-tuning", "/tuning.html", "#tune-save"),
("#nav-history", "/history.html", "#history-table-wrap"),
)
# ---------------------------------------------------------------------------
# KB seeding (house pattern: TRUNCATE-then-import)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int) -> ImportSummary:
"""House reset + the prompt-shaping tables: steering notes and the
KB overview would otherwise append deterministic suffixes to every
mock answer and break the exact-text assertions."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
"""The settled-row count over the whole (truncated) log.
The query log finalizes a row ONLY when the LLM finished AND the
persistence succeeded (phase 48); a cancelled turn — a real
departure mid-stream, or one before the first token — leaves no
settled row, so the count IS the settled-row signal (0 = cancelled,
1 = settled; the house pattern from test_hidden_tab_stream.py).
"""
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
# ---------------------------------------------------------------------------
# Shared flows
# ---------------------------------------------------------------------------
def _stored_parsed(page: Page) -> dict[str, Any]:
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
assert raw is not None, "the conversation key must exist in localStorage"
return json.loads(raw)
def _no_error_banner(page: Page) -> None:
"""The never-stale contract, shell-scoped: the hidden views ship
their own role=alert surfaces (sync/upload banners, …) that are
inert while their view is hidden — so the pin is that NO alert is
VISIBLE, whatever the document carries hidden (the phase-20
rewrite's shell form)."""
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
def _ask_long(page: Page) -> None:
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
def _wait_streaming(page: Page, answer: Locator) -> Locator:
"""Wait until answer text is visibly streaming (a few delta frames
rendered — the mid-stream moment, well inside the ~8–9 s stream)."""
answer.wait_for(state="visible", timeout=30_000)
partial = ""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
partial = answer.inner_text()
if len(partial.split()) >= 8:
break
time.sleep(0.05)
assert len(partial.split()) >= 8, "no answer deltas before the view switch"
# In flight at the switch: the button IS the enabled Stop control.
expect(page.locator("#send-label")).to_have_text("Stop")
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
return answer
def _wait_done(page: Page, answer: Locator) -> str:
"""Wait for the ``done`` settle: the Send button is back and the
bubble carries the unique final line — no error banner on the way."""
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000)
_no_error_banner(page)
return answer.inner_text()
def _assert_full_answer(text: str) -> None:
"""The bubble carries the FULL mock answer — every one of the 40
numbered steps plus the unique final line (a truncated stream
would be missing its tail)."""
for i in range(1, LONG_ANSWER_LINES + 1):
assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer"
assert LONG_ANSWER_END in text
def _assert_one_brain_turn(page: Page) -> dict[str, Any]:
"""``bor.chat.v1`` holds EXACTLY ONE brain turn for the question,
and its text is the COMPLETE mock answer byte-for-byte (the
``done`` settle's record — the settle, not a partial)."""
stored = _stored_parsed(page)
assert stored["v"] == 1
msgs = stored["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"], (
"exactly ONE brain turn for the question: "
f"{[m['who'] for m in msgs]}"
)
assert msgs[0]["text"] == LONG_QUESTION
brain = msgs[1]
assert brain["text"] == LONG_ANSWER, "the record's text is the FULL answer"
assert brain.get("deflected") is False, "the done metadata rides the record"
return brain
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login —
used to call the admin API with plain httpx (the test's API side
sees exactly what the signed-in browser sees)."""
return {c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c}
def _delete_rows_by_title(app_url: str, cookies: dict[str, str], title: str) -> None:
"""Best-effort cleanup of the auto-saved row (a 404 is fine)."""
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
if r.status_code != 200:
return
for c in r.json()["chats"]:
if c["title"] == title:
httpx.delete(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies)
def _wait_row_full(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]:
"""Poll the auto-saved row until it carries the full answer as a
single brain turn (the ``done`` settle's fire-and-forget
``persistConversation`` PUT is the last writer)."""
deadline = time.monotonic() + 15
last: list[dict[str, Any]] = []
while time.monotonic() < deadline:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
rows = (
[c for c in r.json()["chats"] if c["title"] == title]
if r.status_code == 200
else []
)
for c in rows:
row = httpx.get(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies).json()
brains = [m for m in row["messages"] if m["who"] == "brain"]
if len(brains) == 1 and brains[0]["text"] == LONG_ANSWER:
return row
last = [row]
time.sleep(0.2)
raise AssertionError(
"the auto-saved row never held the full answer as exactly one brain turn; last: "
f"{last!r}"
)
# ---------------------------------------------------------------------------
# 1. THE OWNER REPRO: send → RAG mid-stream → Chat — the FULL answer
# completes, one brain turn, one settled query_log row, and the
# auto-saved row carries the same single full turn
# ---------------------------------------------------------------------------
def test_rag_switch_mid_stream_completes(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
# Admin login: the admin-only #nav-sources link is revealed, and
# the auto-save row (the persistConversation path) is reachable,
# so the saved-chat side gets pinned too.
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
cookies = _admin_cookies(page)
_delete_rows_by_title(app_url, cookies, LONG_QUESTION) # stale rows from crashed runs
_ask_long(page)
answer = _wait_streaming(page, page.locator(ANSWER))
# THE SWITCH (mid-stream): the window sentinel set BEFORE the click
# is still readable AFTER it — the canonical same-document proof
# (a real navigation would have wiped window globals).
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76", (
"a real navigation would have wiped the window sentinel — "
"the switch must be same-document"
)
# The RAG view actually showed (the fixture docs' rows are listed)
# and the chat view is hidden (the stream fills it in the
# background — that persistence IS the fix).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
# Stay on the RAG view while the stream keeps running (the switch
# is ~t+2 s; the full answer needs ~8–9 s).
page.wait_for_timeout(2000)
# Back to the chat (the header link — a router-intercepted
# switch, still same-document).
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
assert page.evaluate("() => window.__shell_boot") == "phase76"
# The answer COMPLETED — the bubble carries the FULL mock answer
# (every step line + the unique final line), no error banner.
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
# Storage: EXACTLY ONE brain turn — the FULL answer with done
# metadata (the settle, not a partial).
page.wait_for_timeout(500)
_assert_one_brain_turn(page)
# Server side: the turn SETTLED — exactly one query_log row, so no
# phase-48 "turn cancelled" teardown fired for an in-app switch.
assert _query_log_count() == 1, "a completed turn must finalize its query_log row"
# Auto-save (admin): the row carries the same single full brain
# turn (the shared record shape).
try:
row = _wait_row_full(app_url, cookies, LONG_QUESTION)
brains = [m for m in row["messages"] if m["who"] == "brain"]
assert len(brains) == 1, "the saved row holds exactly one brain turn"
assert brains[0]["text"] == LONG_ANSWER
finally:
_delete_rows_by_title(app_url, cookies, LONG_QUESTION)
# ---------------------------------------------------------------------------
# 2. The same mid-stream switch against the other three views — one
# send, one switch, one return, full answer + settled row each time
# ---------------------------------------------------------------------------
def test_every_nav_view_keeps_stream(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
# Admin: every navbar link (incl. the three below) is revealed by
# the whoami gate.
login(page, app_url, next="/")
for i, (nav_sel, view_path, marker) in enumerate(OTHER_VIEWS, start=1):
expect(page.locator(nav_sel)).to_be_visible()
_ask_long(page)
# The CURRENT turn's bubble (the conversation accumulates one
# full turn per iteration — the latest pair is the pin).
answer = _wait_streaming(page, page.locator(ANSWER).last)
# Mid-stream switch to this view — same-document (sentinel).
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click(nav_sel)
expect(page).to_have_url(app_url + view_path)
assert page.evaluate("() => window.__shell_boot") == "phase76", (
f"a real navigation to {view_path} would have wiped the sentinel"
)
# The view actually showed (its admin content is up) and the
# chat view is hidden (the stream fills it in the background).
expect(page.locator(marker)).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
# Let the stream run while this view is up, then return to the
# chat (still same-document).
page.wait_for_timeout(2000)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
assert page.evaluate("() => window.__shell_boot") == "phase76"
# The answer COMPLETED — FULL mock answer, no error banner.
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
# Every turn SETTLED: exactly one query_log row per completed
# turn so far (a cancelled turn would leave no row).
page.wait_for_timeout(500)
assert _query_log_count() == i, (
f"turn {i} must finalize exactly one settled query_log row"
)
# Storage: the latest pair is the question + ONE brain turn
# carrying the FULL answer (each turn appended, none
# cancelled, none truncated).
msgs = _stored_parsed(page)["messages"]
assert msgs[-2] == {"who": "user", "text": LONG_QUESTION}
assert msgs[-1]["who"] == "brain"
assert msgs[-1]["text"] == LONG_ANSWER
# ---------------------------------------------------------------------------
# 3. The phase-48 CONTROL: a REAL cross-document departure still
# cancels the fetch (the locked contract survives the phase) — and
# the page-20/73 partial persist lands in the exact phase-20 shape
# ---------------------------------------------------------------------------
def test_real_departure_still_cancels(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_ask_long(page)
_wait_streaming(page, page.locator(ANSWER))
# THE DEPARTURE: a REAL cross-document navigation (NOT a navbar
# link — those are view switches now). The fetch is aborted by the
# unload, which is the point (phase 48). /shared.html is a plain
# document with a stable state for a signed-in session — unlike
# /login.html, which auto-redirects a signed-in admin straight
# back into the shell.
page.goto(app_url + "/shared.html")
expect(page).to_have_url(app_url + "/shared.html")
expect(page.locator("#shared-title")).to_be_visible(timeout=15_000)
# The turn was CANCELLED — the phase-48 query_log row only lands
# when the LLM finished AND the persistence succeeded, so a
# cancelled mid-stream turn must leave NO settled row.
assert _query_log_count() == 0, (
"a cancelled mid-stream turn must not finalize a query_log row"
)
# Return to the chat — the page-20/73 leave-save is intact: the
# question AND the already-streamed partial are both rendered.
page.goto(app_url + "/")
expect(page.locator("#empty-state")).to_be_hidden()
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble").first).to_contain_text(LONG_QUESTION)
restored = page.locator(".msg.brain .bubble")
expect(restored).to_have_count(1)
expect(restored.first).to_contain_text(FIRST_LINE_DOM)
_no_error_banner(page)
# The EXACT phase-20 partial shape (mirrored from
# test_sources_midstream_bug.py::test_partial_answer_survives_real_
# departure_midstream — do not invent a new shape): exactly one
# brain turn, raw text STARTING with the first streamed chunk,
# SHORTER than the full answer, and NO done metadata (no
# sources/deflected/suggestions/thinking — the turn never
# settled when it was written).
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
assert msgs[0]["text"] == LONG_QUESTION
brain = msgs[1]
assert brain["text"].startswith(FIRST_CHUNK_RAW)
assert len(brain["text"]) < len(LONG_ANSWER), "the stored answer must be partial"
assert brain["text"] != LONG_ANSWER
assert "sources" not in brain
assert "deflected" not in brain
assert "suggestions" not in brain
assert "thinking" not in brain
# ---------------------------------------------------------------------------
# 4. Baseline: the long question with NO navigation completes
# identically (guards against the shell fold changing the ordinary
# path)
# ---------------------------------------------------------------------------
def test_baseline_no_switch_still_completes(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The normal path, untouched by the shell: the long answer
completes identically without any view switch (guards against an
over-eager router/view change altering the ordinary settle)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
page.goto(app_url)
_ask_long(page)
answer = _wait_streaming(page, page.locator(ANSWER))
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
_assert_one_brain_turn(page)
assert _query_log_count() == 1
+4 -2
View File
@@ -277,7 +277,9 @@ def test_sources_table_full_width(
try:
login(page, app_url, next="/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
wrap_box = page.locator(".table-wrap").bounding_box()
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap
# (the git-sources one ships hidden) — scope to the RAG view.
wrap_box = page.locator("#view-rag .table-wrap").bounding_box()
shell_box = page.locator(".sources-shell").bounding_box()
assert wrap_box is not None and shell_box is not None
assert wrap_box["width"] >= 0.80 * shell_box["width"], (
@@ -292,7 +294,7 @@ def test_sources_table_full_width(
login(mobile, app_url, next="/sources.html") # phase 16: admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
scroll, client = mobile.evaluate(
"() => { const el = document.querySelector('.table-wrap');"
"() => { const el = document.querySelector('#view-rag .table-wrap');"
" return [el.scrollWidth, el.clientWidth]; }"
)
assert scroll > client, (
+13 -8
View File
@@ -157,14 +157,16 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa
)
expect(page.locator("#sign-out-btn")).to_be_hidden()
# The New chat button is chat-page only (moved from the shared bar
# to index.html's .chat-shell at owner request, 2026-08-28).
# The New chat button is chat-view only (moved from the shared bar
# to the shell's .chat-shell at owner request, 2026-08-28).
# Phase 76 (task 02): in the shell the chat view (with the button)
# is in the DOM on every view — hidden + inert — so the pin is
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
# pages like the viewer, where the button does not exist at all).
if page_kind == "chat":
expect(page.locator("#new-chat-btn")).to_be_visible()
else:
assert page.locator("#new-chat-btn").count() == 0, (
f"{page_kind}: the New chat button is chat-page only"
)
expect(page.locator("#new-chat-btn")).to_be_hidden()
# Phase 34: EVERY page kind — viewer included — carries the SAME
# nav contract: the Chat link always visible; the admin-only
@@ -311,12 +313,15 @@ def test_new_chat_button_is_chat_page_only(
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# No non-chat page carries the button anymore…
# No non-chat view shows the button (phase 76 task 02: in the shell
# it EXISTS in the DOM on every view — hidden + inert — so the pin
# is visibility, not existence; standalone pages carry none at
# all, and to_be_hidden() passes for both) …
for path in (SOURCES_URL, VIEWER_URL, "/tuning.html", "/login.html", "/git-sources.html"):
page.goto(app_url + path)
expect(page.locator("#new-chat-btn")).to_have_count(0)
expect(page.locator("#new-chat-btn")).to_be_hidden()
# …and the chat page has exactly one (visible, inside .chat-shell).
# …and the chat view has exactly one (visible, inside .chat-shell).
page.goto(app_url + "/")
expect(page.locator("#new-chat-btn")).to_have_count(1)
expect(page.locator("#new-chat-btn")).to_be_visible()
+223 -44
View File
@@ -1,36 +1,45 @@
"""Phase 20 E2E (Playwright): navigating away mid-turn keeps the answer.
"""Story: mid-stream navigation — the phase-76 SPA shell fix (phase 20
story, re-purposed by phase 76 task 02).
Story: ``.agents/user_stories/sources-midstream.md``
Bug report (TODO.md L3): *"Clicking "sources" while chat is generating
clears chat and result will never show up."*
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov
The bug: the brain message persisted only on ``done``, so leaving the
chat page while a turn was in flight aborted the stream and dropped
whatever had already streamed — the user came back to their own question
with no result, ever. The fix (phase 20, owner-confirmed A1): a single
``pagehide`` save point in app.js persists the partial raw answer (via
the existing ``rememberBrainTurn`` helper) when navigation hits a turn
that is in flight and has already streamed text.
Phase 20 (bug 24) pinned a REAL departure from the chat mid-answer: a
full page navigation (the "Sources" navbar link → /sources.html) aborted
the stream via the unload, and a single ``pagehide`` save point in
app.js persisted the partial raw answer (``rememberBrainTurn``) so the
user came back to their question WITH the partial, rendered as
"Partial answer — navigation interrupted the stream."
Phase 76 (task 02) folded /sources.html (and /git-sources.html) into
the ONE-document shell: from this phase on, a navbar click is a
CLIENT-SIDE view switch — the document (and its in-flight SSE reader)
survive, so the pinned behavior is "the stream survives and the answer
COMPLETES." The phase-20 pagehide partial-persist REMAINS for REAL
departures only (a cross-document navigation still aborts the fetch),
and its coverage home is scenario 1 below in its renamed form.
Timing is deterministic by construction:
* scenario 1 keys off the mock's ``write a long answer`` trigger — a
~5400-char / ~450-frame / ~9s content stream, so the navigation lands
~5400-char / ~450-frame / ~9s content stream, so the departure lands
mid-stream with a wide margin;
* scenario 2 keys off the mock's ``think out loud then hesitate``
trigger — the phase-17 thinking stream followed by a 4s silence before
the first content frame, so the navigation lands inside pure thinking;
* scenarios 3 and 4 settle the turn fully (send button re-enabled)
before any navigation.
* scenarios 2–3 key off the same long stream (mid-stream view switch)
and the ``think out loud then hesitate`` trigger — the phase-17
thinking stream followed by a 4s silence before the first content
frame, so the switch lands inside pure thinking;
* scenario 4 (the pre-token real-departure pin) uses the same
hesitate trigger; scenarios 5 and 6 settle the turn fully (send
button re-enabled) before any navigation.
Test → story mapping (Playwright Mapping Rule):
1. ``test_partial_answer_survives_sources_nav_midstream``
2. ``test_no_orphan_brain_message_when_navigated_before_first_token``
3. ``test_completed_turn_unaffected``
4. ``test_new_chat_still_clears_conversation``
1. ``test_partial_answer_survives_real_departure_midstream``
2. ``test_full_answer_completes_after_rag_nav_midstream``
3. ``test_nav_switch_before_first_token_completes``
4. ``test_no_orphan_brain_message_when_navigated_before_first_token``
5. ``test_completed_turn_unaffected``
6. ``test_new_chat_still_clears_conversation``
"""
from __future__ import annotations
@@ -51,7 +60,7 @@ from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.mock_llm import long_answer
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES, long_answer
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -71,7 +80,7 @@ FIRST_CHUNK_RAW = re.findall(r".{1,12}", FULL_LONG, re.S)[0]
#: dropping the marker (pinned by test_long_answers).
FIRST_LINE_DOM = "Step 1: configure node-1"
# --- scenario 2: navigation during pure thinking (no answer tokens) -----
# --- scenario 3: navigation during pure thinking (no answer tokens) -----
HESITATE_QUESTION = (
"think out loud then hesitate — how is my kubernetes cluster set up?"
)
@@ -123,6 +132,20 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
"""The settled-row count over the whole (truncated) log.
The query log finalizes a row ONLY when the LLM finished AND the
persistence succeeded (phase 48); a cancelled turn — a real
departure mid-stream, or one before the first token — leaves no
settled row, so the count IS the settled-row signal (0 = cancelled,
1 = settled; the house pattern from tests/e2e/test_hidden_tab_
stream.py).
"""
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
def _stored(page: Page) -> str | None:
"""Raw localStorage payload for the chat (None when the key is absent)."""
return page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
@@ -148,8 +171,12 @@ def _ask(page: Page, question: str) -> None:
def _no_error_banner(page: Page) -> None:
"""The never-stale contract: a restored/partial state must never
present an error banner (role=alert) — the turn is simply partial."""
expect(page.locator('[role="alert"]')).to_have_count(0)
present an error banner (role=alert) — the turn is simply partial.
Phase 76 (task 02): the shell's hidden views carry their own
ship-hidden role=alert surfaces (sync banner, upload banner, …),
so the pin is VIEW-SCOPED IN EFFECT — NO alert may be VISIBLE,
whatever the document carries hidden."""
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
@pytest.fixture()
@@ -164,17 +191,17 @@ def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
# ---------------------------------------------------------------------------
# 1. Mid-stream navigation via the Sources nav link: the partial answer
# that had already streamed is persisted and restored
# 1. REAL departure mid-stream (pagehide partial persist — the phase-20
# contract, now exercised via a genuine cross-document navigation;
# a navbar click is no longer a departure — that is scenario 2)
# ---------------------------------------------------------------------------
def test_partial_answer_survives_sources_nav_midstream(
def test_partial_answer_survives_real_departure_midstream(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
# Admin (phase 16/19): only the admin sees the #nav-sources link the
# bug report clicks.
# Admin (phase 16/19): only the admin sees the #nav-sources link.
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
@@ -193,13 +220,22 @@ def test_partial_answer_survives_sources_nav_midstream(
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# THE BUG REPORT, VERBATIM: click "Sources" while chat is generating.
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
# The navigation really landed on the admin catalog (mid-stream state
# of the stream itself does not matter to the page — the fetch is
# aborted by the unload, which is the point).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# THE DEPARTURE, in its phase-76 form: a REAL cross-document
# navigation — page.goto to a genuine other document. /shared.html
# is a plain document (stable for every session state) and — unlike
# /login.html, which auto-redirects a signed-in session straight
# back into the shell — it is a real departure, so the in-flight SSE
# fetch is aborted by the unload (the point).
page.goto(app_url + "/shared.html")
expect(page).to_have_url(app_url + "/shared.html")
expect(page.locator("#shared-title")).to_be_visible(timeout=15_000)
# The turn was CANCELLED — the phase-48 query_log row only lands
# when the LLM finished AND the persistence succeeded, so a
# cancelled mid-stream turn must leave NO settled row.
assert _query_log_count() == 0, (
"a cancelled mid-stream turn must not finalize a query_log row"
)
# Return to the chat.
page.goto(app_url + "/")
@@ -238,8 +274,149 @@ def test_partial_answer_survives_sources_nav_midstream(
# ---------------------------------------------------------------------------
# 2. Navigation BEFORE the first answer token (pure thinking): nothing
# brain-side is persisted — the question comes back alone
# 2. Navbar click to RAG mid-stream = a client-side VIEW SWITCH (phase 76,
# task 02): the in-flight stream keeps running while the RAG view
# shows, and the answer COMPLETES — the phase-20 "answer cut short"
# outcome is impossible now (the fetch was never cancelled)
# ---------------------------------------------------------------------------
def test_full_answer_completes_after_rag_nav_midstream(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
# Start the ~9s long answer and wait until visible streaming (the
# house pattern: first line rendered + the enabled Stop control).
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(FIRST_LINE_DOM, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Same-document proof: a window sentinel set before the click is
# still readable after — no load happened (the navigation-entries
# length is NOT used: it resets on a real load).
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76"
# The RAG view actually mounted (the fixture docs' rows are listed).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# Stay on the RAG view while the stream keeps running in the
# background (the switch is ~t+2s; the full answer needs ~9s).
page.wait_for_timeout(2000)
# Back to the chat (the header link — a router-intercepted switch).
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
# The answer COMPLETED — the final sentinel line is in the bubble
# (not a partial), no error banner.
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(LONG_ANSWER_END, timeout=30_000)
text_now = bubble.inner_text()
for i in range(1, LONG_ANSWER_LINES + 1):
assert f"Step {i}: configure node-{i}" in text_now
_no_error_banner(page)
# Settle, then storage: EXACTLY ONE brain turn — the FULL answer,
# with done metadata (the settle, not a partial).
page.wait_for_timeout(500)
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
brain = msgs[1]
assert brain["text"] == FULL_LONG
assert brain["deflected"] is False
assert any(s["path"] == "homelab/kubernetes.md" for s in brain["sources"])
# The turn SETTLED — the phase-48 query_log row exists (a
# cancelled turn would leave no row at all).
assert _query_log_count() == 1, "a completed turn must finalize its query_log row"
# ---------------------------------------------------------------------------
# 3. Navbar switch in the pre-first-token window (pure thinking — no
# content frame yet): the surviving reader completes the answer, and
# bor.chat.v1 holds exactly ONE brain turn (the no-orphan invariant
# in its new form — a pre-token view switch neither kills the turn
# nor persists a partial)
# ---------------------------------------------------------------------------
def test_nav_switch_before_first_token_completes(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
page.fill("#message-input", HESITATE_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
# The pre-first-token window, pinned the same way as scenario 4:
# the scratchpad's tail is rendered (the thinking stream has just
# ended) and the 4s pre-content pause (SLOW_PRETOKEN_TRIGGER) is
# running — NO content frame has landed yet. (The .msg.brain bubble
# element exists from turn start with its thinking block — the
# pre-token state is "no content text", not "no bubble element".)
thinking = page.locator(".msg.brain").last.locator("details.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text")).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48
# — the old disabled "Thinking…" busy state is gone).
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Switch to the RAG view NOW — mid-pause, still before the first
# content token.
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76"
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# Let the 4s pre-token pause elapse WHILE the RAG view is up — the
# first content frames land while the chat view is still hidden —
# then return to the chat: the surviving reader completes the
# answer.
page.wait_for_timeout(4500)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
# The answer COMPLETED (full text — the deterministic mock answer),
# no error banner.
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
_no_error_banner(page)
# Storage: EXACTLY ONE brain turn — the completed answer with done
# metadata (no partial, no orphan, no duplicate).
page.wait_for_timeout(500)
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
assert MOCK_ANSWER_MARKER in msgs[1]["text"]
assert msgs[1]["deflected"] is False
assert any(s["path"] == "homelab/kubernetes.md" for s in msgs[1]["sources"])
# The turn settled — one finalized row (a cancelled turn would
# leave no row at all).
assert _query_log_count() == 1
# ---------------------------------------------------------------------------
# 4. REAL departure BEFORE the first answer token (pure thinking): nothing
# brain-side is persisted — the pre-token no-orphan convention,
# unchanged (a direct page.goto to /sources.html REMAINS a real
# departure in the SPA — the shell is served, the fetch is aborted
# by the unload)
# ---------------------------------------------------------------------------
@@ -270,8 +447,9 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Leave during the pause (no answer token has streamed — acc is empty,
# so the pagehide save point must persist nothing brain-side).
# Leave during the pause via a REAL cross-document departure (no
# answer token has streamed — acc is empty, so the pagehide save
# point must persist nothing brain-side).
page.goto(app_url + "/sources.html")
# Return to the chat.
@@ -294,8 +472,9 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
# ---------------------------------------------------------------------------
# 3. Completed turn: the done save point is byte-identical to before —
# the new pagehide save point must not duplicate or alter it
# 5. Completed turn: the done save point is byte-identical to before —
# the new pagehide save point must not duplicate or alter it (the
# direct gotos are real departures — unaffected by the fold)
# ---------------------------------------------------------------------------
@@ -337,7 +516,7 @@ def test_completed_turn_unaffected(
# ---------------------------------------------------------------------------
# 4. The DELIBERATE clear is untouched: New chat (chat-page only since
# 6. The DELIBERATE clear is untouched: New chat (chat-page only since
# the owner rework 2026-08-28) still clears the conversation
# (phase 14 contract)
# ---------------------------------------------------------------------------
+3 -1
View File
@@ -94,7 +94,9 @@ def test_sources_page_sub_describes_the_current_source_model(
model. The page is anonymously viewable — the catalog gate hides
the table, not the page-head."""
page.goto(f"{app_url}/sources.html")
sub = page.locator(".page-sub").first
# Phase 76 (task 02): the shell carries the hidden tuning view's
# .page-sub earlier in the DOM — scope to the RAG view.
sub = page.locator("#view-rag .page-sub")
expect(sub).to_be_visible()
text = sub.inner_text().lower()
assert "homelab" not in text, f"retired copy in the page-sub: {text!r}"
+6 -4
View File
@@ -46,10 +46,12 @@ QUESTION = "How is my Kubernetes cluster set up?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
NOTE = "STEEER-MARKER be concise"
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
#: Both index.html and tuning.html ship exactly three classic/module
#: script tags: the phase-39 brand.js classic layer + markdown.js + the
#: page module (app.js / tuning.js).
BASE_SCRIPT_COUNT = 3
#: The shell (index.html — served for BOTH / and /tuning.html since
#: phase 76 task 01, when the Tuning view folded into it) ships exactly
#: FOUR classic/module script tags: the phase-39 brand.js classic layer
#: + markdown.js + the chat module (app.js) + the shell router
#: (router.js, which lazy-imports the tuning.js view module).
BASE_SCRIPT_COUNT = 4
async def _import_fixtures(mock_port: int) -> ImportSummary:
+61 -5
View File
@@ -150,12 +150,23 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
("path", "marker"),
[
("/", "Brain of Reese"),
("/sources.html", "Knowledge base"),
# Phase 76 (task 02): /sources.html + /git-sources.html are SHELL
# routes — the body is the shell (index.html) whose static
# <title> is "Brain of Reese" (the per-view title is set
# CLIENT-side by the router, invisible to httpx). The marker
# asserts the shell body (the view section is inside it) instead
# of the old page's title.
("/sources.html", 'id="view-rag"'), # phase 76: shell route (was "Knowledge base")
("/document.html", "Brain of Reese"), # phase 10: viewer page
("/login.html", "Sign in"), # phase 16: admin sign-in page
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
("/history.html", "Saved chats"), # phase 50: admin saved-chats page
# Phase 76 (task 01): /tuning.html is a SHELL route — same
# shell-body marker pattern as the two task-02 paths above.
("/tuning.html", 'id="view-tuning"'), # phase 76: shell route (was "Global Tuning")
("/git-sources.html", 'id="view-git-sources"'), # phase 76: shell route (was "Git sources")
# Phase 76 (task 03): /history.html is a SHELL route too — the
# shell-body marker (the History view section is inside the
# shell; the old standalone page's title is client-side now).
("/history.html", 'id="view-history"'), # phase 76: shell route (was "Saved chats")
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
],
)
@@ -194,7 +205,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
@pytest.mark.parametrize(
"path",
["/sources.html", "/document.html", "/login.html", "/tuning.html",
"/git-sources.html", "/history.html", # phase 50: + the History page
"/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03)
"/shared.html"], # phase 51: + the anonymous shared page
)
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
@@ -208,6 +219,51 @@ def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
assert f"?v={asset_version()}" in r.text
@pytest.mark.parametrize(
("path", "view_id", "old_title"),
[
("/tuning.html", 'id="view-tuning"', "Global Tuning · Brain of Reese"),
("/sources.html", 'id="view-rag"', "Sources · Brain of Reese"),
("/git-sources.html", 'id="view-git-sources"', "Git sources · Brain of Reese"),
("/history.html", 'id="view-history"', "Saved chats · Brain of Reese"), # phase 76 task 03
],
)
def test_shell_routes_serve_the_shell_no_cache_versioned(
client, path: str, view_id: str, old_title: str
) -> None:
"""Phase 76 (task 01: /tuning.html; task 02: /sources.html +
/git-sources.html; task 03: /history.html — all four non-chat
navbar views): every shell route serves the shell
(``frontend/index.html``), so the phase-33 page contract applies to
it exactly as to a static page: 200, text/html, ``Cache-Control:
no-cache``, ``?v=<token>`` asset refs, no validators (the
middleware wraps the whole app and lists the path in ``HTML_PAGES``
— unchanged). The body IS the shell: the view section is inside it,
the old standalone page's title is gone. The static catch-all stays
intact: an unknown path still 404s. (The conditional-GET
revalidation contract for these paths is pinned by
``tests/integration/test_caching_revalidation.py``.)"""
from app.core.caching import asset_version
r = client.get(path)
assert r.status_code == 200
assert r.headers["content-type"].split(";", 1)[0] == "text/html"
assert r.headers["cache-control"] == "no-cache"
assert "etag" not in r.headers
assert "last-modified" not in r.headers
assert f"?v={asset_version()}" in r.text
# The body is the SHELL: the chat view AND the route's view section
# are inside it…
assert 'id="view-chat"' in r.text
assert view_id in r.text
# …with the shell's static title (the per-view title is the
# router's client-side job), and the old page's own <title> is gone.
assert "<title>Brain of Reese</title>" in r.text
assert f"{old_title}</title>" not in r.text
# The catch-all is intact: an unknown path still 404s.
assert client.get("/nonexistent.html").status_code == 404
def test_index_html_variant_no_cache_versioned(client) -> None:
"""/index.html is the same page as / — same caching treatment."""
from app.core.caching import asset_version
+23 -2
View File
@@ -68,9 +68,30 @@ def file_validators(page_file: Path) -> tuple[str, str]:
return headers["etag"], headers["last-modified"]
#: Phase 76 (task 01): the shell-served view paths — the URL is a
#: navbar view, the file on disk is the SHELL (the shell route in
#: app/main.py serves frontend/index.html for it). The etag
#: computation below must use the file that actually backs the
#: response, or the conditional-GET probe would carry a validator no
#: browser ever saw. Tasks 02/03 extended this as the remaining views
#: folded in (task 03 — History — completes the set: all four
#: non-chat navbar views). The page CONTRACT itself is unchanged: the
#: phase-33 middleware wraps the whole app and lists the path in
#: HTML_PAGES, so the shell-route response is normalized exactly like
#: a static page (200, no-cache, ?v=, no validators — asserted by
#: _assert_page_contract below).
SHELL_BACKED_PAGES = {
"/tuning.html": "index.html", # phase 76 task 01
"/sources.html": "index.html", # phase 76 task 02
"/git-sources.html": "index.html", # phase 76 task 02
"/history.html": "index.html", # phase 76 task 03
}
def _page_file(path: str) -> Path:
"""The static file backing a page path (``/`` → ``index.html``)."""
name = path.lstrip("/") or "index.html"
"""The static file backing a page path (``/`` → ``index.html``;
the shell-served view paths → the shell, ``SHELL_BACKED_PAGES``)."""
name = SHELL_BACKED_PAGES.get(path, path.lstrip("/") or "index.html")
file = FRONTEND / name
assert file.is_file(), f"missing page file for {path}: {file}"
return file
@@ -242,6 +242,18 @@ def test_header_module_is_imported_not_directly_loaded() -> None:
js_path = ASSETS / name
assert js_path.is_file(), f"page script missing: {js_path}"
body = js_path.read_text(encoding="utf-8")
# Phase 76: the shell's router (router.js) is NOT a view module
# — the shell's shared header boots via the chat module (app.js,
# which imports header.js), and the view modules the router
# lazy-imports (tuning.js, …) import header.js themselves, so
# the single-evaluation contract holds without the router
# importing it (it owns the view switch, not the header).
if name == "router.js":
assert 'from "./header.js"' not in body, (
"router.js must not import the header module — the chat "
"module boots the shell's ONE header"
)
continue
assert re.search(r"""from\s+["']\./header\.js["']""", body), (
f"{name}: must import the shared header module relatively "
f'("from \\"./header.js\\"")'
+25 -7
View File
@@ -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"
)
+45 -39
View File
@@ -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"
)
+6 -4
View File
@@ -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)
)
+354
View File
@@ -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
+64 -32
View File
@@ -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
+7 -9
View File
@@ -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
+50 -18
View File
@@ -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
View File
@@ -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
+13 -9
View File
@@ -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)"
+17 -9
View File
@@ -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"
)
+171 -56
View File
@@ -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, (
+37 -20
View File
@@ -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)
+13 -10
View File
@@ -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)."""
+52 -27
View File
@@ -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"