feat(ui): expose Global Tuning from Chat + Sources headers (admin-only Tuning nav link)
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"""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}"
|
||||
)
|
||||
Reference in New Issue
Block a user