Files
brain-of-reese/tests/e2e/test_shared_header.py
T
ducoterra ffa919b8bf fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the
five navbar views (Chat, RAG, Sources, Tuning, History) were separate
HTML documents, so a navbar click was a REAL cross-document navigation
— the chat page unloaded, the in-flight SSE fetch was aborted, and the
phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled")
stopped the model. Observed: send question -> click RAG mid-stream ->
click Chat -> the answer never finished: no `query_log` row, and on
return a dangling question with no brain record (the pre-token pagehide
partial persist skips because `acc` is empty).

Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06,
flagged per AGENTS.md rule 3, not silently deviated): "real navigation
cancels the fetch" now means LEAVING THE APP — tab close,
external/other-document navigation, the Stop button. In-app navbar
switches are client-side view switches and no longer cancel.

Fix — Option A (SPA shell), chosen over B (Service Worker owns the
stream) and C (server-side turn registry + resume):
- frontend/index.html is the shell: ONE `<main id="main">` holds the
  five `<section class="view">` blocks; hidden views carry BOTH
  `hidden` and `inert` (WCAG — no focus/keyboard traversal). The
  shared header, the single `doc-modal-*` skeleton, and the
  `#app-version` footer each exist exactly once; the per-view copies
  from the four folded pages are dropped.
- New frontend/assets/router.js (vanilla module — no framework, no
  bundler, No-CDN rule intact): lazy-imports a view module on FIRST
  show only (mount-once, hide-forever — the chat view's in-flight SSE
  reader persists across switches; that persistence IS the fix);
  intercepts same-shell navbar links with preventDefault +
  history.pushState (never a document load); handles popstate; single
  writer of `.nav-link` active state (is-active + aria-current),
  document.title, and the per-view meta description (values carried
  over from the old pages' heads, brand-resolved at write time).
- Each folded page's JS becomes `export async function mount(root)` —
  root-scoped queries; `initSharedHeader()` dropped (the header boots
  once in the shell via the chat module; the admin flag comes from the
  same cached `fetchIsAdmin()` promise — zero extra requests).
- app/main.py: a small list-driven route factory serves the shell for
  /tuning.html, /sources.html, /git-sources.html, /history.html —
  registered AFTER the API routers and BEFORE the static catch-all
  (routes-first). The phase-33 caching middleware applies no-cache +
  `?v=` rewriting unchanged; app/core/caching.py needed NO change
  (the view paths did not change — pinned by the integration tests).
- The four old view .html files are DELETED (one source of truth);
  deep links to the old URLs keep working (the router picks the view
  from the pathname); `/?chat=<id>` is unaffected; the Containerfile
  bundles router.js (inlining the lazy view modules) and drops the
  folded page files.
- app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell
  keeps long saved answers in the chat, and the old cap (stricter than
  the 24_000-char total history budget) 422-rejected any second turn
  in such a chat (found by the phase-42 E2E suite on the shell).

Boundaries: login.html, shared.html, doc-edit.html, document.html
REMAIN separate documents (flow pages, not navbar tabs); a mid-stream
navigation to doc-edit/document.html still cancels per phase 48
(follow-up candidate, out of scope). The SSE API is unchanged. Real
departures still cancel the turn — phase 48 intact (pinned by
tests/e2e/test_stop_generation.py, unchanged, and by the new suite's
real-departure control).

Tests:
- Phase-20 suite REWRITTEN to the new semantics
  (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer
  cancels — the stream survives the switch and the FULL answer
  settles; the pagehide partial persist REMAINS for real departures
  (the partial's exact shape — first streamed chunk prefix, no done
  metadata — is still pinned there).
- NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock
  LLM): the owner repro (send -> RAG mid-stream -> Chat: window
  sentinel survives = same document, FULL answer, exactly one brain
  turn in bor.chat.v1, exactly one settled query_log row, auto-saved
  row matches) + the same mid-stream switch against the other three
  views + the real-departure-still-cancels control + the no-switch
  baseline.
- tests/unit/test_frontend_router.py: source-level pins of the router
  invariants (click interceptor targets ONLY same-shell view paths,
  pushState-only switches, mount-once guard, hidden+inert pair,
  single-writer active state/title); shell-route integration tests
  (each folded path serves the shell with no-cache + `?v=` body; a
  non-view path still 404s); the file-reading unit pins re-pointed at
  the shell (the four view files are gone — the shell is the source
  of truth).

Verification (this commit): full suite green — 1565 unit+integration
tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the
phase's E2E suites green in isolation (house protocol, AGENTS.md rule
9). Owner repro verified in a real browser against the real LLM
(dev server :8010, headful Chromium): "tell me about everquest" ->
RAG mid-stream -> Chat — the answer completed with one brain bubble
and no error banner, `query_log` gained exactly one settled row
(deflected=True: the dev KB holds no EverQuest docs — the settle, not
the topic, is the proof), zero "chat: turn cancelled" lines for that
turn; the control (real navigation to /shared.html mid-stream) still
cancelled (no settled row, the cancel line logged, the partial
persisted on return). Screenshots: .agents/screenshots/76_manual_*.

