Files
brain-of-reese/tests/e2e/test_shared_header.py
T
ducoterra 872a07cee7 feat(ui): rename nav items — "Sources" becomes "RAG", "Git sources" becomes "Sources"
Owner request (2026-08-28): the two admin-only nav items read like the
same thing, so they are relabeled — the document-catalog link
(#nav-sources, /sources.html) becomes "RAG" and the source-manager link
(#nav-git-sources, /git-sources.html) becomes "Sources".

Phase 48 (48_nav_rename_sources), label-only per the locked decision:
- all six pages (index, sources, git-sources, tuning, document, login):
  the two <a> texts swap; ids, hrefs, hidden defaults, is-active /
  aria-current placement, and nav order (Chat, RAG, Sources, Tuning)
  are byte-unchanged otherwise.
- header.js: comment/docstring label mentions only — the reveal-by-id
  logic is untouched (ship-hidden/reveal contract intact).
- test_git_sources_admin.py / test_mobile_hamburger_nav.py: the two
  suites that asserted the old label text are updated; comment-only
  label fixes in test_shared_header.py / test_nav_consistency.py.
- tests/e2e/test_nav_rename_sources.py: the story E2E (green in
  isolation) — renamed labels + unchanged hrefs/order/markers on all
  six pages, click navigation with the active marker, the anonymous
  ship-hidden contract, and regression guards for the untouched
  controls (#sync-label "Sync sources", viewer #doc-back "Sources").
- All eight surrounding header/nav suites stay green in isolation;
  unit+integration green, app/ coverage 99% (frontend-only change),
  ruff + pyright clean.

Note: per this phase file-level staging, the six page files and
header.js also carry the same-day in-flight owner rework that was
already in the working tree when phase 48 ran (mobile sign-in dropdown
copy, sync button ship-hidden on the Sources page); the label rename
itself is the two-text swap on each page.
2026-08-28 12:33:18 -04:00

391 lines
16 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 19 E2E (Playwright): the shared header bar on every page.
Story: ``.agent/user_stories/shared-header.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_shared_header.py -v --no-cov
Contract under test (owner report 2026-08-23, phase 19) — ONE bar per
page, the same controls everywhere:
* chat / sources / viewer: brand + nav [Chat, RAG — admin only, + the
"Sources" link (phase 35, shipped as "Git sources")] + Sign in /
Sign out. The New Chat button is chat-page only — it left the shared
bar at owner request (2026-08-28, moved to index.html's .chat-shell);
* document viewer: the standard bar (row 1) + back + title + meta in a
second titlebar row (phase 34, owner confirmation 2026-08-26 — the
viewer's old "no nav" single-row bar is superseded; it now carries
the SAME nav contract as every other page);
* the "RAG" nav link (``#nav-sources``) is HIDDEN for anonymous
users on every page and shown for admin (phase-16 UX revision with
owner permission; the soft-gate page and the A10 API split are
untouched) — now on the viewer as well (phase 34);
* the bar height never moves: 64px desktop / 58px at ≤640px (phase-12
``--header-h`` contract, bounding-box measurement convention) — on
the viewer this is ROW 1 (``.doc-header .app-header``); the
titlebar row is content-sized.
Determinism note: every assertion is settled-state — ``assert_shared_bar``
first waits for the whoami toggle to land (exactly one of Sign in /
Sign out visible), and the viewer waits for the document to render. No
streaming is involved in this story: the chat page is opened at most for
its header; no turn is ever submitted.
Test → story mapping (Playwright Mapping Rule):
1. ``test_anonymous_bar_on_all_pages``
2. ``test_admin_bar_on_all_pages``
3. ``test_sources_nav_hidden_for_anonymous_everywhere``
4. ``test_new_chat_button_is_chat_page_only``
5. ``test_sign_out_from_viewer_returns_to_anonymous``
6. ``test_mobile_bar_fits_and_heights_held``
"""
from __future__ import annotations
import asyncio
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"
#: 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"
SOURCES_URL = "/sources.html"
DOC_TITLE = "Kubernetes Homelab Cluster"
#: The shared header-bar token values (frontend/assets/styles.css :root
#: and the ≤640px media query) — phase 12, pinned here as a regression.
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 with the fixture docs (needed for the viewer URL and the
admin sources catalog)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
_run_in_thread(_import_fixtures(mock_port))
# ---------------------------------------------------------------------------
# The heart of the suite: one helper, the full shared-bar contract
# ---------------------------------------------------------------------------
def _expected_h(page: Page) -> int:
"""The phase-12 bar height for the current viewport (≤640 → 58)."""
viewport = page.viewport_size
assert viewport is not None, "every test here sets an explicit viewport"
return MOBILE_HEADER_H if viewport["width"] <= 640 else DESKTOP_HEADER_H
def _bar_selector(page_kind: str) -> str:
"""The STANDARD bar element on each page kind. Phase 34: the
viewer's header is two rows — the height contract applies to row 1
(the standard bar), not the whole two-row <header>."""
return ".doc-header .app-header" if page_kind == "viewer" else ".app-header"
def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = False) -> None:
"""Assert the phase-19 shared-bar contract on the page the ``page``
is already showing.
``page_kind`` is ``"chat"``, ``"sources"``, or ``"viewer"``. The
helper waits for the SETTLED state — both auth controls ship hidden
in the HTML, so "exactly one is visible" means /api/whoami resolved
and header.js (``initSharedHeader``) did its toggle — before any
assertion runs.
``mobile`` (phase 46, owner permission 2026-08-27, ``TODO.md`` L9):
at ≤640px the nav links no longer sit inline — the bar carries the
44px ``#nav-toggle`` hamburger and the nav ships as the CLOSED
(invisible) dropdown. Per-role link visibility INSIDE the menu is
pinned by ``test_mobile_hamburger_nav.py`` (phase 46, task 03); this
helper pins the bar-level contract only.
"""
# Settled auth state: exactly one of Sign in / Sign out is
# revealed (phase-16 semantics, now owned by the shared module).
# Both probes are viewport-independent attribute checks — at ≤640px
# the bar auth copies are CSS-hidden behind the #sign-in-link-mobile
# / #sign-out-btn-mobile dropdown copies (phase-46 UX revision), so
# visibility is not a cross-viewport probe.
if admin:
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=15_000,
)
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
page.wait_for_function(
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
timeout=15_000,
)
expect(page.locator("#sign-out-btn")).to_be_hidden()
# The New chat button is chat-page only (moved from the shared bar
# to index.html's .chat-shell at owner request, 2026-08-28).
if page_kind == "chat":
expect(page.locator("#new-chat-btn")).to_be_visible()
else:
assert page.locator("#new-chat-btn").count() == 0, (
f"{page_kind}: the New chat button is chat-page only"
)
# Phase 34: EVERY page kind — viewer included — carries the SAME
# nav contract: the Chat link always visible; the admin-only
# #nav-sources / #nav-tuning links (phase 19 / phase 29) ship
# hidden and are revealed for admin (phase-16 UX revision, owner
# permission 2026-08-23; the soft-gate page and the A10 API split
# are untouched).
#
# Phase 46 (owner permission 2026-08-27, ``TODO.md`` L9): at ≤640px
# the links live in the #nav-toggle dropdown instead — the bar
# shows the hamburger and the nav is the closed (invisible +
# non-interactive) panel; the per-role link visibility inside the
# menu is pinned by test_mobile_hamburger_nav.py (phase 46 task 03).
if mobile:
expect(page.locator("#nav-toggle")).to_be_visible()
expect(page.locator("#app-nav")).to_be_hidden()
else:
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
for link_id in ("#nav-sources", "#nav-tuning"):
nav = page.locator(link_id)
assert nav.count() == 1, f"one {link_id} expected on the {page_kind} page"
if admin:
expect(nav).to_be_visible()
else:
expect(nav).to_be_hidden()
if page_kind == "viewer":
# The document itself has settled (rendered, not Loading…/not-found)
# so the bar is being measured on the real page.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
# The bar height never moves: 64px desktop / 58px ≤640px (phase 12),
# bounding-box measurement — the new pills must fit inside it.
box = page.locator(_bar_selector(page_kind)).bounding_box()
assert box is not None, f"{_bar_selector(page_kind)} not rendered"
assert box["height"] == _expected_h(page), (
f"{page_kind} bar is {box['height']}px, expected {_expected_h(page)}px"
)
def _assert_no_overflow(page: Page, label: str) -> None:
"""No horizontal page overflow (the responsive-polish convention)."""
scroll, client = page.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
assert scroll <= client, f"horizontal overflow on {label}: {scroll} > {client}"
# ---------------------------------------------------------------------------
# 1. Anonymous: the bar exists on all three pages, in the anonymous state
# ---------------------------------------------------------------------------
def test_anonymous_bar_on_all_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)
page.goto(app_url + "/")
assert_shared_bar(page, admin=False, page_kind="chat")
page.goto(app_url + SOURCES_URL)
# Phase 16's soft gate is unchanged for direct-URL visitors — the
# bar above it is what this suite pins.
expect(page.locator("#sources-gate")).to_be_visible()
assert_shared_bar(page, admin=False, page_kind="sources")
page.goto(app_url + VIEWER_URL)
assert_shared_bar(page, admin=False, page_kind="viewer")
# ---------------------------------------------------------------------------
# 2. Admin: the bar on all three pages flips to the signed-in state
# ---------------------------------------------------------------------------
def test_admin_bar_on_all_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)
# Real form login with next=/ — the phase-16 redirect flow still
# lands the admin on the chat page.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
assert_shared_bar(page, admin=True, page_kind="chat")
page.goto(app_url + SOURCES_URL)
expect(page.locator("#sources-gate")).to_be_hidden()
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
assert_shared_bar(page, admin=True, page_kind="sources")
page.goto(app_url + VIEWER_URL)
assert_shared_bar(page, admin=True, page_kind="viewer")
# ---------------------------------------------------------------------------
# 3. The RAG nav link: hidden for anonymous everywhere, revealed
# after a real login (a toggle, not just initial state)
# ---------------------------------------------------------------------------
def test_sources_nav_hidden_for_anonymous_everywhere(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
for path in ("/", SOURCES_URL):
page.goto(app_url + path)
# Settled anonymous state, then the nav-link contract.
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
nav = page.locator("#nav-sources")
assert nav.count() == 1
expect(nav).to_be_hidden()
# The login page carries the full shared header too (phase 34);
# for anonymous the admin-only nav links stay hidden (they ship
# hidden and are revealed only for the admin).
page.goto(app_url + "/login.html")
page.wait_for_load_state("networkidle") # the whoami round-trip has settled
nav = page.locator("#nav-sources")
assert nav.count() == 1
expect(nav).to_be_hidden()
# And the toggle works, not just the initial state: after a real
# form login on the chat page the link appears.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#nav-sources")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. The New chat button is chat-page only (owner rework 2026-08-28 —
# it left the shared bar with the 'go to the chat, fresh' behavior)
# ---------------------------------------------------------------------------
def test_new_chat_button_is_chat_page_only(
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 non-chat page carries the button anymore…
for path in (SOURCES_URL, VIEWER_URL, "/tuning.html", "/login.html", "/git-sources.html"):
page.goto(app_url + path)
expect(page.locator("#new-chat-btn")).to_have_count(0)
# …and the chat page has exactly one (visible, inside .chat-shell).
page.goto(app_url + "/")
expect(page.locator("#new-chat-btn")).to_have_count(1)
expect(page.locator("#new-chat-btn")).to_be_visible()
# ---------------------------------------------------------------------------
# 5. Sign out from the viewer: the same page comes back anonymous
# ---------------------------------------------------------------------------
def test_sign_out_from_viewer_returns_to_anonymous(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# Log in with next=/sources.html — lands on the admin sources bar…
login(page, app_url, next="/sources.html")
expect(page).to_have_url(app_url + "/sources.html")
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
# …and open the viewer directly: the admin bar is there too.
page.goto(app_url + VIEWER_URL)
assert_shared_bar(page, admin=True, page_kind="viewer")
# Sign out from the viewer: header.js POSTs /api/logout and reloads;
# after the reload the same page shows the anonymous bar.
page.click("#sign-out-btn")
assert_shared_bar(page, admin=False, page_kind="viewer")
expect(page).to_have_url(app_url + VIEWER_URL)
# ---------------------------------------------------------------------------
# 6. Mobile (375×812): 58px bars, no horizontal overflow, in BOTH auth
# states — the new pills never grow the bar
#
# Phase 46 adaptation (owner permission 2026-08-27, ``TODO.md`` L9):
# at ≤640px the nav links leave the bar — the hamburger (#nav-toggle)
# is visible and the nav is the closed dropdown; the per-role link
# visibility inside the menu is pinned by
# test_mobile_hamburger_nav.py (phase 46, task 03).
# ---------------------------------------------------------------------------
def test_mobile_bar_fits_and_heights_held(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 375, "height": 812})
_seed_db(mock_llm)
def check_all(admin: bool) -> None:
for kind, path in (
("chat", "/"),
("sources", SOURCES_URL),
("viewer", VIEWER_URL),
):
page.goto(app_url + path)
# 58px at 375px is asserted inside assert_shared_bar…
assert_shared_bar(page, admin=admin, page_kind=kind, mobile=True)
# …and the pills (icon-only at ≤640px) + the hamburger fit
# without overflow.
_assert_no_overflow(page, f"{kind} @375px (admin={admin})")
# Anonymous: the two icon pills are Sign in + New chat.
check_all(admin=False)
# Signed in: Sign out joins the bars (the admin-only nav links join
# the MENU, not the bar — phase 46) — and the bar never grows.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
check_all(admin=True)