Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
625 lines
28 KiB
Python
625 lines
28 KiB
Python
"""Phase 34 story E2E (Playwright): ONE navbar on every page.
|
||
|
||
Story: ``.agents/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``
|
||
7. ``test_viewer_nav_click_full_loads_the_shell_rag_view`` (phase 76
|
||
task 04 — the header is shell-owned: the surviving standalone
|
||
documents keep their header copies, and a navbar click on one is a
|
||
REAL departure that full-loads the shell, whose router renders the
|
||
target view from the pathname)
|
||
|
||
Phase 76 adaptation (tasks 01–03): chat / sources / tuning are VIEWS of
|
||
ONE shell document (index.html) — the header under test on those URLs is
|
||
the shell's single one (the router deep-links the view from the
|
||
pathname on each real goto). The per-URL inventory comparison is the
|
||
pre-phase-76 probe, kept verbatim; test 7 adds the surviving-document
|
||
side of the ownership boundary (the viewer's own header copy).
|
||
"""
|
||
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
|
||
<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 viewer has settled, so the bar is measured on the real
|
||
# page. Phase 79 (task 05): content is require_user-gated —
|
||
# admin settles on the title; anonymous settles on the inline
|
||
# token gate (#doc-auth-gate — the content fetch never runs,
|
||
# the title stays "Loading…").
|
||
if admin:
|
||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||
else:
|
||
expect(page.locator("#doc-auth-gate")).to_be_visible(timeout=15_000)
|
||
expect(page.locator("#doc-title")).to_have_text("Loading…")
|
||
|
||
# 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 (now "Sources",
|
||
# shipped as "Git sources") is revealed on every page, between
|
||
# RAG and Tuning.
|
||
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||
expect(page.locator("#nav-tuning")).to_be_visible()
|
||
# Phase 50: the FIFTH admin-only nav link (History) is revealed
|
||
# on every page, after Tuning.
|
||
expect(page.locator("#nav-history")).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"
|
||
)
|
||
# The Sync button is a view-specific control (RAG view only —
|
||
# owner rework 2026-08-28): visible on sources, not visible
|
||
# anywhere else. Phase 76 (task 02): in the shell the RAG view
|
||
# (with the button) is in the DOM on every view — hidden +
|
||
# inert — so the pin is VISIBLE, not ABSENT (to_be_hidden()
|
||
# also passes on standalone pages where the button is absent).
|
||
if name == "sources":
|
||
expect(page.locator("#sync-btn")).to_be_visible()
|
||
else:
|
||
expect(page.locator("#sync-btn")).to_be_hidden()
|
||
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()
|
||
# Phase 50: the History link ships hidden and stays hidden for
|
||
# anonymous (the same A10 UI revision).
|
||
expect(page.locator("#nav-history")).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"
|
||
)
|
||
# The New chat button is chat-view only (moved from the shared bar
|
||
# to the shell's .chat-shell at owner request, 2026-08-28).
|
||
# Phase 76 (task 02): in the shell the chat view (with the button)
|
||
# is in the DOM on every view — hidden + inert — so the pin is
|
||
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
|
||
# pages where the button is absent).
|
||
if name == "chat":
|
||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||
else:
|
||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||
|
||
_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()
|
||
# Phase 50: the History link joins the admin bar on the login
|
||
# page too (the one-bar contract).
|
||
expect(page.locator("#nav-history")).to_be_visible()
|
||
assert page.locator("#steering-toggle").count() == 0, (
|
||
"login: the steering toggle was removed from the navbar"
|
||
)
|
||
# Page-specific controls are NOT on the auth page (owner rework
|
||
# 2026-08-28: sync → Sources page, new chat → chat page).
|
||
assert page.locator("#sync-btn").count() == 0
|
||
assert page.locator("#new-chat-btn").count() == 0
|
||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||
_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)
|
||
login(page, app_url, next="/") # phase 79: the viewer's title needs the content
|
||
|
||
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)
|
||
login(page, app_url, next="/") # phase 79: the viewer's title needs the content
|
||
|
||
# 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_sources_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)
|
||
|
||
# The button's home is the Sources page (owner rework 2026-08-28 —
|
||
# it left the shared navbar).
|
||
login(page, app_url, next=SOURCES_URL)
|
||
expect(page).to_have_url(app_url + SOURCES_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.
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 7. Phase 76 task 04: the surviving documents keep their header copies —
|
||
# a navbar click on one is a REAL departure that full-loads the shell
|
||
# (whose router then renders the target view from the pathname)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_viewer_nav_click_full_loads_the_shell_rag_view(
|
||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||
) -> None:
|
||
"""Phase 76 task 04: the document viewer is one of the four surviving
|
||
standalone documents (login, shared, doc-edit, document) — it keeps
|
||
its own header copy, and the router does NOT run there (the router
|
||
lives in the shell). Clicking its RAG nav link is therefore a REAL,
|
||
document-level navigation — not the shell's pushState switch: it
|
||
full-loads the shell at /sources.html, and the shell's router renders
|
||
the RAG view from the pathname (the Chat view ships hidden + inert
|
||
inside that same document).
|
||
|
||
The window sentinel proves the departure in the phase-76 canonical
|
||
form, used in INVERSE: it is set in the viewer document and must be
|
||
GONE after the click (a real load wipes window globals — exactly
|
||
what distinguishes a departure from the shell's same-document
|
||
switches, where the sentinel survives)."""
|
||
page.set_viewport_size({"width": 1280, "height": 800})
|
||
_seed_db(mock_llm)
|
||
|
||
# Admin: the viewer's RAG nav link is admin-only (revealed by the
|
||
# whoami pass of its own header copy).
|
||
login(page, app_url, next=CHAT_URL)
|
||
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
||
page.goto(app_url + VIEWER_URL)
|
||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||
_wait_settled(page, admin=True)
|
||
expect(page.locator("#nav-sources")).to_be_visible()
|
||
|
||
# The sentinel lives in the VIEWER document only.
|
||
page.evaluate("() => { window.__phase76_viewer = 'phase76'; }")
|
||
page.click("#nav-sources")
|
||
|
||
# The arrival is the SHELL at the RAG view's URL: the document loaded
|
||
# for real (the sentinel is gone), the RAG view is rendered (first
|
||
# table row visible), the Chat view is hidden AND inert in the same
|
||
# document, and the RAG link carries the router's single-writer
|
||
# active stamp.
|
||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||
assert page.evaluate("() => window.__phase76_viewer") is None, (
|
||
"a surviving document's nav click must be a real departure "
|
||
"(a fresh document load wipes window globals)"
|
||
)
|
||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||
expect(page.locator("#view-chat")).to_be_hidden()
|
||
assert page.evaluate("() => document.getElementById('view-chat').inert") is True, (
|
||
"the chat view ships hidden AND inert in the shell"
|
||
)
|
||
expect(page.locator("#nav-sources")).to_have_class(re.compile(r"\bis-active\b"))
|