"""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/`` — 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/ (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}" )