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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user