Files
brain-of-reese/tests/e2e/test_nav_consistency.py
T
2026-08-28 09:42:19 -04:00

504 lines
22 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-git-sources, #nav-tuning]
(four links, that order — the Git sources link joined in phase 35,
owner permission 2026-08-26) + #sync-btn + #new-chat-btn +
#sign-out-btn (with #sign-in-link hidden) — on all five pages, same
id+class inventory, same DOM order. The #steering-toggle was removed
from the navbar at owner request (2026-08-28); note management lives
on /tuning.html;
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-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 (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 /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 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_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",
"#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 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 (Git sources) is
# revealed on every page, between Sources and Tuning.
expect(page.locator("#nav-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).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"
)
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-git-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 (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"
)
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-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
assert page.locator("#steering-toggle").count() == 0, (
"login: the steering toggle was removed from the navbar"
)
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 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_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.