Files
brain-of-reese/tests/e2e/test_nav_consistency.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

616 lines
28 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 34 story E2E (Playwright): ONE navbar on every page.
Story: ``.agents/user_stories/nav-consistency.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_nav_consistency.py -v --no-cov
TODO.md L3 (owner 2026-08-26): "I want the navbar to be consistent
between every page. I don't want buttons to pop in and out of existance.
Just keep all those buttons active across all tabs."
Contract under test — the header is IDENTICAL on all five pages (chat,
sources, document viewer, global tuning, login): one shared markup block
(phase 34 task 03), one owner of all control behavior (header.js, tasks
01/02), the viewer's back + title preserved in a second titlebar row
(task 04), and the phase-12/19 height contract (64px desktop / 58px at
≤640px) on the standard row everywhere.
Per role, the VISIBLE inventory:
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning,
#nav-history] (five links, that order — the "Sources" link joined in
phase 35 as "Git sources", owner permission 2026-08-26; the History
link joined in phase 50, owner permission 2026-08-29) + #sign-out-btn
(with
#sign-in-link
hidden) — on all five pages, same id+class inventory, same DOM order.
The #sync-btn (Sources page only) and the #new-chat-btn (chat page
only) left the shared bar at owner request (2026-08-28 — they are
page-specific now, so the per-page visible inventory differs for
exactly those two); the #steering-toggle was removed from the navbar
the same day; note management lives on /tuning.html;
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-sources /
#nav-tuning / #nav-history hidden — locked A10 UI revision) + #sign-in-link (with
#sign-out-btn hidden; the Sources page's #sync-btn stays ship-hidden)
on all five pages — and the steering toggle (removed at owner
request, 2026-08-28) + panel are ABSENT from the DOM (the panel via
the phase 16 "absent, not hidden" treatment, carried into phase 34
task 01; test_admin_auth pins it).
Normalization for the inventory comparison: the current-page ``is-active``
nav marker and the sign-in ``?next=`` value legitimately differ per page,
so both are stripped (the href is compared by pathname only).
Viewer specifics: row 1 (the standard bar) is exactly as tall as the chat
page's bar (64px / 58px) and row 2 (``.doc-titlebar``) is present with
#doc-back + #doc-title + #doc-meta badges; #doc-back target resolution
(phase 13) is honored — ``back=`` accepted for same-origin relative
URLs, rejected (→ /sources.html) otherwise.
Steering off-chat: on /tuning.html (admin, zero notes) the navbar
carries no steering toggle (removed at owner request, 2026-08-28) — the
header #steering-panel section still ships hidden and the Tuning
page's own note list shows the empty state — no chat needed. Sync is
present, not triggered: #sync-btn is visible on /sources.html (its
home since the owner rework 2026-08-28) but is never clicked here (a
real sync clones real repos — the full state machine is
test_sync_button.py's job).
Determinism note: every assertion is settled-state — each page visit
first waits for the whoami toggle to land (exactly one of Sign in /
Sign out visible; the anonymous removal of the steering panel happens
in the SAME initSharedHeader pass) and, on the viewer, for the document
title to render. The seed truncates steering_notes, so the Tuning page
shows the empty state on every admin page. No chat turn is ever
submitted; #sync-btn is never clicked.
Test → story mapping (Playwright Mapping Rule):
1. ``test_admin_inventory_identical_on_all_five_pages``
2. ``test_anonymous_inventory_identical_on_all_five_pages``
3. ``test_viewer_row1_height_matches_chat_and_titlebar_present``
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
import asyncio
import re
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"
#: The five pages of the app (acceptance criterion 2 of the story).
CHAT_URL = "/"
SOURCES_URL = "/sources.html"
TUNING_URL = "/tuning.html"
LOGIN_URL = "/login.html"
#: 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"
DOC_TITLE = "Kubernetes Homelab Cluster"
#: The phase-12/19 pinned bar heights (frontend/assets/styles.css
#: --header-h, desktop and ≤640px).
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 + ZERO steering notes (deterministic count badge on
every admin page) + the fixture docs for the viewer URL."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
_run_in_thread(_import_fixtures(mock_port))
def _box_height(page: Page, selector: str) -> float:
box = page.locator(selector).bounding_box()
assert box is not None, f"{selector} not rendered"
return box["height"]
# ---------------------------------------------------------------------------
# The heart of the suite: the normalized header-control inventory
# ---------------------------------------------------------------------------
#: The header controls, in their shipped DOM order. The inventory is
#: normalized per the task: the current-page ``is-active`` nav marker is
#: stripped from the class list, and anchor hrefs are compared by
#: pathname only (the sign-in ``?next=`` value legitimately differs per
#: page — it is rewritten to the current page by header.js).
_INVENTORY_JS = """() => {
const inner = document.querySelector("header .header-inner");
if (!inner) return null;
const sel = [
".brand",
".app-nav > a.nav-link",
"#sync-btn",
"#new-chat-btn",
"#sign-in-link",
"#sign-out-btn",
].join(",");
return [...inner.querySelectorAll(sel)].map((el) => {
const classes = [...el.classList].filter((c) => c !== "is-active");
const id = el.id ? "#" + el.id : "";
const href = el.tagName === "A" ? (el.getAttribute("href") || "").split("?")[0] : "";
const text = (el.textContent || "").replace(/\\s+/g, " ").trim();
return el.tagName.toLowerCase() + id + "." + classes.join(".") + "::" + text + "::" + href;
});
}"""
def _header_inventory(page: Page) -> list[str]:
"""The ordered id+class inventory of the header controls on the page
``page`` is showing (normalized — see _INVENTORY_JS)."""
inv = page.evaluate(_INVENTORY_JS)
assert inv is not None, "no `header .header-inner` on this page"
# Shared bar: brand + the four nav links + Sign in + Sign out = 7
# (the #sync-btn / #new-chat-btn selectors keep matching nothing —
# they left the bar with the owner rework 2026-08-28; the steering
# toggle was removed the same day).
assert len(inv) >= 7, f"header control inventory unexpectedly short: {inv}"
return inv
def _wait_settled(page: Page, admin: bool) -> None:
"""Wait for initSharedHeader's whoami toggle to land: exactly one of
Sign in / Sign out is visible (both ship hidden in the HTML). For
anonymous visitors the steering panel removal happens in the SAME
pass, so it is already gone when this returns."""
if admin:
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
expect(page.locator("#sign-out-btn")).to_be_hidden()
def _assert_landmarks(page: Page, label: str) -> None:
"""UI Structure Check (AGENTS.md rule 5): the page's landmarks — a
<header>, the labeled <nav>, and <main> — survive on every page."""
assert page.locator("header").count() >= 1, f"{label}: no <header> landmark"
assert page.locator('nav[aria-label="Primary"]').count() == 1, (
f"{label}: no labeled <nav aria-label> landmark"
)
assert page.locator("main").count() >= 1, f"{label}: no <main> landmark"
def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[str]:
"""Goto a page, wait for the settled header state (+ the document on
the viewer), check the per-role visible inventory, and return the
normalized control inventory."""
page.goto(app_url + url)
_wait_settled(page, admin=admin)
if name == "viewer":
# The document itself has settled (rendered, not Loading…/
# not-found) so the bar is measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# The per-role VISIBLE inventory (the story: no button pops in or
# out because of which page you are on).
expect(page.locator(".app-nav a[href='/']")).to_be_visible() # Chat
if admin:
expect(page.locator("#nav-sources")).to_be_visible()
# Phase 35: the fourth admin-only nav link (now "Sources",
# shipped as "Git sources") is revealed on every page, between
# RAG and Tuning.
expect(page.locator("#nav-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
# Phase 50: the FIFTH admin-only nav link (History) is revealed
# on every page, after Tuning.
expect(page.locator("#nav-history")).to_be_visible()
# The steering toggle was removed from the navbar at owner
# request (2026-08-28) — absent on every page, admin included.
assert page.locator("#steering-toggle").count() == 0, (
f"{name}: the steering toggle was removed from the navbar"
)
# 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:
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:
# Locked A10 UI revision: admin-only links ship hidden, never
# revealed for anonymous…
expect(page.locator("#nav-sources")).to_be_hidden()
expect(page.locator("#nav-git-sources")).to_be_hidden()
expect(page.locator("#nav-tuning")).to_be_hidden()
# Phase 50: the History link ships hidden and stays hidden for
# anonymous (the same A10 UI revision).
expect(page.locator("#nav-history")).to_be_hidden()
expect(page.locator("#sync-btn")).to_be_hidden()
expect(page.locator("#sign-out-btn")).to_be_hidden()
expect(page.locator("#sign-in-link")).to_be_visible()
# …and the steering surface is ABSENT (the toggle was removed
# from the navbar at owner request, 2026-08-28; the panel via
# phase 16 "absent, not hidden", carried into the shared module
# by phase 34 task 01 — test_admin_auth pins the same
# contract).
assert page.locator("#steering-toggle").count() == 0, (
f"{name}: the steering toggle must be absent for anonymous"
)
assert page.locator("#steering-panel").count() == 0, (
f"{name}: the steering panel must be absent for anonymous"
)
# 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:
expect(page.locator("#new-chat-btn")).to_be_hidden()
_assert_landmarks(page, name)
return _header_inventory(page)
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
"""The login page's header IN THE ADMIN STATE. A signed-in admin is
redirected off the login form by login.js (``location.replace`` →
the default next, /sources.html — phase 16, pinned by
test_admin_auth), so this ONE visit serves the page script with the
redirect lines suppressed (a test-local route on the login.js
script; the page's header — settled by the same initSharedHeader
pass — is what gets measured, and the page stays put).
The browser cache is cleared first: phase 33 caches ``/assets/*``
``immutable`` for a year, and the earlier form login already fetched
the (unmodified) login.js — a cache hit would bypass the route.
"""
login_js = (REPO / "frontend" / "assets" / "login.js").read_text(encoding="utf-8")
assert "window.location.replace(safeNext())" in login_js
suppressed = login_js.replace(
"window.location.replace(safeNext())",
"window.__e2e_redirectSuppressed = true; // test: observe the header",
)
page.route(
LOGIN_JS_ROUTE,
lambda route: route.fulfill(
status=200, content_type="text/javascript", body=suppressed
),
)
try:
cdp = page.context.new_cdp_session(page)
try:
cdp.send("Network.clearBrowserCache")
finally:
cdp.detach()
page.goto(app_url + LOGIN_URL)
expect(page).to_have_url(app_url + LOGIN_URL, timeout=15_000)
_wait_settled(page, admin=True)
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
expect(page.locator("#nav-sources")).to_be_visible()
expect(page.locator("#nav-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
# Phase 50: the History link joins the admin bar on the login
# page too (the one-bar contract).
expect(page.locator("#nav-history")).to_be_visible()
assert page.locator("#steering-toggle").count() == 0, (
"login: the steering toggle was removed from the navbar"
)
# Page-specific controls are NOT on the auth page (owner rework
# 2026-08-28: sync → Sources page, new chat → chat page).
assert page.locator("#sync-btn").count() == 0
assert page.locator("#new-chat-btn").count() == 0
expect(page.locator("#sign-out-btn")).to_be_visible()
expect(page.locator("#sign-in-link")).to_be_hidden()
_assert_landmarks(page, "login")
return _header_inventory(page)
finally:
page.unroute(LOGIN_JS_ROUTE)
# ---------------------------------------------------------------------------
# 1. Admin: the same visible controls, same inventory, same DOM order,
# on all five pages
# ---------------------------------------------------------------------------
def test_admin_inventory_identical_on_all_five_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)
login(page, app_url, next=CHAT_URL)
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
inventories: dict[str, list[str]] = {}
for name, url in (
("chat", CHAT_URL),
("sources", SOURCES_URL),
("viewer", VIEWER_URL),
("tuning", TUNING_URL),
):
inventories[name] = _visit(page, app_url, name, url, admin=True)
# The login page redirects a signed-in admin away — measure it with
# the redirect aborted (see the helper).
inventories["login"] = _admin_login_page_inventory(page, app_url)
reference = inventories["chat"]
for name, inv in inventories.items():
assert inv == reference, (
f"admin header control inventory differs on {name}:\n"
f" chat: {reference}\n {name}: {inv}"
)
# ---------------------------------------------------------------------------
# 2. Anonymous: the reduced bar — identically — on all five pages
# ---------------------------------------------------------------------------
def test_anonymous_inventory_identical_on_all_five_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)
# No login: a fresh context is anonymous by construction.
inventories: dict[str, list[str]] = {}
for name, url in (
("chat", CHAT_URL),
("sources", SOURCES_URL),
("viewer", VIEWER_URL),
("tuning", TUNING_URL),
("login", LOGIN_URL),
):
inventories[name] = _visit(page, app_url, name, url, admin=False)
reference = inventories["chat"]
for name, inv in inventories.items():
assert inv == reference, (
f"anonymous header control inventory differs on {name}:\n"
f" chat: {reference}\n {name}: {inv}"
)
# ---------------------------------------------------------------------------
# 3. Viewer: row 1 is exactly the chat bar's height (64px / 58px) and
# the titlebar row (back + title + meta) is present
# ---------------------------------------------------------------------------
def test_viewer_row1_height_matches_chat_and_titlebar_present(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_seed_db(mock_llm)
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
page.set_viewport_size({"width": width, "height": 800})
page.goto(app_url + CHAT_URL)
chat_h = _box_height(page, ".app-header")
page.goto(app_url + VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# Row 1 IS the standard bar — the same --header-h as chat…
row1 = _box_height(page, ".doc-header .app-header")
assert row1 == expected_h, f"viewer row 1 is {row1}px at {width}px"
assert chat_h == expected_h, f"chat bar is {chat_h}px at {width}px"
assert row1 == chat_h, "viewer row 1 must match the chat bar exactly"
# …and the titlebar row exists below it (content-sized, > 0)…
titlebar = _box_height(page, ".doc-titlebar")
assert titlebar > 0, "the .doc-titlebar row is not rendered"
# …with the back link + the rendered title + the meta badges.
expect(page.locator("#doc-back")).to_be_visible()
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
expect(page.locator("#doc-meta .doc-source-badge", has_text="docs")).to_be_visible()
expect(page.locator("#doc-meta .format-badge", has_text="md")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. Viewer: #doc-back target resolution (phase 13) — one positive, one
# rejection case, both by clicking the link
# ---------------------------------------------------------------------------
def test_viewer_back_link_honors_back_param(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# Positive: back=/ (same-origin relative) is honored — href "/",
# label "Chat", and the click returns to the chat page.
page.goto(app_url + VIEWER_URL + "&back=%2F")
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/")
expect(back.locator("span")).to_have_text("Chat")
back.click()
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
# Rejection: an absolute URL is NOT same-origin-relative — the
# target falls back to the Sources page (label "Sources") and the
# click goes there.
page.goto(app_url + VIEWER_URL + "&back=https%3A%2F%2Fevil.example")
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/sources.html")
expect(back.locator("span")).to_have_text("Sources")
back.click()
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
# ---------------------------------------------------------------------------
# 5. Steering off-chat: on /tuning.html (admin, zero notes) the navbar
# carries no steering toggle (removed at owner request, 2026-08-28)
# — the header panel section still ships hidden and the Tuning
# page's own note list shows the empty state. No chat turn needed.
# ---------------------------------------------------------------------------
def test_steering_surface_off_chat_on_tuning_page(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm) # truncates steering_notes → zero notes
login(page, app_url, next=TUNING_URL)
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
# Settled admin state: the navbar toggle is GONE (removed at owner
# request, 2026-08-28), the header panel section still ships
# hidden, and the Tuning page's own note list shows the empty
# state (zero seeded notes).
assert page.locator("#steering-toggle").count() == 0, (
"the steering toggle was removed from the navbar"
)
expect(page.locator("#steering-panel")).to_be_hidden()
expect(page.locator("#tune-list .tuning-note")).to_have_count(0)
expect(page.locator("#tune-empty")).to_be_visible()
# ---------------------------------------------------------------------------
# 6. Sync is present (admin) on a non-Sources page — and is NOT
# triggered: a real sync clones real repos; the full state machine
# is test_sync_button.py's job.
# ---------------------------------------------------------------------------
def test_sync_button_present_on_sources_page_without_triggering(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# The button's home is the Sources page (owner rework 2026-08-28 —
# it left the shared navbar).
login(page, app_url, next=SOURCES_URL)
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=15_000)
# Idle, retry-ready state — the boot re-attach (GET /api/sync/status,
# idle on the fresh app) must not have left it busy or labeled as a
# finished run.
expect(btn).to_be_enabled()
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"))