diff --git a/.agents/phases/complete/76_spa_nav_shell/00_phase.md b/.agents/phases/complete/76_spa_nav_shell/00_phase.md new file mode 100644 index 0000000..c03d383 --- /dev/null +++ b/.agents/phases/complete/76_spa_nav_shell/00_phase.md @@ -0,0 +1,50 @@ +# Phase 76 — In-app view switches never halt a generating answer (the navbar views become one document) + +**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 finishes (turn cancelled: 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). Root cause: every navbar view is a separate HTML document (`RAG` = ``), so a navbar click is a REAL cross-document navigation — the chat page unloads, the in-flight `fetch` is aborted, and the phase-48 teardown (`app/api/chat.py` `finally`, `chat: turn cancelled`) stops the model. +**Story:** n/a (TODO-derived — descendant of `.agents/user_stories/sources-midstream.md` (phase 20) and `TODO.md` L3 (phase 73)) +**Context:** `frontend/index.html` (chat page; the shared `
` markup is copy-pasted into every page), `frontend/assets/app.js` (the chat logic — owns the in-flight SSE stream that must SURVIVE view switches; phase-20/48/73 machinery must stay intact and its source-level unit pins in `tests/unit/test_frontend_*.py` must keep passing), `frontend/assets/{sources,git-sources,tuning,history}.js` (page modules that boot at document load and call `initSharedHeader()`), `frontend/assets/header.js` (shared header: auth-gated links, active state, mobile hamburger), `app/main.py` (explicit routes + catch-all `StaticFiles` mount, API routes first), `app/core/caching.py` (`HTML_PAGES` list — the view PATHS do not change, so the no-cache + `?v=` rewrite contract applies to the shell routes untouched), `tests/e2e/test_sources_midstream_bug.py` (phase-20 suite that encodes the OLD "partial survives the navigation" semantics — rewritten in task 02), `tests/e2e/test_hidden_tab_stream.py` (phase 73 — must stay green unchanged), `tests/e2e/test_stop_generation.py` (phase 48 — must stay green unchanged). + +## Objective +Collapse the five navbar views (Chat, RAG, Sources, Tuning, History) into views of ONE HTML shell so a navbar click is a client-side view switch (`history.pushState` + show/hide), never a document load. An in-flight answer keeps streaming through any navbar switch and completes when the user returns to Chat — the owner repro (send → RAG → Chat) must finish the FULL answer, settled server-side (one `query_log` row), with exactly one brain turn persisted. Real departures (tab close, leaving the app, the Stop button) still cancel the fetch and stop the model — the phase-48 contract, intact. + +## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3) +- **Option A** (SPA-ify the navbar views) chosen over B (Service Worker owns the stream) and C (server-side turn registry + resume). +- **LOCKED-DECISION REFINEMENT (phase 48):** "real navigation cancels the fetch" now means **leaving the app** (tab close, external/other-document navigation, Stop). In-app navbar switches no longer cancel. Owner-confirmed refinement — flagged, not silently deviated. +- **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. +- **No new technology:** vanilla JS modules, no framework, no bundler — consistent with the existing architecture and the No-CDN rule (AGENTS.md #6). + +## Design (shared by all tasks — the executor reads this, not the chat) +- **Shell:** `frontend/index.html` becomes the shell. The shared header stays at the top of the shell (it is copy-pasted into every page today; in the shell it exists ONCE, so the header/nav/auth/steering ids — `#app-nav`, `#nav-*`, `#sign-in-link*`, `#sign-out-btn*`, `#steering-*`, `#nav-toggle` — deduplicate automatically). ONE `
` container (skip-link target unchanged) holds the five `
` blocks; each folded page's own `
` wrapper (all five pages carry the identical one — layout CSS is class-based, so nothing is lost) is DROPPED when its content moves. Only the active view is rendered: hidden views carry BOTH `hidden` AND `inert` (WCAG — hidden views must not receive focus or keyboard traversal; AGENTS.md #5). ONE shell footer (the chat page's `#app-version` footer): per-page footers from folded views are dropped (`app-version` otherwise duplicates — it exists in both `index.html` and `history.html`). +- **Duplicate-id resolution (audited 2026-09-06, whole-file id scan of the five pages, comments stripped):** the only cross-page id collisions in VIEW markup are (a) the per-view `
` — resolved by the single-main container above, and (b) the `doc-modal-*` family (9 ids: `doc-modal`, `-backdrop`, `-close`, `-content`, `-desc`, `-meta`, `-open`, `-panel`, `-title`), present in BOTH `index.html` and `sources.html` — the shell keeps EXACTLY ONE instance (the chat's, already in `index.html`), placed at body level (the modal is a `position: fixed` overlay; its DOM position is cosmetic). `document-modal.js` resolves all of them with document-level `querySelector` at MODULE IMPORT (its lines 44–52), so the single skeleton must be static markup present before any import — it is. `app.js` (chat chips) and `sources.js` (RAG rows) both call `openDocumentModal(...)` and work UNCHANGED against the one shared instance. The moved sources view markup DROPS its skeleton copy (task 02). +- **Same-document proof (canonical E2E assertion for "no document load"):** set a `window` sentinel before the nav click (`window.__shell_boot = "phase76"`) and assert it is still readable after the switch + return — a real navigation wipes `window` globals. Do NOT use `performance.getEntriesByType("navigation").length` — a real load resets that counter to 1 in the fresh document, so it cannot distinguish pushState from a reload. +- **View-scoped E2E absence pattern:** in the shell, hidden views' elements REMAIN IN THE DOM (hidden+inert). Any E2E absence assertion (`to_have_count(0)`) on a view-scoped id must be re-scoped to the visible view (e.g. `#view-rag #new-chat-btn`) or switched to a visibility assertion (`to_be_hidden`). Known instance: `tests/e2e/test_chat_persistence.py::test_persists_across_page_navigation` asserts `#new-chat-btn` count 0 on the Sources page (task 02 fixes it). +- **Router (new `frontend/assets/router.js`, module, ~200 lines):** a `VIEW` map of `pathname → view name` (`"/"` → chat, plus one entry per folded view); on boot it reads `location.pathname`, lazy-`import()`s the view module on FIRST show only, calls `await module.mount(root)`, then shows it. **Mount-once, hide-forever:** a view's DOM (and JS state — for chat, the in-flight SSE reader) persists across switches; that persistence IS the fix. A delegated click handler on the navbar intercepts same-shell view links (`preventDefault` + `pushState` + switch — no document load); `popstate` switches views for back/forward. The router is the SINGLE WRITER of `.nav-link` active state (`is-active` + `aria-current="page"`), `document.title`, and the per-view `` (values carried over from the old pages' ``s). +- **View modules:** each folded page's JS changes from top-level boot to an exported `async function mount(root)`; DOM queries scope to `root` (view element ids stay unique across the shell — verify, don't rename, to keep E2E selectors stable); the `initSharedHeader()` call is dropped (the header boots once in the shell via the chat module, as today). +- **Boot order in the shell:** `brand.js` (classic) → `app.js` (module — the chat view; runs at shell boot exactly as today, so its boot/restore/streaming behavior is untouched) → `router.js` (module — lazy-imports non-chat views only). +- **Server:** one small route factory in `app/main.py` serves the shell (`frontend/index.html`) for the folded view paths, registered BEFORE the static catch-all mount (API routes first, per existing comment). The phase-33 caching middleware already wraps the whole app and lists these paths in `HTML_PAGES`, so no-cache + `?v=` asset rewriting applies unchanged — verify, don't rewire. The old per-view `.html` files are DELETED in the same task that lands their shell route (one source of truth). +- **Deep links:** every old view URL (`/sources.html`, …) keeps working as a direct load — the server serves the shell, the router picks the view from the pathname. `/?chat=` (the saved-chat deep link read by `app.js` at boot) is unaffected — it already lives on the shell's own path. +- **Auth flag in view modules:** the folded view modules today call `await initSharedHeader()` and (in `sources.js`, `git-sources.js`) USE ITS RETURN VALUE as the admin flag. In the shell the header boots once via the chat module — so each view module DROPS `initSharedHeader()` and reads the admin flag from `fetchIsAdmin()` instead (the SAME cached `/api/whoami` promise `header.js` exports — zero extra requests; `sources.js`/`history.js`/`tuning.js` already import it, `git-sources.js` must add it to its import). + +## Dependencies +— (none) + +## Tasks +1. `01_shell_router_tuning.md` — shell + router + shell-route factory; fold Tuning as the first non-chat view (proves the pattern end-to-end, gate included). +2. `02_fold_rag_sources_views.md` — fold RAG (sources) + Sources (git-sources); REWRITE the phase-20 `test_sources_midstream_bug.py` to the new "the stream survives" semantics. +3. `03_fold_history_view.md` — fold History; verify the `/?chat=` deep link and history suites. +4. `04_header_shell_wiring.md` — header is shell-owned (single-writer active state, auth gate, hamburger, per-view title/meta); the surviving documents keep their header copies; update the header/nav consistency suites. +5. `05_stream_survival_e2e.md` — new story suite `test_nav_switch_keeps_stream.py`: the owner repro pinned on the mock LLM (mid-stream switch → FULL answer, one brain turn, settled turn) + the real-departure-still-cancels control. +6. `06_regression_sweep_commit.md` — full suite + coverage floor + ruff/pyright; manual verification of the owner repro in a real browser; atomic commit; phase → complete. + +## Testing & Quality +- Unit: `tests/unit/test_frontend_router.py` — source-level pins of the router invariants (house pattern: `tests/unit/test_frontend_hidden_tab.py` reads JS source without a browser): the click interceptor targets ONLY same-shell view paths; switches use `pushState` (no document load); mount-once guard; hidden views get `hidden` + `inert`; the router writes active state/title. +- Integration: the shell-route tests — each folded path serves the shell (200, `text/html`, body is the index page carrying `?v=`-tagged asset refs, `Cache-Control: no-cache`), and a non-view path (e.g. `/nonexistent.html`) still 404s. `app/core/caching.py` must need NO change (the paths are unchanged) — pin that in the test's assertion set. +- Coverage: **>90%** on `app/` (server delta is the small route factory + tests). +- E2E: new story suite `tests/e2e/test_nav_switch_keeps_stream.py` run in isolation (`uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov`) against the deterministic mock LLM (the ~8 s long stream gives the guaranteed mid-stream window — house pattern from `tests/e2e/test_hidden_tab_stream.py`). The phase-20 suite is REWRITTEN in task 02 (its premise — navbar click = navigation — is the behavior this phase removes); phase 73 (`test_hidden_tab_stream.py`) and phase 48 (`test_stop_generation.py`) must stay green UNCHANGED. + +## Completion Criteria +- [ ] Owner repro passes in a REAL browser (real LLM): send → RAG mid-stream → Chat → the FULL answer completes, one brain turn, one `query_log` row (settled, no `turn cancelled`). +- [ ] All five navbar views render in the shell; direct loads of `/`, `/sources.html`, `/git-sources.html`, `/tuning.html`, `/history.html` deep-link to the right view; the four old view `.html` files are deleted; the cache-busting E2E (`test_cache_busting.py`, `test_asset_cache_revalidation.py`) is green with the shell routes. +- [ ] `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` green in isolation, including the real-departure control (external navigation / Stop still cancels per phase 48). +- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean. +- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `fix(chat): keep in-flight answers alive across in-app view switches`) whose body records the owner repro + the phase-48 refinement; phase dir moved to `.agents/phases/complete/`. diff --git a/.agents/phases/complete/76_spa_nav_shell/01_shell_router_tuning.md b/.agents/phases/complete/76_spa_nav_shell/01_shell_router_tuning.md new file mode 100644 index 0000000..93024e6 --- /dev/null +++ b/.agents/phases/complete/76_spa_nav_shell/01_shell_router_tuning.md @@ -0,0 +1,30 @@ +# Task 01 — Shell + router + shell-route factory; Tuning becomes the first non-chat view + +**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived) + +## Objective +Prove the whole architecture end-to-end with ONE folded view: the shell structure, the new router (pushState view switches, mount-once, hidden+inert, single-writer active state/title), the server shell-route factory, and `tuning.js` as a `mount(root)` module. After this task, clicking Tuning in the navbar switches views WITHOUT a document load, and direct loads of `/tuning.html` still deep-link to the Tuning view. + +## Work +1. `app/main.py` — add a small shell-route factory: `def _shell_routes(app, static_dir, paths)` registering `GET ` handlers that return `frontend/index.html` (`text/html`), added AFTER `app.include_router(...)` calls and BEFORE the `app.mount("/", StaticFiles(...))` catch-all (API/routes-first invariant). Call it with `("/tuning.html",)` for this task (tasks 02/03 extend the list — keep the factory list-driven). VERIFY (curl + the integration test) that the phase-33 caching middleware already applies to these responses: `Cache-Control: no-cache` and `?v=`-tagged asset refs (the path is already in `app/core/caching.py` `HTML_PAGES` — do NOT edit that file; if the header is missing from the route response, set it on the route instead of rewiring the middleware). +2. `frontend/index.html` — restructure the body per the phase design: the existing `
` becomes the single view container; INSIDE it, wrap the chat content in `
` and add `
- -
- - -

