"""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
, the labeled