"""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}" )