feat(ui): expose Global Tuning from Chat + Sources headers (admin-only Tuning nav link)
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# Phase 29 — Global Tuning nav link
|
||||
|
||||
**Source:** `.agent/phases/complete/27_global_tuning` — the `/tuning.html` page and its create/edit/delete machinery shipped, but the "Tuning" header link that was supposed to *expose* the page (`27` task 04: *"Expose the tuning page via an admin-only header link"`) only exists on `tuning.html` itself. It is not present on the Chat or Sources pages, so there is no way to reach `/tuning.html` from anywhere except typing the URL.
|
||||
**Story:** `.agent/user_stories/global-tuning.md`
|
||||
**Context:** The shared header (phase 19) renders `[Chat, Sources — admin only]` in `<nav class="app-nav">` on the Chat and Sources pages. `frontend/assets/header.js` already contains logic to reveal an admin-only `#nav-tuning` link for admins (lines 69–70) — but that element is never in `index.html`/`sources.html` markup, so the reveal is a no-op. This phase adds the missing markup so the link the JS already handles actually exists.
|
||||
|
||||
## Objective
|
||||
Make the Global Tuning page reachable by adding the admin-only **"Tuning"** nav link to the shared headers of the **Chat** (`index.html`) and **Sources** (`sources.html`) pages — hidden by default, revealed for admins by the existing `header.js` whoami gate (the exact same pattern as the Sources link). No backend, schema, or API change.
|
||||
|
||||
## Dependencies
|
||||
- `19_shared_header` (complete) — the `<nav class="app-nav">` contract, the shared header markup/ids, and the 64px header-height rule on the Chat and Sources pages.
|
||||
- `16_admin_auth` (complete) — the `/api/whoami` gate + `header.js` `fetchIsAdmin()` that reveals admin-only nav links.
|
||||
- `27_global_tuning` (complete) — the `/tuning.html` page and `PUT/POST/DELETE /api/steering` endpoints that this link points to.
|
||||
|
||||
## Tasks
|
||||
1. `01_tuning_links_markup.md` — add the admin-only `#nav-tuning` link to the Chat (`index.html`) and Sources (`sources.html`) headers, hidden by default.
|
||||
2. `02_tuning_nav_e2e_and_regression.md` — add the story E2E suite (`test_tuning_nav_link.py`) + verify no regressions in the header/shared-header/no-CDN suites and commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: no new backend logic (frontend-only markup). The existing no-CDN integration test (`tests/integration/test_api.py::test_index_html_served_locally`) must still pass — the new link is same-origin `<a>`, no new tags.
|
||||
- Coverage: frontend-only; the `app/` >90% gate is unaffected (unchanged).
|
||||
- E2E (mandatory, A16): `tests/e2e/test_tuning_nav_link.py` — the story gate, run in isolation.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] On the **Chat** page (`/`), a signed-in admin sees a **"Tuning"** link (next to Chat/Sources) that navigates to `/tuning.html`; an anonymous visitor does **not** see it (ships `hidden`, `header.js` reveals only for admin).
|
||||
- [ ] Same on the **Sources** page (`/sources.html`).
|
||||
- [ ] The link is absent from the anonymous DOM-reveal (verified via `test_anonymous` behavior); header markup stays valid and semantic.
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged (no backend change).
|
||||
- [ ] Existing header suites stay green in isolation: `test_header_consistency.py` (64px height + nav structure unchanged), `test_shared_header.py`, `test_smoke.py`, `test_global_tuning.py`.
|
||||
- [ ] `uv run pytest tests/e2e/test_tuning_nav_link.py -v --no-cov` green in isolation.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean (no Python change, but run the gate).
|
||||
- [ ] UI Structure Check (AGENTS.md rule 5): the link is a labeled, focus-visible `<a>`; header landmarks/contrast unchanged; no CDN.
|
||||
- [ ] One `--no-gpg-sign` commit staging only this phase's files; `.agent/phases/todo/29_tuning_nav_link/` moved to `.agent/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, no new packages; the link is a same-origin `<a href="/tuning.html">`.
|
||||
- **A10 untouched** — no new endpoint or auth surface; the link reuses the existing `#nav-sources` reveal path (`header.js` already gates `#nav-tuning` on `fetchIsAdmin()`).
|
||||
- **No schema / migration** — purely a markup addition.
|
||||
- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
|
||||
- **Scope boundary** — only the Chat and Sources pages (the two that share the standard `app-nav` with the Sources link). The viewer page (`document.html`) uses a different `.doc-header-actions` header variant and the login page is the auth gate; both stay out of scope for this fix.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Task 01 — Tuning nav link on the Chat and Sources pages
|
||||
|
||||
**Phase:** `29_tuning_nav_link` · **Story:** `.agent/user_stories/global-tuning.md`
|
||||
|
||||
## Objective
|
||||
Add the admin-only **"Tuning"** nav link to the shared headers of the **Chat** (`index.html`) and **Sources** (`sources.html`) pages, so `/tuning.html` is reachable from both.
|
||||
|
||||
## Work
|
||||
Add one link to each page, inside the existing `<nav class="app-nav" aria-label="Primary">`, **immediately after** the Sources link, mirroring the Sources pattern exactly.
|
||||
|
||||
1. `frontend/index.html` (Chat page) — after the `#nav-sources` link, add:
|
||||
```html
|
||||
<!-- Phase 29: the Global Tuning link is admin-only (owner permission
|
||||
2026-08-25) — hidden by default, header.js reveals it once
|
||||
whoami says admin, exactly like the Sources link above. Points at
|
||||
the standalone /tuning.html manager (phase 27). -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
```
|
||||
2. `frontend/sources.html` (Sources page) — same link, placed after its `#nav-sources` link:
|
||||
```html
|
||||
<!-- Phase 29: the Global Tuning link is admin-only (owner permission
|
||||
2026-08-25) — hidden by default, header.js reveals it once
|
||||
whoami says admin, exactly like the Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
```
|
||||
|
||||
Rules for both edits:
|
||||
- Keep `hidden` by default (anonymous-safe ship-hidden, identical to `#nav-sources`).
|
||||
- Do **not** add `is-active` / `aria-current="page"` — those are "current page" visual states; this link is always hidden on the Chat/Sources pages, and `tuning.html` already sets `is-active` on its own link.
|
||||
- Preserve surrounding markup and indentation so the header layout and the pinned 64px height (phases 12/19) are unchanged. The link is inline in the nav, so height is unaffected.
|
||||
- **Do not modify `frontend/assets/header.js`.** It already handles this element (lines 69–70: `const navTuning = document.querySelector("#nav-tuning"); if (navTuning) navTuning.hidden = !admin;`). The link now exists for that existing reveal code to act on. If, against expectation, that reveal code were absent, add it next to the `navSources` reveal — but it is present, so leave `header.js` untouched.
|
||||
|
||||
## Testing & Quality
|
||||
- No new unit/integration logic (frontend markup only).
|
||||
- Coverage: frontend-only; `app/` coverage unaffected.
|
||||
- This is a same-origin `<a>` — the existing no-CDN integration test (`tests/integration/test_api.py::test_index_html_served_locally`) still passes.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `index.html` and `sources.html` each contain an admin-only `#nav-tuning` `<a href="/tuning.html">` placed after `#nav-sources`, `hidden` by default, without `is-active`.
|
||||
- [ ] Header structure/height unchanged (confirmed in task 02 via `test_header_consistency.py` / `test_shared_header.py`).
|
||||
- [ ] Full test suite green; no behavior change in completed phases.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Task 02 — Tuning nav-link E2E suite, regression checks, and commit
|
||||
|
||||
**Phase:** `29_tuning_nav_link` · **Story:** `.agent/user_stories/global-tuning.md`
|
||||
|
||||
## Objective
|
||||
Prove the new admin-only "Tuning" link works (and is hidden for anonymous users) on the Chat and Sources pages, confirm no header/no-CDN regressions, and commit.
|
||||
|
||||
## Work
|
||||
1. **New story E2E suite** — create `tests/e2e/test_tuning_nav_link.py`, following the harness already used in `tests/e2e/test_global_tuning.py`:
|
||||
- Reuse `from e2e.auth_helpers import login` (real form login on `/login.html`; `login(page, app_url, next=...)` redirects to `next`) and the `db_ready` fixture. No KB seeding is needed for the link-visibility tests (the link is pure header markup gated by `/api/whoami`), so a light module fixture that just brings up the app + mock LLM is enough — mirror `test_global_tuning.py`'s `mock_llm`/`app_url`/`db_ready` params and its `_reset_db` if you want a clean DB.
|
||||
- The visible `#nav-tuning` on an admin page is the sync point (exactly the Sources-link contract): `header.js` reveals it only after whoami says admin.
|
||||
- Write these tests (Playwright `expect`, sync API):
|
||||
1. `test_admin_sees_tuning_link_on_chat` — `login(page, app_url, next="/")`; on the Chat page assert `#nav-tuning` is visible and its `href` is `/tuning.html`; click it and assert the URL navigates to `/tuning.html` (and `#nav-tuning` becomes `is-active`, matching the page's own link).
|
||||
2. `test_admin_sees_tuning_link_on_sources` — `login(page, app_url, next="/sources.html")`; assert `#nav-tuning` is visible on the Sources page and clicking it navigates to `/tuning.html`.
|
||||
3. `test_anonymous_hides_tuning_link_on_chat_and_sources` — visit `/` and `/sources.html` **without** logging in; assert `#nav-tuning` is `hidden` on both pages, `#sign-in-link` visible, `#nav-sources` also hidden (consistent with the existing Sources gate).
|
||||
4. `test_tuning_link_a11y_and_no_cdn` — on the admin Chat page, assert the link is a labeled, focus-visible `<a>` (tab through the header, focus lands on it), and assert every `script[src]`/`link[href]` on the page is same-origin or `data:` (no CDN) — reuse the same `evaluate` scan `test_global_tuning.py::test_tuning_page_a11y_and_no_cdn` uses.
|
||||
2. **Regression verification** — after the new suite is green, run the header- and page-sensitive suites **in isolation** to prove no regression from the added markup/height:
|
||||
```bash
|
||||
uv run pytest tests/e2e/test_header_consistency.py -v --no-cov
|
||||
uv run pytest tests/e2e/test_shared_header.py -v --no-cov
|
||||
uv run pytest tests/e2e/test_smoke.py -v --no-cov
|
||||
uv run pytest tests/e2e/test_global_tuning.py -v --no-cov
|
||||
uv run pytest tests/integration/test_api.py::test_index_html_served_locally -v --no-cov
|
||||
```
|
||||
Fix anything the new link breaks (it should not — the link is inline in the existing nav and does not change header height).
|
||||
3. **Full gate** — run the whole suite with coverage and confirm `app/` coverage TOTAL is **unchanged** (this phase adds no backend logic):
|
||||
```bash
|
||||
uv run pytest
|
||||
uv run pytest --cov=app --cov-report=term-missing # TOTAL must not drop
|
||||
```
|
||||
4. **Lint / types** — `uv run ruff check . && uv run pyright` (no Python change expected, but run the gate).
|
||||
5. **Commit** — one atomic `--no-gpg-sign` commit staging only this phase's files (the new E2E file + any committed edits), then move the phase directory to `.agent/phases/complete/`:
|
||||
```bash
|
||||
git add -f .agent/phases/todo/29_tuning_nav_link tests/e2e/test_tuning_nav_link.py
|
||||
git commit --no-gpg-sign -m "feat(ui): expose Global Tuning from Chat + Sources headers (admin-only Tuning nav link)"
|
||||
git mv .agent/phases/todo/29_tuning_nav_link .agent/phases/complete/29_tuning_nav_link
|
||||
```
|
||||
(`.agent/` is gitignored — use `git add -f` / `git mv -f` as needed.)
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (mandatory, A16): `tests/e2e/test_tuning_nav_link.py` green **in isolation**:
|
||||
```bash
|
||||
uv run pytest tests/e2e/test_tuning_nav_link.py -v --no-cov
|
||||
```
|
||||
- No new unit/integration logic (frontend markup only); `app/` coverage unchanged.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Admin sees a visible **"Tuning"** link on the Chat and Sources pages → clicking it opens `/tuning.html`.
|
||||
- [ ] Anonymous visitors see **no** Tuning link on either page (ships `hidden`, revealed only for admin).
|
||||
- [ ] Link is labeled, focus-visible; page stays CDN-free.
|
||||
- [ ] `test_tuning_nav_link.py` green in isolation; `test_header_consistency.py`, `test_shared_header.py`, `test_smoke.py`, `test_global_tuning.py` green in isolation; `test_index_html_served_locally` green.
|
||||
- [ ] `uv run pytest` green; `app/` coverage TOTAL unchanged; `ruff` + `pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase moved to `.agent/phases/complete/`.
|
||||
@@ -1571,7 +1571,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
/* Phase 14: the New chat pill joins the header — tighten the bar so
|
||||
brand + nav + pill fit at 360px without horizontal overflow (the
|
||||
brand text may ellipsize as the designated squeeze target). */
|
||||
.header-inner { gap: 0.6rem; }
|
||||
/* Phase 29: the admin-only Tuning link joins the nav — three text
|
||||
pills on the chat bar in the admin state. The brand text (the
|
||||
designated squeeze target) is already at zero, so the bar squeezes
|
||||
the nav pills + gaps instead: tighter pill padding/font and a
|
||||
tighter inner gap. The 44px touch floor is held by min-height, the
|
||||
58px bar height is pinned by --header-h, and the bar now fits both
|
||||
auth states at 375px (and 360px) without horizontal overflow. */
|
||||
.header-inner { gap: 0.5rem; }
|
||||
.brand { min-width: 0; }
|
||||
.brand-text {
|
||||
font-size: 0.8rem;
|
||||
@@ -1581,7 +1588,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-link { padding: 0.4rem 0.55rem; font-size: 0.88rem; }
|
||||
.nav-link { padding: 0.4rem 0.4rem; font-size: 0.84rem; }
|
||||
.app-nav { gap: 0.15rem; }
|
||||
.new-chat-btn { padding: 0.4rem 0.55rem; }
|
||||
.new-chat-label { display: none; }
|
||||
.new-chat-btn svg { display: block; }
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
2026-08-23) — hidden by default, header.js reveals it once
|
||||
whoami says admin. The soft-gated page itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 29: the Global Tuning link is admin-only (owner permission
|
||||
2026-08-25) — hidden by default, header.js reveals it once
|
||||
whoami says admin, exactly like the Sources link above. Points at
|
||||
the standalone /tuning.html manager (phase 27). -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
</nav>
|
||||
<!-- Phase 15: open the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — chat page only. -->
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
2026-08-23) — hidden by default, header.js reveals it once
|
||||
whoami says admin. The soft-gated page itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 29: the Global Tuning link is admin-only (owner permission
|
||||
2026-08-25) — hidden by default, header.js reveals it once
|
||||
whoami says admin, exactly like the Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
</nav>
|
||||
<!-- Phase 19: the shared header controls reach the Sources page —
|
||||
same markup, ids, and aria as the chat header (one consistent
|
||||
|
||||
@@ -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