-
-
-
-

Knowledge base

- -
-

- Every file indexed from your configured sources — git repositories, - local directories, and uploaded archives. Press Sync sources - to pull the latest and re-import. -

-
- - - - - - - - -
-
- – - documents -
-
- – - chunks -
-
- – - last indexed -
-
- -
- - - - - - - - - - - - -
Indexed markdown documents
SourcePathTitleChunksIndexed
-
- - -
-
- - - - - - - - - - - - - diff --git a/frontend/tuning.html b/frontend/tuning.html deleted file mode 100644 index 0f0efa2..0000000 --- a/frontend/tuning.html +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - - Global Tuning · Brain of Reese - - - - - - -
-
- - - Brain of Reese - - - - - - - - -
-
- -
- - -

-
-
-

Global Tuning

-

- Every note below is read into the system prompt of - every chat turn. Add, edit, or remove them here — - no conversation required. -

-
- - -
- - - -
- - -

- - -
-

Tuning notes

-
    -

    No tuning notes yet — add one above.

    -
    -
    -
    - - - - - - - - - - diff --git a/tests/e2e/test_chat_persistence.py b/tests/e2e/test_chat_persistence.py index f909f97..b2e8696 100644 --- a/tests/e2e/test_chat_persistence.py +++ b/tests/e2e/test_chat_persistence.py @@ -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. diff --git a/tests/e2e/test_history_copy.py b/tests/e2e/test_history_copy.py index 7980c8b..64a4594 100644 --- a/tests/e2e/test_history_copy.py +++ b/tests/e2e/test_history_copy.py @@ -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

    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

    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() diff --git a/tests/e2e/test_import_documents.py b/tests/e2e/test_import_documents.py index 9d208e2..9bd9cd8 100644 --- a/tests/e2e/test_import_documents.py +++ b/tests/e2e/test_import_documents.py @@ -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") diff --git a/tests/e2e/test_local_directory_sources.py b/tests/e2e/test_local_directory_sources.py index e9b28b5..74a6581 100644 --- a/tests/e2e/test_local_directory_sources.py +++ b/tests/e2e/test_local_directory_sources.py @@ -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)"). diff --git a/tests/e2e/test_nav_consistency.py b/tests/e2e/test_nav_consistency.py index a83c169..b06d5f5 100644 --- a/tests/e2e/test_nav_consistency.py +++ b/tests/e2e/test_nav_consistency.py @@ -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")) diff --git a/tests/e2e/test_nav_rename_sources.py b/tests/e2e/test_nav_rename_sources.py index 9a634ba..76ca9f6 100644 --- a/tests/e2e/test_nav_rename_sources.py +++ b/tests/e2e/test_nav_rename_sources.py @@ -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. diff --git a/tests/e2e/test_nav_switch_keeps_stream.py b/tests/e2e/test_nav_switch_keeps_stream.py new file mode 100644 index 0000000..340929d --- /dev/null +++ b/tests/e2e/test_nav_switch_keeps_stream.py @@ -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 diff --git a/tests/e2e/test_responsive_polish.py b/tests/e2e/test_responsive_polish.py index d0fc435..d6bbe67 100644 --- a/tests/e2e/test_responsive_polish.py +++ b/tests/e2e/test_responsive_polish.py @@ -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, ( diff --git a/tests/e2e/test_shared_header.py b/tests/e2e/test_shared_header.py index ded2143..78ba825 100644 --- a/tests/e2e/test_shared_header.py +++ b/tests/e2e/test_shared_header.py @@ -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() diff --git a/tests/e2e/test_sources_midstream_bug.py b/tests/e2e/test_sources_midstream_bug.py index 572cc71..8231f61 100644 --- a/tests/e2e/test_sources_midstream_bug.py +++ b/tests/e2e/test_sources_midstream_bug.py @@ -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) # --------------------------------------------------------------------------- diff --git a/tests/e2e/test_stale_ui_copy.py b/tests/e2e/test_stale_ui_copy.py index 1dfa055..1e8a0bb 100644 --- a/tests/e2e/test_stale_ui_copy.py +++ b/tests/e2e/test_stale_ui_copy.py @@ -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}" diff --git a/tests/e2e/test_steering.py b/tests/e2e/test_steering.py index eb64f08..14ad5bd 100644 --- a/tests/e2e/test_steering.py +++ b/tests/e2e/test_steering.py @@ -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 = "" -#: 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: diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 7ba3e7a..4694ec5 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -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 + # 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" in r.text + assert f"{old_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 diff --git a/tests/integration/test_caching_revalidation.py b/tests/integration/test_caching_revalidation.py index 8024a28..4963132 100644 --- a/tests/integration/test_caching_revalidation.py +++ b/tests/integration/test_caching_revalidation.py @@ -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 diff --git a/tests/integration/test_containerfile_assets.py b/tests/integration/test_containerfile_assets.py index b0e20c3..325748e 100644 --- a/tests/integration/test_containerfile_assets.py +++ b/tests/integration/test_containerfile_assets.py @@ -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\\"")' diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py index fb75ac7..b641065 100644 --- a/tests/unit/test_chat_persistence.py +++ b/tests/unit/test_chat_persistence.py @@ -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']*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
    " + # 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" ) diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py index 7ee22b8..224e84d 100644 --- a/tests/unit/test_document_viewer.py +++ b/tests/unit/test_document_viewer.py @@ -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 ', 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" ) diff --git a/tests/unit/test_frontend_brand.py b/tests/unit/test_frontend_brand.py index 9c8d992..7a53c5d 100644 --- a/tests/unit/test_frontend_brand.py +++ b/tests/unit/test_frontend_brand.py @@ -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) ) diff --git a/tests/unit/test_frontend_router.py b/tests/unit/test_frontend_router.py new file mode 100644 index 0000000..f2cbf00 --- /dev/null +++ b/tests/unit/test_frontend_router.py @@ -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 ````; +* 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- 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' 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' 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('
    ') + assert main != -1 + view_chat = html.find('
    ", 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('Chat') + 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']*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("", tag_start) + tag = html[tag_start:tag_end] + assert tag.startswith(' 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']*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']*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 diff --git a/tests/unit/test_frontend_sync_upload.py b/tests/unit/test_frontend_sync_upload.py index b6b9fc8..68096cb 100644 --- a/tests/unit/test_frontend_sync_upload.py +++ b/tests/unit/test_frontend_sync_upload.py @@ -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 ` 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 ` 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 ` 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 diff --git a/tests/unit/test_hamburger_nav.py b/tests/unit/test_hamburger_nav.py index d3243de..e9ac58a 100644 --- a/tests/unit/test_hamburger_nav.py +++ b/tests/unit/test_hamburger_nav.py @@ -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 diff --git a/tests/unit/test_history_copy.py b/tests/unit/test_history_copy.py index 37d15a8..86bd4d4 100644 --- a/tests/unit/test_history_copy.py +++ b/tests/unit/test_history_copy.py @@ -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 ````, value carried over from the +old page's ````). 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('
    ", 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 ), 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

    , 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}" diff --git a/tests/unit/test_history_page.py b/tests/unit/test_history_page.py index ae21dbc..a882d60 100644 --- a/tests/unit/test_history_page.py +++ b/tests/unit/test_history_page.py @@ -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=`` 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 ```` aria-label in BOTH states — WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ```` - 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 ``

    `` after + it).""" + start = html.find('
    ", 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 (...)`` (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 `` 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 '