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.
This commit is contained in:
2026-08-28 12:33:18 -04:00
parent 03bead092c
commit 872a07cee7
17 changed files with 785 additions and 118 deletions
+3 -3
View File
@@ -82,7 +82,7 @@ REPO = Path(__file__).resolve().parents[2]
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The five pages that ship the header (phase 34 contract) — the pages
#: task 05 gave the admin-only "Git sources" nav link.
#: task 05 gave the admin-only "Sources" nav link (then "Git sources").
CHAT_URL = "/"
SOURCES_URL = "/sources.html"
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
@@ -307,7 +307,7 @@ def test_admin_nav_link_on_all_five_pages_and_click_navigates(
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
# The link is revealed (admin) on every one of the five pages,
# pointing at the git sources page, labeled "Git sources" — and it
# pointing at the git sources page, labeled "Sources" — and it
# is NOT the current page on the four non-git-sources pages.
for _name, path in FIVE_PAGES:
if path != CHAT_URL:
@@ -316,7 +316,7 @@ def test_admin_nav_link_on_all_five_pages_and_click_navigates(
link = page.locator("#nav-git-sources")
expect(link).to_be_visible(timeout=15_000)
expect(link).to_have_attribute("href", GIT_SOURCES_URL)
expect(link).to_have_text("Git sources")
expect(link).to_have_text("Sources")
if path != GIT_SOURCES_URL:
expect(link).not_to_have_class(IS_ACTIVE)
+19 -10
View File
@@ -32,7 +32,7 @@ Test → story mapping (Playwright Mapping Rule):
3. ``test_admin_menu_contents`` — admin at 375px: the menu shows all
four links (the whoami reveal works inside the menu).
4. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking
"Sources" navigates to /sources.html and the menu on the arrival
"RAG" navigates to /sources.html and the menu on the arrival
page ships closed.
5. ``test_esc_and_outside_close`` — Esc closes AND returns focus to the
toggle; an outside click does NOT close (accepted — see the test
@@ -58,7 +58,7 @@ MOBILE: ViewportSize = {"width": 375, "height": 812} # the story's phone viewpo
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
NAV_LINKS = ("#app-nav a[href='/']", "#nav-sources", "#nav-git-sources", "#nav-tuning")
LINK_TEXTS = ("Chat", "Sources", "Git sources", "Tuning")
LINK_TEXTS = ("Chat", "RAG", "Sources", "Tuning")
def _mobile_page(browser: Browser) -> Page:
@@ -67,16 +67,25 @@ def _mobile_page(browser: Browser) -> Page:
def _wait_settled_anonymous(page: Page) -> None:
"""Wait until whoami has resolved for the anonymous visitor (Sign in
visible — the phase-16 settled state the header pins)."""
expect(page.locator("#sign-in-link")).to_be_visible(timeout=10_000)
"""Wait until whoami has resolved for the anonymous visitor: the bar
Sign in copy (``#sign-in-link``) loses its ship-hidden attribute —
the phase-16 settled state, probed by attribute (not visibility):
at ≤640px the bar copy is CSS-hidden behind the ``#sign-in-link-mobile``
dropdown copy (phase 46), so visibility is viewport-dependent."""
page.wait_for_function(
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
timeout=10_000,
)
def _wait_settled_admin(page: Page) -> None:
"""Wait until whoami has resolved for the admin (Sign out visible)
AND the whoami reveal has un-hidden the admin-only nav links (the
menu-contents assertions must run on a settled auth state)."""
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=10_000)
"""Wait until whoami has resolved for the admin: the whoami reveal
has un-hidden the admin-only nav links (the menu-contents assertions
must run on a settled auth state). The nav link is the
viewport-independent settled signal — the sign-out control is the
bar copy on desktop but the #sign-out-btn-mobile dropdown copy at
≤640px (phase-46 UX revision), so it is not a cross-viewport
probe."""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
@@ -205,7 +214,7 @@ def test_admin_menu_contents(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC2 (admin): at 375px the opened menu shows ALL FOUR links —
Chat / Sources / Git sources / Tuning — i.e. the whoami reveal
Chat / RAG / Sources / Tuning — i.e. the whoami reveal
works inside the menu exactly as it does inline (one <nav>, one
set of links, the same hidden attributes header.js drives)."""
page = _mobile_page(browser)
+54 -26
View File
@@ -19,19 +19,22 @@ sources, document viewer, global tuning, login): one shared markup block
Per role, the VISIBLE inventory:
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning]
(four links, that order — the Git sources link joined in phase 35,
owner permission 2026-08-26) + #sync-btn + #new-chat-btn +
#sign-out-btn (with #sign-in-link hidden) — on all five pages, same
id+class inventory, same DOM order. The #steering-toggle was removed
from the navbar at owner request (2026-08-28); note management lives
on /tuning.html;
(four links, that order — the "Sources" link joined in phase 35 as
"Git sources", owner permission 2026-08-26) + #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 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 (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).
#nav-tuning 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,
@@ -47,9 +50,10 @@ 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 /tuning.html but is
never clicked here (a real sync clones real repos — the full state
machine is test_sync_button.py's job).
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 /
@@ -65,7 +69,7 @@ Test → story mapping (Playwright Mapping Rule):
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_tuning_page_without_triggering``
6. ``test_sync_button_present_on_sources_page_without_triggering``
"""
from __future__ import annotations
@@ -177,7 +181,11 @@ def _header_inventory(page: Page) -> list[str]:
``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}"
# 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
@@ -220,8 +228,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
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 (Git sources) is
# revealed on every page, between Sources and Tuning.
# 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()
# The steering toggle was removed from the navbar at owner
@@ -229,7 +238,15 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
assert page.locator("#steering-toggle").count() == 0, (
f"{name}: the steering toggle was removed from the navbar"
)
expect(page.locator("#sync-btn")).to_be_visible()
# The Sync button is a page-specific control (Sources page only
# — owner rework 2026-08-28): visible on sources, absent from
# the shared bar everywhere else.
if name == "sources":
expect(page.locator("#sync-btn")).to_be_visible()
else:
assert page.locator("#sync-btn").count() == 0, (
f"{name}: #sync-btn left the shared bar (Sources page only)"
)
expect(page.locator("#sign-out-btn")).to_be_visible()
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
@@ -252,7 +269,14 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
assert page.locator("#steering-panel").count() == 0, (
f"{name}: the steering panel must be absent for anonymous"
)
expect(page.locator("#new-chat-btn")).to_be_visible()
# 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 name == "chat":
expect(page.locator("#new-chat-btn")).to_be_visible()
else:
assert page.locator("#new-chat-btn").count() == 0, (
f"{name}: #new-chat-btn left the shared bar (chat page only)"
)
_assert_landmarks(page, name)
return _header_inventory(page)
@@ -302,10 +326,12 @@ def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
assert page.locator("#steering-toggle").count() == 0, (
"login: the steering toggle was removed from the navbar"
)
expect(page.locator("#sync-btn")).to_be_visible()
# 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()
expect(page.locator("#new-chat-btn")).to_be_visible()
_assert_landmarks(page, "login")
return _header_inventory(page)
finally:
@@ -482,14 +508,16 @@ def test_steering_surface_off_chat_on_tuning_page(
# ---------------------------------------------------------------------------
def test_sync_button_present_on_tuning_page_without_triggering(
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)
login(page, app_url, next=TUNING_URL)
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
# 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")
+400
View File
@@ -0,0 +1,400 @@
"""Phase 48 story E2E (Playwright): the nav rename — "Sources" becomes
"RAG", "Git sources" becomes "Sources".
Story: ``.agent/user_stories/nav-sources-rag-rename.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_nav_rename_sources.py -v --no-cov
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"**. Everything else is UNCHANGED (the phase's locked
decision, label-only): element ids, hrefs, the physical nav order, the
phase-16/19/35 ship-hidden/reveal contract, ``header.js`` behavior, and
every other label on the pages — the document viewer's "Sources" back
button (a different control, phase 13) and the "Sync sources" button
(phase 32) in particular.
The six pages under test (all carry the one shared header, phase
19/34): chat (/), the RAG catalog (/sources.html), the Sources manager
(/git-sources.html), Tuning (/tuning.html), the login page
(/login.html), and the document viewer (/document.html — seeded with
one fixture document row first, the test_nav_consistency.py viewer
pattern; #doc-title must settle before any bar assertion).
Contract under test (desktop viewport 1280×800, settled whoami state —
every assertion waits for the initSharedHeader pass to land first):
* admin: on EACH of the six pages ``#nav-sources`` is visible with the
exact text "RAG" and href /sources.html, ``#nav-git-sources`` is
visible with the exact text "Sources" and href /git-sources.html,
and the nav DOM order reads Chat, RAG, Sources, Tuning; the
current page's link is the ONLY one carrying is-active +
aria-current="page" (the login and viewer pages mark none —
neither is a nav page).
* from the chat page: clicking "RAG" (``#nav-sources``) lands on
/sources.html with that link active; clicking "Sources"
(``#nav-git-sources``) lands on /git-sources.html with that link
active.
* anonymous: on / and /login.html both links are PRESENT in the DOM
(the ship-hidden contract — header.js toggles the hidden attribute,
the markup is never removed) but hidden, and #sign-in-link is
visible.
* the rename did not leak: on /sources.html the Sync button still
reads "Sync sources" (``#sync-label``), and on the settled viewer
page the back button's span still reads "Sources" (href
/sources.html).
Determinism note: the seed truncates documents/chunks/query_log/
steering_notes and re-imports the fixture docs (mock embeddings) so the
viewer URL resolves to "Kubernetes Homelab Cluster" on every run. No
chat turn is submitted and #sync-btn is never clicked.
Test → story mapping (Playwright Mapping Rule):
1. ``test_admin_labels_on_all_six_pages``
2. ``test_click_navigates_with_marker``
3. ``test_anonymous_sees_neither``
4. ``test_untouched_controls_stay``
"""
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"
CHAT_URL = "/"
SOURCES_URL = "/sources.html"
GIT_SOURCES_URL = "/git-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 six pages of the app, in the story's order.
SIX_PAGES = (
("chat", CHAT_URL),
("sources", SOURCES_URL),
("git-sources", GIT_SOURCES_URL),
("tuning", TUNING_URL),
("login", LOGIN_URL),
("viewer", VIEWER_URL),
)
#: The locator of the link that carries the current-page marker on each
#: page (None — login and viewer are not nav pages — marks none).
CURRENT_LINK: dict[str, str | None] = {
"chat": ".app-nav a[href='/']",
"sources": "#nav-sources",
"git-sources": "#nav-git-sources",
"tuning": "#nav-tuning",
"login": None,
"viewer": None,
}
#: The four primary nav links, in their physical DOM order — the labels
#: after the phase-48 swap (ids/hrefs unchanged).
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
#: The login.js script — route pattern for the redirect suppression.
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
#: is-active as a word-boundary regex (to_have_class matches against the
#: whole class string — the test_git_sources_admin.py convention).
IS_ACTIVE = re.compile(r"\bis-active\b")
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 + the fixture docs so the viewer URL resolves (the
test_nav_consistency.py seeding pattern)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
_run_in_thread(_import_fixtures(mock_port))
def _wait_settled_admin(page: Page) -> None:
"""Wait for initSharedHeader's whoami toggle to land for a signed-in
admin: the whoami reveal has un-hidden the admin-only nav links. The
nav link is the viewport-independent settled signal (the auth pair is
the bar copy on desktop but the dropdown copy at ≤640px, phase 46) —
and the sign-in/out state settles in the SAME initSharedHeader pass.
"""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=15_000,
)
def _wait_settled_anonymous(page: Page) -> None:
"""Wait for the whoami toggle to land for an anonymous visitor: the
bar Sign in copy (``#sign-in-link``) loses its ship-hidden attribute
(probed by attribute — at ≤640px the bar copy is CSS-hidden behind
the dropdown copy, phase 46)."""
page.wait_for_function(
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
timeout=15_000,
)
def _assert_renamed_labels(page: Page, name: str) -> None:
"""AC1 on one page: the swapped labels, the unchanged hrefs, and the
nav DOM order (Chat, RAG, Sources, Tuning) on a settled admin bar."""
rag = page.locator("#nav-sources")
expect(rag).to_be_visible(timeout=15_000)
expect(rag).to_have_text("RAG")
expect(rag).to_have_attribute("href", "/sources.html")
git = page.locator("#nav-git-sources")
expect(git).to_be_visible(timeout=15_000)
expect(git).to_have_text("Sources")
expect(git).to_have_attribute("href", "/git-sources.html")
# The four primary nav links (class nav-link) in physical DOM order.
nav_texts = page.eval_on_selector_all(
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
)
assert nav_texts == list(NAV_LABELS), (
f"{name}: nav link order/labels are {nav_texts}, expected {list(NAV_LABELS)}"
)
# …and the full anchor sequence of the nav (it also carries the
# phase-46 mobile sign-in copy) opens with the same four, in order.
all_texts = page.eval_on_selector_all(
".app-nav a", "els => els.map(e => e.textContent.trim())"
)
assert all_texts[:4] == list(NAV_LABELS), (
f"{name}: .app-nav anchor sequence {all_texts} does not open with "
f"Chat, RAG, Sources, Tuning"
)
def _assert_current_marker(page: Page, name: str) -> None:
"""AC1 on one page: the current page's link carries is-active +
aria-current="page" — and ONLY it does (login/viewer mark none)."""
current = CURRENT_LINK[name]
if current is None:
assert page.locator(".app-nav a.is-active").count() == 0, (
f"{name}: no nav page is current — no link may carry is-active"
)
assert page.locator('.app-nav a[aria-current="page"]').count() == 0, (
f"{name}: no nav page is current — no link may carry aria-current"
)
return
expect(page.locator(current)).to_have_class(IS_ACTIVE)
expect(page.locator(current)).to_have_attribute("aria-current", "page")
assert page.locator(".app-nav a.is-active").count() == 1, (
f"{name}: exactly one nav link may carry is-active"
)
assert page.locator('.app-nav a[aria-current="page"]').count() == 1, (
f"{name}: exactly one nav link may carry aria-current"
)
def _visit_admin_page(page: Page, app_url: str, name: str, url: str) -> None:
"""Goto a page as the signed-in admin, wait for the settled header
(and the document title on the viewer), assert the rename contract."""
page.goto(app_url + url)
_wait_settled_admin(page)
if name == "viewer":
# The document itself has settled (rendered, not Loading…/
# not-found) before any bar assertion — the test_nav_consistency
# viewer-pass pattern.
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
_assert_renamed_labels(page, name)
_assert_current_marker(page, name)
def _visit_login_as_admin(page: Page, app_url: str) -> None:
"""The login page redirects a signed-in admin away (login.js —
phase 16), so this ONE visit serves login.js with the redirect lines
suppressed (a test-local route, the test_nav_consistency.py pattern;
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)
_visit_admin_page(page, app_url, "login", LOGIN_URL)
finally:
page.unroute(LOGIN_JS_ROUTE)
# ---------------------------------------------------------------------------
# 1. Admin: the swapped labels, unchanged hrefs/order/markers, on all
# six pages
# ---------------------------------------------------------------------------
def test_admin_labels_on_all_six_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)
for name, url in SIX_PAGES:
if name == "login":
_visit_login_as_admin(page, app_url)
else:
_visit_admin_page(page, app_url, name, url)
# ---------------------------------------------------------------------------
# 2. The renamed links navigate: "RAG" → /sources.html (active),
# "Sources" → /git-sources.html (active)
# ---------------------------------------------------------------------------
def test_click_navigates_with_marker(
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)
_wait_settled_admin(page)
# "RAG" (the renamed catalog label) → the RAG catalog page, where
# #nav-sources is the active link.
expect(page.locator("#nav-sources")).to_have_text("RAG")
page.click("#nav-sources")
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
_wait_settled_admin(page)
_assert_current_marker(page, "sources")
# "Sources" (the renamed manager label) → the Sources manager page,
# where #nav-git-sources is the active link.
page.goto(app_url + CHAT_URL)
_wait_settled_admin(page)
expect(page.locator("#nav-git-sources")).to_have_text("Sources")
page.click("#nav-git-sources")
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
_wait_settled_admin(page)
_assert_current_marker(page, "git-sources")
# ---------------------------------------------------------------------------
# 3. Anonymous: both links present in the DOM (ship-hidden contract) but
# hidden — #sign-in-link visible
# ---------------------------------------------------------------------------
def test_anonymous_sees_neither(
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.
for name, url in (("chat", CHAT_URL), ("login", LOGIN_URL)):
page.goto(app_url + url)
_wait_settled_anonymous(page)
rag = page.locator("#nav-sources")
git = page.locator("#nav-git-sources")
# Present in the DOM (the markup ships, header.js toggles the
# hidden attribute)…
assert rag.count() == 1, f"{name}: #nav-sources must be in the DOM"
assert git.count() == 1, f"{name}: #nav-git-sources must be in the DOM"
# …and hidden for anonymous (the ship-hidden contract, unchanged
# by the rename).
expect(rag).to_be_hidden()
expect(git).to_be_hidden()
# The reduced bar's settled sign-in control is visible.
expect(page.locator("#sign-in-link")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. The rename did not leak: the Sync button label and the viewer back
# button label (different controls) are untouched
# ---------------------------------------------------------------------------
def test_untouched_controls_stay(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# On /sources.html (admin — the button ships hidden and the page
# boot reveals it on the same cached whoami) the Sync button still
# reads "Sync sources".
login(page, app_url, next=SOURCES_URL)
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
_wait_settled_admin(page)
expect(page.locator("#sync-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#sync-label")).to_have_text("Sync sources")
# Never clicked — a real sync is test_sync_button.py's job.
# On the settled viewer page the back button's span still reads
# "Sources" (the viewer back link is a different control — phase 13;
# the rename only touched the nav items).
page.goto(app_url + VIEWER_URL)
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")
+42 -49
View File
@@ -8,13 +8,15 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
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;
* 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 "Sources" nav link (``#nav-sources``) is HIDDEN for anonymous
* 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);
@@ -33,7 +35,7 @@ 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``
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``
"""
@@ -136,17 +138,33 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa
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 visible
# (phase-16 semantics, now owned by the shared module).
# 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:
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
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)
page.wait_for_function(
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
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()
# 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
@@ -246,7 +264,7 @@ def test_admin_bar_on_all_pages(
# ---------------------------------------------------------------------------
# 3. The Sources nav link: hidden for anonymous everywhere, revealed
# 3. The RAG nav link: hidden for anonymous everywhere, revealed
# after a real login (a toggle, not just initial state)
# ---------------------------------------------------------------------------
@@ -282,51 +300,26 @@ def test_sources_nav_hidden_for_anonymous_everywhere(
# ---------------------------------------------------------------------------
# 4. New Chat from a non-chat page: clear the conversation, land on the
# chat empty state
# 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_from_sources_clears_and_navigates(
page: Page, app_url: str, db_ready: None
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)
# 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 {}
})();"""
)
# 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)
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"
)
# …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()
# ---------------------------------------------------------------------------