Phase 76 (76_spa_nav_shell) complete — moved to
.agents/phases/complete/.
2026-09-06 06:31:31 -04:00

396 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 19 E2E (Playwright): the shared header bar on every page.
Story: ``.agents/user_stories/shared-header.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_shared_header.py -v --no-cov
Contract under test (owner report 2026-08-23, phase 19) — ONE bar per
page, the same controls everywhere:
* chat / sources / viewer: brand + nav [Chat, RAG — admin only, + the
"Sources" link (phase 35, shipped as "Git sources")] + Sign in /
Sign out. The New Chat button is chat-page only — it left the shared
bar at owner request (2026-08-28, moved to index.html's .chat-shell);
* document viewer: the standard bar (row 1) + back + title + meta in a
second titlebar row (phase 34, owner confirmation 2026-08-26 — the
viewer's old "no nav" single-row bar is superseded; it now carries
the SAME nav contract as every other page);
* the "RAG" nav link (``#nav-sources``) is HIDDEN for anonymous
users on every page and shown for admin (phase-16 UX revision with
owner permission; the soft-gate page and the A10 API split are
untouched) — now on the viewer as well (phase 34);
* the bar height never moves: 64px desktop / 58px at ≤640px (phase-12
``--header-h`` contract, bounding-box measurement convention) — on
the viewer this is ROW 1 (``.doc-header .app-header``); the
titlebar row is content-sized.
Determinism note: every assertion is settled-state — ``assert_shared_bar``
first waits for the whoami toggle to land (exactly one of Sign in /
Sign out visible), and the viewer waits for the document to render. No
streaming is involved in this story: the chat page is opened at most for
its header; no turn is ever submitted.
Test → story mapping (Playwright Mapping Rule):
1. ``test_anonymous_bar_on_all_pages``
2. ``test_admin_bar_on_all_pages``
3. ``test_sources_nav_hidden_for_anonymous_everywhere``
4. ``test_new_chat_button_is_chat_page_only``
5. ``test_sign_out_from_viewer_returns_to_anonymous``
6. ``test_mobile_bar_fits_and_heights_held``
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import 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
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: A seeded fixture doc (source=docs), URL-encoded — the same document
#: every viewer suite uses (title "Kubernetes Homelab Cluster").
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
SOURCES_URL = "/sources.html"
DOC_TITLE = "Kubernetes Homelab Cluster"
#: The shared header-bar token values (frontend/assets/styles.css :root
#: and the ≤640px media query) — phase 12, pinned here as a regression.
DESKTOP_HEADER_H = 64
MOBILE_HEADER_H = 58
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 owns the test loop)."""
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 _seed_db(mock_port: int) -> None:
"""Fresh KB with the fixture docs (needed for the viewer URL and the
admin sources catalog)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
_run_in_thread(_import_fixtures(mock_port))
# ---------------------------------------------------------------------------
# The heart of the suite: one helper, the full shared-bar contract
# ---------------------------------------------------------------------------
def _expected_h(page: Page) -> int:
"""The phase-12 bar height for the current viewport (≤640 → 58)."""
viewport = page.viewport_size
assert viewport is not None, "every test here sets an explicit viewport"
return MOBILE_HEADER_H if viewport["width"] <= 640 else DESKTOP_HEADER_H
def _bar_selector(page_kind: str) -> str:
"""The STANDARD bar element on each page kind. Phase 34: the
viewer's header is two rows — the height contract applies to row 1
(the standard bar), not the whole two-row <header>."""
return ".doc-header .app-header" if page_kind == "viewer" else ".app-header"
def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = False) -> None:
"""Assert the phase-19 shared-bar contract on the page the ``page``
is already showing.
``page_kind`` is ``"chat"``, ``"sources"``, or ``"viewer"``. The
helper waits for the SETTLED state — both auth controls ship hidden
in the HTML, so "exactly one is visible" means /api/whoami resolved
and header.js (``initSharedHeader``) did its toggle — before any
assertion runs.
``mobile`` (phase 46, owner permission 2026-08-27, ``TODO.md`` L9):
at ≤640px the nav links no longer sit inline — the bar carries the
44px ``#nav-toggle`` hamburger and the nav ships as the CLOSED
(invisible) dropdown. Per-role link visibility INSIDE the menu is
pinned by ``test_mobile_hamburger_nav.py`` (phase 46, task 03); this
helper pins the bar-level contract only.
"""
# Settled auth state: exactly one of Sign in / Sign out is
# revealed (phase-16 semantics, now owned by the shared module).
# Both probes are viewport-independent attribute checks — at ≤640px
# the bar auth copies are CSS-hidden behind the #sign-in-link-mobile
# / #sign-out-btn-mobile dropdown copies (phase-46 UX revision), so
# visibility is not a cross-viewport probe.
if admin:
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=15_000,
)
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
page.wait_for_function(
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
timeout=15_000,
)
expect(page.locator("#sign-out-btn")).to_be_hidden()
# 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:
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
# #nav-sources / #nav-tuning links (phase 19 / phase 29) ship
# hidden and are revealed for admin (phase-16 UX revision, owner
# permission 2026-08-23; the soft-gate page and the A10 API split
# are untouched).
#
# Phase 46 (owner permission 2026-08-27, ``TODO.md`` L9): at ≤640px
# the links live in the #nav-toggle dropdown instead — the bar
# shows the hamburger and the nav is the closed (invisible +
# non-interactive) panel; the per-role link visibility inside the
# menu is pinned by test_mobile_hamburger_nav.py (phase 46 task 03).
if mobile:
expect(page.locator("#nav-toggle")).to_be_visible()
expect(page.locator("#app-nav")).to_be_hidden()
else:
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
for link_id in ("#nav-sources", "#nav-tuning"):
nav = page.locator(link_id)
assert nav.count() == 1, f"one {link_id} expected on the {page_kind} page"
if admin:
expect(nav).to_be_visible()
else:
expect(nav).to_be_hidden()
if page_kind == "viewer":
# The document itself has settled (rendered, not Loading…/not-found)
# so the bar is being measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# The bar height never moves: 64px desktop / 58px ≤640px (phase 12),
# bounding-box measurement — the new pills must fit inside it.
box = page.locator(_bar_selector(page_kind)).bounding_box()
assert box is not None, f"{_bar_selector(page_kind)} not rendered"
assert box["height"] == _expected_h(page), (
f"{page_kind} bar is {box['height']}px, expected {_expected_h(page)}px"
)
def _assert_no_overflow(page: Page, label: str) -> None:
"""No horizontal page overflow (the responsive-polish convention)."""
scroll, client = page.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
assert scroll <= client, f"horizontal overflow on {label}: {scroll} > {client}"
# ---------------------------------------------------------------------------
# 1. Anonymous: the bar exists on all three pages, in the anonymous state
# ---------------------------------------------------------------------------
def test_anonymous_bar_on_all_pages(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
page.goto(app_url + "/")
assert_shared_bar(page, admin=False, page_kind="chat")
page.goto(app_url + SOURCES_URL)
# Phase 16's soft gate is unchanged for direct-URL visitors — the
# bar above it is what this suite pins.
expect(page.locator("#sources-gate")).to_be_visible()
assert_shared_bar(page, admin=False, page_kind="sources")
page.goto(app_url + VIEWER_URL)
assert_shared_bar(page, admin=False, page_kind="viewer")
# ---------------------------------------------------------------------------
# 2. Admin: the bar on all three pages flips to the signed-in state
# ---------------------------------------------------------------------------
def test_admin_bar_on_all_pages(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# Real form login with next=/ — the phase-16 redirect flow still
# lands the admin on the chat page.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
assert_shared_bar(page, admin=True, page_kind="chat")
page.goto(app_url + SOURCES_URL)
expect(page.locator("#sources-gate")).to_be_hidden()
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
assert_shared_bar(page, admin=True, page_kind="sources")
page.goto(app_url + VIEWER_URL)
assert_shared_bar(page, admin=True, page_kind="viewer")
# ---------------------------------------------------------------------------
# 3. The RAG nav link: hidden for anonymous everywhere, revealed
# after a real login (a toggle, not just initial state)
# ---------------------------------------------------------------------------
def test_sources_nav_hidden_for_anonymous_everywhere(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
for path in ("/", SOURCES_URL):
page.goto(app_url + path)
# Settled anonymous state, then the nav-link contract.
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
nav = page.locator("#nav-sources")
assert nav.count() == 1
expect(nav).to_be_hidden()
# The login page carries the full shared header too (phase 34);
# for anonymous the admin-only nav links stay hidden (they ship
# hidden and are revealed only for the admin).
page.goto(app_url + "/login.html")
page.wait_for_load_state("networkidle") # the whoami round-trip has settled
nav = page.locator("#nav-sources")
assert nav.count() == 1
expect(nav).to_be_hidden()
# And the toggle works, not just the initial state: after a real
# form login on the chat page the link appears.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#nav-sources")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. The New chat button is chat-page only (owner rework 2026-08-28 —
# it left the shared bar with the 'go to the chat, fresh' behavior)
# ---------------------------------------------------------------------------
def test_new_chat_button_is_chat_page_only(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# 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_be_hidden()
# …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()
# ---------------------------------------------------------------------------
# 5. Sign out from the viewer: the same page comes back anonymous
# ---------------------------------------------------------------------------
def test_sign_out_from_viewer_returns_to_anonymous(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# Log in with next=/sources.html — lands on the admin sources bar…
login(page, app_url, next="/sources.html")
expect(page).to_have_url(app_url + "/sources.html")
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
# …and open the viewer directly: the admin bar is there too.
page.goto(app_url + VIEWER_URL)
assert_shared_bar(page, admin=True, page_kind="viewer")
# Sign out from the viewer: header.js POSTs /api/logout and reloads;
# after the reload the same page shows the anonymous bar.
page.click("#sign-out-btn")
assert_shared_bar(page, admin=False, page_kind="viewer")
expect(page).to_have_url(app_url + VIEWER_URL)
# ---------------------------------------------------------------------------
# 6. Mobile (375×812): 58px bars, no horizontal overflow, in BOTH auth
# states — the new pills never grow the bar
#
# Phase 46 adaptation (owner permission 2026-08-27, ``TODO.md`` L9):
# at ≤640px the nav links leave the bar — the hamburger (#nav-toggle)
# is visible and the nav is the closed dropdown; the per-role link
# visibility inside the menu is pinned by
# test_mobile_hamburger_nav.py (phase 46, task 03).
# ---------------------------------------------------------------------------
def test_mobile_bar_fits_and_heights_held(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 375, "height": 812})
_seed_db(mock_llm)
def check_all(admin: bool) -> None:
for kind, path in (
("chat", "/"),
("sources", SOURCES_URL),
("viewer", VIEWER_URL),
):
page.goto(app_url + path)
# 58px at 375px is asserted inside assert_shared_bar…
assert_shared_bar(page, admin=admin, page_kind=kind, mobile=True)
# …and the pills (icon-only at ≤640px) + the hamburger fit
# without overflow.
_assert_no_overflow(page, f"{kind} @375px (admin={admin})")
# Anonymous: the two icon pills are Sign in + New chat.
check_all(admin=False)
# Signed in: Sign out joins the bars (the admin-only nav links join
# the MENU, not the bar — phase 46) — and the bar never grows.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
check_all(admin=True)