Files
brain-of-reese/tests/e2e/test_history_pagination.py
T
ducoterra 375169bbfc
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 175_history_pagination
All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green.

**Phase 175 final verification pass — report**

- Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed.
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate).
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN).
- Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins).
- Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's).
- Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.**
- Deviations: none. No defects found in previously completed work.
- Next pending phase: none — 175 is the only entry in `todo/`.
2026-10-11 04:12:53 -04:00

701 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 175 E2E (Playwright): the History page's list pager.
TODO.md L3 (owner 2026-10-10): "Need pagination on the history page
- it gets long fast" — the saved-chat list is now paginated
SERVER-side (``GET /api/chats`` takes ``?limit=`` (default 25,
clamped 1–100) / ``?offset=`` and reports the full ``total`` on
every response), and the view walks it through the ``.history-pager``
control under the table card (Prev / "Page N of M" / Next — the
``.history-refresh`` pill language; hidden while the list is a single
page, A4; Refresh keeps the current page, A5; the anonymous gate is
untouched, D5; the ≤640px full-width block, D6).
Run in isolation (the DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_history_pagination.py -v --no-cov
The contract under test:
* **page 1 of 2 (a 30-row fixture)** — the table holds 25 rows (the
25-row page at offset 0), the pager is visible, ``#history-pager-
status`` reads "Page 1 of 2", Prev ``disabled`` / Next enabled, and
the live region carries "Showing 1–25 of 30." (the never-stale
first-load line — the settled load announces its own page);
* **Next → page 2** — the 5 remaining rows (rows are REPLACED, never
appended — the 25 page-1 rows are gone), "Page 2 of 2", Next
``disabled`` / Prev enabled, "Showing 26–30 of 30.";
* **Prev → page 1** — back to 25 rows, "Page 1 of 2", Prev
``disabled``, "Showing 1–25 of 30.";
* **Refresh on page 2** — re-fetches page 2 (A5 — never resets to
page 1): still 5 rows + "Page 2 of 2" and "Saved chats
refreshed." lands;
* **delete-to-empty on the last page** — the inline two-step confirm
(the owner-locked no-native-dialog pin — the "Delete? [Yes] [No]"
pair appearing in place is the contract) deletes all five page-2
rows: the empty state appears, and the pager is NOT recomputed on
the local delete (D4 — it still reads "Page 2 of 2", the last
known truth); the next load (Prev) corrects it with the fresh
``total`` (25 → single page → the pager hides, A4);
* **single page** — a 10-row list: all ten rows, the pager fully
hidden (A4 — no dead "Page 1 of 1" control);
* **anonymous** — the gate shows, the table AND the pager stay
hidden, and NO ``GET /api/chats`` request appears in the log (the
house request-log pin — the router 403s them);
* **≤640px** — the pager row stacks and the buttons go full width
(the ``.history-refresh`` mobile idiom, D6) while the table still
scrolls inside its card.
DB isolation: the pagination pins are EXACT (25 of 30, 10 of 10,
"Page N of M"), so every test starts from an EMPTY ``saved_chats``
(the ``test_history_page_width.py`` TRUNCATE house precedent — the
state is under test, not a byproduct; the KB tables are never
touched — the suite never drives the chat page). Fixture rows are
created through the PUBLIC ``POST /api/chats`` write surface with
DISTINCTIVE auto-titles, re-Saved (``PUT /api/chats/<id>`` — the
public upsert, ``onupdate`` bumps ``updated_at``) in a controlled
loop (a short sleep between bumps — Postgres' ``now()`` is µs-
resolution, the HTTP round-trip ms; the sleep makes the
``updated_at desc`` order FULLY deterministic) so the last re-Saved
chat is row 1, and they are deleted in a ``finally`` (admin cookie —
the ``test_chat_history.py`` idiom). The order is pinned by the first
row of page 1 (the last re-Saved) and the first / last rows of the
last page (the earliest re-Saved) — never the full middle order.
"""
from __future__ import annotations
import re
import time
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.db import SessionLocal
from e2e.auth_helpers import login
@pytest.fixture(autouse=True)
def clean_saved_chats(db_ready: None) -> None:
"""Every test here pins an EXACT total (25 of 30, 10 of 10 —
"Page N of M"), so the shared dev DB's leftover rows from other
suites would break it: each test starts from an EMPTY
``saved_chats`` (the ``test_history_page_width.py`` TRUNCATE
house precedent — the state is under test, not a byproduct).
The KB tables are never touched — the suite never drives the
chat page."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# ---------------------------------------------------------------------------
# Fixture helpers (the test_chat_history.py DB-isolation idiom, adapted
# to a known-total fixture: the suite's pins are exact, so the rows
# are created through the public API with distinctive auto-titles,
# order-controlled, and deleted in a finally)
# ---------------------------------------------------------------------------
def _title(i: int, n: int) -> str:
"""The fixture row's distinctive auto-title (the first user
message — the auto-title convention: whitespace-collapsed,
120-char cap)."""
return f"Pagination probe {i} of {n} — what is the capital of France?"
def _make_chat(app_url: str, title: str) -> str:
"""POST /api/chats (the PUBLIC write surface, phase 55): the
auto-title IS the first user message, so the title goes in as
the question; the minimal house conversation shape (one user +
one brain reply)."""
r = httpx.post(
f"{app_url}/api/chats",
json={
"messages": [
{"who": "user", "text": title},
{"who": "brain", "text": "That's a good question."},
]
},
timeout=10,
)
assert r.status_code == 201, r.text
return r.json()["id"]
def _re_save(app_url: str, chat_id: str, title: str) -> None:
"""PUT /api/chats/<id> (the public re-Save upsert): a full
message replacement that bumps ``updated_at``
(``onupdate=func.now()``) — the controlled order bump."""
r = httpx.put(
f"{app_url}/api/chats/{chat_id}",
json={
"messages": [
{"who": "user", "text": title},
{"who": "brain", "text": "That's a good question."},
]
},
timeout=10,
)
assert r.status_code == 200, r.text
def _make_chats(app_url: str, n: int) -> list[str]:
"""n fixture conversations (distinctive auto-titles), then the
controlled re-Save loop — a short sleep between bumps makes the
``updated_at desc`` order FULLY deterministic, so the last
re-Saved chat is row 1. Returns the ids in re-Save order (index
0 = the OLDEST = the last row of the last page)."""
ids: list[str] = []
for i in range(1, n + 1):
ids.append(_make_chat(app_url, _title(i, n)))
for i, chat_id in enumerate(ids, start=1):
time.sleep(0.02)
_re_save(app_url, chat_id, _title(i, n))
return ids
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form
login — used to call the admin API with plain httpx (the
``test_chat_history.py`` idiom)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _delete_all(app_url: str, cookies: dict[str, str], ids: list[str]) -> None:
"""Best-effort row cleanup (a 403 — not signed in yet — or a 404
— already deleted — is fine; the next test's TRUNCATE covers
any leak)."""
for chat_id in ids:
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _settle_admin_history(page: Page, app_url: str, rows: int) -> None:
"""Real form login onto /history.html + wait for the view's
first load to have settled: the N data rows (the title links)
IS the settle signal — the pager's state and the first-load
"Showing" line land in the same settled load (updatePager runs
before the row append; the announcement after it)."""
login(page, app_url, next="/history.html")
expect(page).to_have_url(app_url + "/history.html", timeout=30_000)
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(rows, timeout=15_000)
# ---------------------------------------------------------------------------
# Contrast helpers (copied from test_responsive_polish.py — suites
# stay self-contained; only auth_helpers is imported)
# ---------------------------------------------------------------------------
def _rgb(value: str) -> tuple[int, int, int]:
match = re.match(r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
assert match, f"unparsable color: {value!r}"
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
def chan(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(fg: str, bg: str) -> float:
l1, l2 = _rel_luminance(_rgb(fg)), _rel_luminance(_rgb(bg))
if l1 < l2:
l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)
def _assert_aa(pair: Any, label: str) -> None:
fg, bg = str(pair[0]), str(pair[1])
ratio = contrast_ratio(fg, bg)
assert ratio >= 4.5, (
f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
)
# ---------------------------------------------------------------------------
# 1. Page 1 of 2 for 30 rows: the 25-row page, the pager's visible
# state, the never-stale first-load line
# ---------------------------------------------------------------------------
def test_pager_shows_page_1_of_2_for_30_rows(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
# The table holds the first 25 of 30 (the 25-row page)…
expect(page.locator("#history-pager")).to_be_visible()
expect(page.locator("#history-pager-status")).to_have_text(
"Page 1 of 2"
)
expect(page.locator("#history-pager-prev")).to_be_disabled()
expect(page.locator("#history-pager-next")).to_be_enabled()
# …and the live region carries the first-load line (the
# settled load announces its own page — task 03).
expect(page.locator("#history-status")).to_have_text(
"Showing 1–25 of 30."
)
# Order pinned by the first row of page 1 (the last
# re-Saved — the newest).
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(30, 30))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 2. Next → page 2: the 5 rows (replaced, never appended), the
# disabled edge, the live-region line
# ---------------------------------------------------------------------------
def test_next_walks_to_page_2(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
page.locator("#history-pager-next").click()
# The 25 page-1 rows are GONE (replaced, never appended —
# an append would leave 30; the count IS the pin)…
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5, timeout=15_000)
expect(page.locator("#history-pager-status")).to_have_text(
"Page 2 of 2"
)
expect(page.locator("#history-pager-next")).to_be_disabled()
expect(page.locator("#history-pager-prev")).to_be_enabled()
expect(page.locator("#history-status")).to_have_text(
"Showing 26–30 of 30."
)
# …reinforced: the newest page-1 title (row 1) is not on
# the page at all.
expect(
page.locator(
f'#history-tbody a.history-title-link:text-is("{_title(30, 30)}")'
)
).to_have_count(0)
# Order: the first row of the last page (the 5th re-Saved —
# row 26) …
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(5, 30))
# …and the last row is the earliest re-Saved (row 30).
expect(
page.locator("#history-tbody a.history-title-link").last
).to_have_text(_title(1, 30))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 3. Prev → page 1: the round trip (25 rows again, the edge back,
# the line)
# ---------------------------------------------------------------------------
def test_prev_walks_back_to_page_1(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
page.locator("#history-pager-next").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5, timeout=15_000)
page.locator("#history-pager-prev").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(25, timeout=15_000)
expect(page.locator("#history-pager-status")).to_have_text(
"Page 1 of 2"
)
expect(page.locator("#history-pager-prev")).to_be_disabled()
expect(page.locator("#history-pager-next")).to_be_enabled()
expect(page.locator("#history-status")).to_have_text(
"Showing 1–25 of 30."
)
# Order round-trips: the first row is the last re-Saved
# again.
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(30, 30))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 4. Refresh on page 2 stays on page 2 (A5 — never resets to
# page 1)
# ---------------------------------------------------------------------------
def test_refresh_on_page_2_stays_on_page_2(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
page.locator("#history-pager-next").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5, timeout=15_000)
# The Refresh lifecycle: click → disabled while in flight →
# re-enabled in the finally — waiting for the re-enable IS
# the settle gate (the house idiom).
page.locator("#history-refresh").click()
expect(page.locator("#history-refresh")).to_be_enabled(
timeout=15_000
)
# A5: the re-fetch landed page 2, not page 1 — still the 5
# page-2 rows + "Page 2 of 2" (a reset to page 1 would show
# 25 rows + "Page 1 of 2" — the pins fail it).
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5)
expect(page.locator("#history-pager-status")).to_have_text(
"Page 2 of 2"
)
expect(page.locator("#history-pager-next")).to_be_disabled()
expect(page.locator("#history-status")).to_have_text(
"Saved chats refreshed."
)
# The page-2 order is intact (the 5th re-Saved first).
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(5, 30))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 5. Delete-to-empty on the last page: the inline two-step confirm
# (no native dialog), the empty state, the pager NOT recomputed
# on the local delete (D4), the next load's fresh total hides
# it (A4)
# ---------------------------------------------------------------------------
def test_delete_all_rows_of_page_2_then_prev_single_page_hides_pager(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
page.locator("#history-pager-next").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5, timeout=15_000)
# Delete all five page-2 rows through the inline two-step
# confirm — the owner-locked no-native-dialog pin: the
# "Delete? [Yes] [No]" pair appearing IN PLACE is the
# contract (a real window.confirm would hang Playwright).
for remaining in (4, 3, 2, 1, 0):
tr = page.locator(
"#history-tbody tr",
has=page.locator("a.history-title-link"),
).first
expect(tr).to_be_visible()
tr.locator("button.history-delete").click()
expect(tr.locator(".history-confirm-yes")).to_be_visible()
expect(tr.locator(".history-confirm-no")).to_be_visible()
tr.locator(".history-confirm-yes").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(remaining)
# The last row's deletion reveals the empty state…
expect(page.locator("#history-empty-row")).to_be_visible()
# …and the pager is NOT recomputed on the local delete
# (D4 — the last known truth: still "Page 2 of 2", the
# server's 30-row count from the page-2 load; only the next
# load corrects it).
expect(page.locator("#history-pager")).to_be_visible()
expect(page.locator("#history-pager-status")).to_have_text(
"Page 2 of 2"
)
expect(page.locator("#history-pager-prev")).to_be_enabled()
expect(page.locator("#history-pager-next")).to_be_disabled()
# The next load (Prev) corrects the pager with the fresh
# total: 25 rows (the page-1 rows — the page-2 ones are
# deleted) → single page → the pager hides (A4).
page.locator("#history-pager-prev").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(25, timeout=15_000)
expect(page.locator("#history-pager")).to_be_hidden()
expect(page.locator("#history-status")).to_have_text(
"Showing 1–25 of 25."
)
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 6. Single page: the pager is fully hidden (A4 — no dead "Page 1
# of 1" control)
# ---------------------------------------------------------------------------
def test_single_page_hides_the_pager(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 10)
_settle_admin_history(page, app_url, 10)
try:
# The whole list is on the page (10 of 10)…
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(10)
# …and the pager is fully hidden (the container's `hidden`
# takes the whole row away — the buttons with it).
expect(page.locator("#history-pager")).to_be_hidden()
expect(page.locator("#history-pager-prev")).to_be_hidden()
expect(page.locator("#history-pager-next")).to_be_hidden()
# Order: the first row is the last re-Saved (the newest).
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(10, 10))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 7. Anonymous: the gate, the table AND the pager hidden, and NO
# /api/chats request in the log (D5)
# ---------------------------------------------------------------------------
def test_anonymous_sees_gate_and_never_fetches(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
# A fresh context is anonymous by construction (no login).
requests: list[str] = []
page.on("request", lambda r: requests.append(r.url))
page.goto(app_url + "/history.html")
# Settled anonymous state (the whoami round-trip has landed)…
expect(page.locator("#history-gate")).to_be_visible(timeout=15_000)
# …the table is wrapped away AND the pager stays hidden (the
# markup-shipped state — the anonymous branch never touches
# it, D5 — no dead control beside the gate).
expect(page.locator("#history-table-wrap")).to_be_hidden()
expect(page.locator("#history-pager")).to_be_hidden()
# …and the view NEVER calls /api/chats (the router 403s
# anonymous) — a short settle wait so the claim is made on a
# settled page (the house log-pin idiom).
page.wait_for_timeout(500)
assert not any("/api/chats" in u for u in requests), (
f"the anonymous History page must not fetch /api/chats: {requests}"
)
# ---------------------------------------------------------------------------
# 8. ≤640px (D6): the pager row stacks, the buttons go full width
# (the .history-refresh mobile idiom), the table still scrolls
# inside its card
# ---------------------------------------------------------------------------
def test_pager_mobile_375px(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
page.set_viewport_size({"width": 375, "height": 720})
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
expect(page.locator("#history-pager")).to_be_visible()
pager = page.locator("#history-pager").bounding_box()
assert pager is not None, "the pager must render at 375px"
# The D6 block: the row STACKS (the .settings-actions idiom)
# and the buttons go FULL WIDTH (the .history-refresh mobile
# idiom) — each button's width is the pager's (±1px
# subpixel; the pills are `width: 100%` of the stacked row).
direction = page.evaluate(
"() => getComputedStyle("
"document.getElementById('history-pager')).flexDirection"
)
assert direction == "column", (
f"the pager row must stack at 375px (got {direction!r})"
)
for sel in ("#history-pager-prev", "#history-pager-next"):
box = page.locator(sel).bounding_box()
assert box is not None, f"{sel} must render at 375px"
assert abs(box["width"] - pager["width"]) <= 1, (
f"{sel} must be full width: {box['width']:.1f}px vs the "
f"pager's {pager['width']:.1f}px"
)
assert pager["x"] + pager["width"] <= 375 + 1, (
f"the pager's right edge ({pager['x'] + pager['width']:.1f}) "
f"reaches/past the 375px viewport"
)
# No page-level horizontal scroll (the table's own overflow-x
# scroll is the mobile scroll)…
scroll_width = page.evaluate(
"() => document.scrollingElement.scrollWidth"
)
assert scroll_width <= 375, (
f"page-level horizontal scroll at 375px "
f"(scrollWidth {scroll_width})"
)
# …and the 640px table still scrolls INSIDE its card (the
# phase-86 contract the D6 block must not break).
report = page.evaluate(
"() => { const el = document.getElementById("
"'history-table-wrap'); "
"return { scroll: el.scrollWidth, client: el.clientWidth }; }"
)
assert report["scroll"] > report["client"], (
f"the 640px table must scroll inside its card at 375px "
f"(scrollWidth {report['scroll']} <= clientWidth "
f"{report['client']})"
)
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 9. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6)
# ---------------------------------------------------------------------------
def test_pager_a11y_and_no_cdn_basics(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
_make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
expect(page.locator("#history-pager")).to_be_visible()
# Standard app frame (PLAN §7.2).
expect(page.locator("header.app-header")).to_have_count(1)
expect(page.locator('nav[aria-label="Primary"]')).to_have_count(1)
expect(page.locator("main#main")).to_have_count(1)
expect(page.locator("footer.app-footer")).to_have_count(1)
expect(page.locator(".skip-link")).to_have_count(1)
# The pager buttons' accessible names (the aria-labels — what
# the accessibility tree exposes; the visible "Prev" / "Next"
# labels stay in both) and the decorative SVGs (aria-hidden).
assert page.locator("#history-pager-prev").get_attribute("aria-label") == (
"Previous page of saved chats"
), "the Prev button's accessible name"
assert page.locator("#history-pager-next").get_attribute("aria-label") == (
"Next page of saved chats"
), "the Next button's accessible name"
assert (
page.locator("#history-pager-prev .history-pager-icon")
.get_attribute("aria-hidden")
== "true"
), "the Prev chevron is decorative (aria-hidden)"
assert (
page.locator("#history-pager-next .history-pager-icon")
.get_attribute("aria-hidden")
== "true"
), "the Next chevron is decorative (aria-hidden)"
# The live region that announces the page lines (the
# never-stale contract).
assert page.locator("#history-status").get_attribute("role") == "status"
assert (
page.locator("#history-status").get_attribute("aria-live")
== "polite"
), "#history-status must be a polite live region"
# The pager button reaches the 3px :focus-visible outline via a
# REAL keyboard Tab walk (the phase-171 walk shape: the skip
# link is focused first — the document's FIRST tabbable
# element; every move after it is a real Tab keypress). The
# walk crosses the header, the Refresh pill, the table region
# (tabindex=0) and the 25 row's controls (title link + share
# Create link + Delete — 3 tabbables per row) before it lands
# on the pager's Next (Prev is `disabled` on page 1 — not
# tabbable).
page.evaluate("() => document.querySelector('a.skip-link').focus()")
seen: list[str] = []
reached = False
for _ in range(150):
page.keyboard.press("Tab")
active = page.evaluate(
"() => (document.activeElement && document.activeElement.id)"
" || ''"
)
seen.append(active)
if active == "history-pager-next":
reached = True
break
assert reached, (
f"the walk never reached the pager's Next control "
f"(seen={seen[-12:]})"
)
outline = page.evaluate(
"() => getComputedStyle(document.activeElement).outlineWidth"
)
assert outline == "3px", (
f"focus-visible outline missing on the pager button: {outline!r}"
)
# Contrast (the existing tokens — the phase-08 AA palette,
# computed-style spot check): the "Page N of M" status text on
# the page bg (the inherited text color), and the button ink on
# the brand pill.
pairs = page.evaluate(
"""() => {
const cs = (sel, prop) =>
getComputedStyle(document.querySelector(sel))[prop];
const root = getComputedStyle(document.documentElement);
const body = getComputedStyle(document.body);
const bg =
root.backgroundColor !== "rgba(0, 0, 0, 0)"
? root.backgroundColor
: (body.backgroundColor !== "rgba(0, 0, 0, 0)"
? body.backgroundColor
: "rgb(0, 0, 0)");
return {
status: [cs("#history-pager-status", "color"), bg],
next_btn: [
cs("#history-pager-next", "color"),
cs("#history-pager-next", "backgroundColor"),
],
};
}"""
)
_assert_aa(pairs["status"], "pager status text on the page bg")
_assert_aa(pairs["next_btn"], "pager button ink on brand")
# No CDN (rule 6): no https:// asset tags; every script/link
# ref is same-origin or a data: URI (the house check).
html = page.content()
assert 'src="https://' not in html and 'href="https://' not in html
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}"
)