214 lines
8.7 KiB
Python
214 lines
8.7 KiB
Python
"""Phase 29 E2E (Playwright): the admin-only "Tuning" nav link on the Chat
|
|
and Sources pages.
|
|
|
|
The Global Tuning page (``/tuning.html``, phase 27) was reachable only by
|
|
typing the URL — the admin-only "Tuning" header link that was supposed to
|
|
expose it (phase 27 task 04) existed on ``tuning.html`` itself but not on
|
|
the Chat or Sources pages. This phase adds the missing markup: an
|
|
``#nav-tuning`` link in the shared header of ``index.html`` and
|
|
``sources.html``, shipped ``hidden`` and revealed by the EXISTING
|
|
``header.js`` whoami gate (``initSharedHeader``) — the exact same contract
|
|
as the ``#nav-sources`` link. This suite proves the link's full lifecycle:
|
|
|
|
* admin: visible on Chat AND Sources (revealed after whoami), ``href`` is
|
|
``/tuning.html``, clicking it navigates there (where the page's own
|
|
link carries ``is-active`` / ``aria-current="page"``);
|
|
* anonymous: hidden on both pages (ships ``hidden``, never revealed —
|
|
consistent with the Sources gate), Sign in visible;
|
|
* a11y / no-CDN: a labeled, keyboard-focusable ``<a>`` with the global
|
|
``:focus-visible`` outline, and every ``script[src]`` / ``link[href]``
|
|
same-origin or ``data:``.
|
|
|
|
No KB seeding is needed: the link is pure header markup gated by
|
|
``/api/whoami``, so a light environment (app + mock LLM + ``db_ready``)
|
|
suffices — there is no ``_reset_db`` here.
|
|
|
|
Story: ``.agent/user_stories/global-tuning.md``
|
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
|
|
|
uv run pytest tests/e2e/test_tuning_nav_link.py -v --no-cov
|
|
|
|
Test → story mapping (Playwright Mapping Rule):
|
|
1. ``test_admin_sees_tuning_link_on_chat``
|
|
2. ``test_admin_sees_tuning_link_on_sources``
|
|
3. ``test_anonymous_hides_tuning_link_on_chat_and_sources``
|
|
4. ``test_tuning_link_a11y_and_no_cdn``
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from playwright.sync_api import Page, expect
|
|
|
|
from e2e.auth_helpers import login
|
|
|
|
IS_ACTIVE = re.compile(r"\bis-active\b")
|
|
CHAT_URL = "/"
|
|
SOURCES_URL = "/sources.html"
|
|
TUNING_URL = "/tuning.html"
|
|
|
|
|
|
def _assert_nav_tuning_link(page: Page) -> None:
|
|
"""The link contract shared by both admin pages: revealed, pointing
|
|
at the Global Tuning page, as a real same-origin ``<a>``."""
|
|
link = page.locator("#nav-tuning")
|
|
expect(link).to_be_visible(timeout=15_000)
|
|
expect(link).to_have_attribute("href", TUNING_URL)
|
|
expect(link).to_have_text("Tuning")
|
|
|
|
|
|
def _open_admin_chat(page: Page, app_url: str) -> None:
|
|
"""Real form login landing on the Chat page, admin state settled.
|
|
|
|
The visible ``#nav-tuning`` is the sync point (exactly the
|
|
Sources-link contract): it ships hidden and ``header.js`` reveals it
|
|
only once whoami says admin.
|
|
"""
|
|
login(page, app_url, next=CHAT_URL)
|
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
|
expect(page.locator("#nav-sources")).to_be_visible()
|
|
_assert_nav_tuning_link(page)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Admin: the Tuning link on the Chat page navigates to /tuning.html
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_admin_sees_tuning_link_on_chat(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_default_timeout(30_000)
|
|
_open_admin_chat(page, app_url)
|
|
|
|
# Not the current page: the link carries no is-active / aria-current
|
|
# here (those belong to tuning.html's own link).
|
|
expect(page.locator("#nav-tuning")).not_to_have_class(IS_ACTIVE)
|
|
|
|
# Click: a real navigation to the Global Tuning page…
|
|
page.click("#nav-tuning")
|
|
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
|
|
|
|
# …where the (newly revealed, admin-only) Tuning link is the page's
|
|
# own current-page link.
|
|
expect(page.locator("#nav-tuning")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#nav-tuning")).to_have_attribute("href", TUNING_URL)
|
|
expect(page.locator("#nav-tuning")).to_have_class(IS_ACTIVE)
|
|
expect(page.locator("#nav-tuning")).to_have_attribute("aria-current", "page")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Admin: the Tuning link on the Sources page navigates to /tuning.html
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_admin_sees_tuning_link_on_sources(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_default_timeout(30_000)
|
|
login(page, app_url, next=SOURCES_URL)
|
|
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
|
|
|
# Admin state settled: the admin-only pair is both revealed (Sources
|
|
# stays is-active — this is the Sources page — Tuning is not).
|
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
|
expect(page.locator("#nav-sources")).to_have_class(IS_ACTIVE)
|
|
_assert_nav_tuning_link(page)
|
|
expect(page.locator("#nav-tuning")).not_to_have_class(IS_ACTIVE)
|
|
|
|
page.click("#nav-tuning")
|
|
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
|
|
expect(page.locator("#nav-tuning")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#nav-tuning")).to_have_class(IS_ACTIVE)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Anonymous: no Tuning link on either page (ships hidden, never
|
|
# revealed — the same gate as Sources)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_anonymous_hides_tuning_link_on_chat_and_sources(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_default_timeout(30_000)
|
|
for path in (CHAT_URL, SOURCES_URL):
|
|
page.goto(app_url + path)
|
|
|
|
# Settled anonymous state: whoami resolved, Sign in revealed…
|
|
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
|
|
|
# …and both admin-only nav links stay hidden. The element IS in
|
|
# the DOM (shipped hidden — anonymous-safe default), just not
|
|
# revealed.
|
|
link = page.locator("#nav-tuning")
|
|
assert link.count() == 1, f"#nav-tuning missing from {path} markup"
|
|
expect(link).to_be_hidden()
|
|
assert page.locator("#nav-tuning[hidden]").count() == 1, (
|
|
f"#nav-tuning must ship [hidden] on {path}"
|
|
)
|
|
expect(page.locator("#nav-sources")).to_be_hidden()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. A11y + no-CDN on the admin Chat page: labeled, focus-visible <a>;
|
|
# every script/link reference same-origin or data:
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_tuning_link_a11y_and_no_cdn(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_default_timeout(30_000)
|
|
_open_admin_chat(page, app_url)
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
# A real (labeled) anchor, not a button/div: visible text is the
|
|
# accessible name.
|
|
tag = page.evaluate("() => document.querySelector('#nav-tuning').tagName")
|
|
assert tag == "A", f"#nav-tuning must be an <a>, got <{tag.lower()}>"
|
|
name = page.evaluate(
|
|
"() => document.querySelector('#nav-tuning').textContent.trim()"
|
|
)
|
|
assert name == "Tuning", f"link must be labeled 'Tuning', got {name!r}"
|
|
|
|
# :focus-visible via a REAL keyboard walk: Tab from the top until
|
|
# focus lands on the link (skip-link → Chat → Tuning in tab order).
|
|
focus_id = ""
|
|
for _ in range(25):
|
|
page.keyboard.press("Tab")
|
|
focus_id = page.evaluate("() => (document.activeElement.id || '')")
|
|
if focus_id == "nav-tuning":
|
|
break
|
|
assert focus_id == "nav-tuning", (
|
|
"keyboard Tab never reached #nav-tuning — the link must be "
|
|
"focusable in the header tab order"
|
|
)
|
|
# Focus is still on the link at the break — read its outline now.
|
|
outline = page.evaluate(
|
|
"""() => {
|
|
const cs = getComputedStyle(document.activeElement);
|
|
return {style: cs.outlineStyle, width: cs.outlineWidth};
|
|
}"""
|
|
)
|
|
width_px = float(outline["width"].replace("px", ""))
|
|
assert outline["style"] == "solid" and width_px >= 2, (
|
|
f"#nav-tuning has no visible focus outline "
|
|
f"({outline['style']} {outline['width']})"
|
|
)
|
|
|
|
# No CDN: every script/link reference is same-origin or a data: URI
|
|
# (same scan as test_global_tuning.py::test_tuning_page_a11y_and_no_cdn).
|
|
refs = page.evaluate(
|
|
"""() => [...document.querySelectorAll("script[src], link[href]")]
|
|
.map((el) => el.src || el.href)"""
|
|
)
|
|
assert refs, "expected local asset references"
|
|
for ref in refs:
|
|
assert ref.startswith(app_url) or ref.startswith("data:"), (
|
|
f"non-local asset reference: {ref}"
|
|
)
|