phase: 175_history_pagination
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 12s

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/`.
This commit is contained in:
2026-10-11 04:12:53 -04:00
parent 938d6cf09d
commit 375169bbfc
31 changed files with 2339 additions and 61 deletions
+700
View File
@@ -0,0 +1,700 @@
"""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}"
)