"""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 ```` 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 ````."""
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 ;
# 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 , 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}"
)