Files
brain-of-reese/tests/e2e/test_nav_consistency.py
T

494 lines
21 KiB
Python

"""Phase 34 story E2E (Playwright): ONE navbar on every page.
Story: ``.agent/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-tuning] + #steering-toggle
+ #sync-btn + #new-chat-btn + #sign-out-btn (with #sign-in-link
hidden) — on all five pages, same id+class inventory, same DOM order;
* anonymous: brand + nav [Chat] (#nav-sources / #nav-tuning hidden —
locked A10 UI revision) + #new-chat-btn + #sign-in-link (with
#sync-btn hidden, #sign-out-btn hidden) on all five pages — and the
steering toggle + panel are ABSENT from the DOM (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 works off-chat: on /tuning.html (admin, zero notes) the toggle
opens/closes #steering-panel with the empty state and a 0 count badge —
no chat needed. Sync is present, not triggered: #sync-btn is visible on
/tuning.html 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 toggle happens
in the SAME initSharedHeader pass) and, on the viewer, for the document
title to render. The seed truncates steering_notes, so the count badge is
0 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_panel_works_off_chat_on_tuning_page``
6. ``test_sync_button_present_on_tuning_page_without_triggering``
"""
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",
"#steering-toggle",
"#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"
assert len(inv) >= 8, 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 toggle + panel removal happens in
the SAME pass, so they are 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()
expect(page.locator("#nav-tuning")).to_be_visible()
expect(page.locator("#steering-toggle")).to_be_visible()
expect(page.locator("#sync-btn")).to_be_visible()
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-tuning")).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 (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"
)
expect(page.locator("#new-chat-btn")).to_be_visible()
_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-tuning")).to_be_visible()
expect(page.locator("#steering-toggle")).to_be_visible()
expect(page.locator("#sync-btn")).to_be_visible()
expect(page.locator("#sign-out-btn")).to_be_visible()
expect(page.locator("#sign-in-link")).to_be_hidden()
expect(page.locator("#new-chat-btn")).to_be_visible()
_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 works off-chat: on /tuning.html (admin, zero notes) the
# header toggle drives the panel — open/close cycle, empty state,
# count badge 0. No chat turn is needed.
# ---------------------------------------------------------------------------
def test_steering_panel_works_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 toggle is on the bar, the panel ships
# hidden, and the count badge reads 0 (zero seeded notes).
expect(page.locator("#steering-toggle")).to_be_visible()
expect(page.locator("#steering-panel")).to_be_hidden()
expect(page.locator("#steering-count")).to_have_text("0")
# Open: the panel shows, the toggle's aria-expanded follows, and
# the empty state is visible (the re-open refresh fetched 0 notes).
page.click("#steering-toggle")
expect(page.locator("#steering-panel")).to_be_visible()
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "true")
expect(page.locator("#steering-empty")).to_be_visible()
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
expect(page.locator("#steering-count")).to_have_text("0")
# Close: the cycle completes, the count badge still reads 0.
page.click("#steering-toggle")
expect(page.locator("#steering-panel")).to_be_hidden()
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "false")
expect(page.locator("#steering-count")).to_have_text("0")
# ---------------------------------------------------------------------------
# 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_tuning_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)
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)
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.