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