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/`.
701 lines
29 KiB
Python
701 lines
29 KiB
Python
"""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}"
|
||
)
|