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

374 lines
15 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, Sources — admin only] +
New Chat + Sign in / Sign out;
* 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 "Sources" 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_from_sources_clears_and_navigates``
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) -> 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.
"""
# Settled auth state: exactly one of Sign in / Sign out is visible
# (phase-16 semantics, now owned by the shared module).
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()
# New Chat is on the bar on every page kind (the owner's ask).
expect(page.locator("#new-chat-btn")).to_be_visible()
# 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).
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 Sources 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. New Chat from a non-chat page: clear the conversation, land on the
# chat empty state
# ---------------------------------------------------------------------------
def test_new_chat_from_sources_clears_and_navigates(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
# Seed the phase-14 conversation before any page script runs. The
# init script runs on EVERY navigation, so it is scoped to the
# sources page — the post-click navigation to "/" must start clean.
page.add_init_script(
"""(() => {
if (location.pathname !== "/sources.html") return;
try {
localStorage.setItem("bor.chat.v1", JSON.stringify({
v: 1,
messages: [
{ who: "user", text: "hello brain" },
{ who: "brain", text: "hey there" }
]
}));
} catch {}
})();"""
)
page.goto(app_url + SOURCES_URL)
# The seeded conversation is in storage…
assert (
page.evaluate("() => localStorage.getItem('bor.chat.v1')") is not None
), "init script must have seeded the phase-14 conversation key"
# New Chat from the sources page: a new chat means going to the
# chat — fresh.
page.click("#new-chat-btn")
expect(page).to_have_url(app_url + "/", timeout=30_000)
# …and the chat lands on its empty state with the key removed.
expect(page.locator("#empty-state")).to_be_visible()
expect(page.locator(".msg")).to_have_count(0)
assert page.evaluate("() => localStorage.getItem('bor.chat.v1')") is None, (
"New Chat from a non-chat page must clear the localStorage key"
)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
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)
# …and the pills (icon-only at ≤640px) 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 + the Sources nav link join the bars — and the
# bar never grows.
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/")
check_all(admin=True)