"""Phase 86 E2E (Playwright): the table pages are viewport-width — the owner's "the page is the width of the table" bug, pinned. Source: ``TODO.md`` L4 (owner bug report, 2026-09-07): "The history page appears to be the width of the table despite the table being scrollable. On mobile this results in half the page being blank and awkwardly scrollable." Story: n/a (owner bug report — ``TODO.md`` L4 is the story; no ``.agents/user_stories/`` file). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_history_page_width.py -v --no-cov The bug (confirmed by live reproduction, 375×812, signed-in admin): the History and Tokens tables carry ``position: absolute`` ``.visually-hidden`` elements (the ```` + the Actions column header span). No ancestor in the chain was positioned, so their 1px boxes were laid out against the INITIAL containing block — the Actions span at the 640px table's right edge — and leaked into the DOCUMENT's scrollable overflow, bypassing the card's own ``overflow-x: auto`` scrolling. Measured ``documentElement.scrollWidth`` at 375px: ``/history.html`` **626**, ``/tokens.html`` **618** (the byte-identical defect, folded in per owner decision A3), ``/sources.html`` **375** (clean — the RAG table's headers are visible text). The fix is ONE CSS property (phase 86, task 01): ``position: relative`` on the shared ``.table-wrap`` card, which becomes the containing block for those spans. This suite pins the FIXED contract: the document never pans past the viewport, the 640px tables still scroll INSIDE their cards (the phase-07 / AGENTS.md-rule-5 full-width contract), the a11y spans stay in the DOM, and the RAG view + the desktop layout are unchanged. Counterfactual (verified red→green in the phase session): with the task-01 rule reverted (no ``position: relative`` on ``.table-wrap``), ``test_history_page_is_viewport_width`` fails at its ``scrollWidth <= innerWidth`` pin (626 > 375); restored, it is green. Test → contract mapping (Playwright Mapping Rule): 1. ``test_history_page_is_viewport_width`` — THE TODO.md L4 pin: admin at 375×812, direct ``/history.html`` — ``documentElement.scrollWidth <= innerWidth`` (626 → ≤375), the table still scrolls INSIDE ``#history-table-wrap`` (``scrollWidth > clientWidth`` — the 640px ``min-width``), the card is the container's content width (≤ the viewport), and the visually-hidden Actions header + caption survived the fix (the a11y names are intact). 2. ``test_tokens_page_is_viewport_width`` — the folded-in identical defect (618 pre-fix): the same two pins on direct ``/tokens.html`` for ``#tokens-table-wrap``. 3. ``test_spa_switch_keeps_page_width`` — the invariant survives the router, not just direct loads: at 375px on ``/history.html`` the mobile menu (a REAL ``#nav-toggle`` click — the phase-85 contract) opens, the Tokens link switches the view IN THE SAME DOCUMENT (the phase-76 window-sentinel proof — a real navigation would wipe ``window`` globals), and the Tokens page is still viewport-width with its table still scrolling in-card. 4. ``test_rag_view_regression`` — ``/sources.html`` at 375px: no overflow (it was already clean at 375 pre-fix — pinned so the shared-rule change cannot regress it) AND the RAG table is still full-width inside its card (in-card scroll — the 640px ``min-width`` still engages). 5. ``test_desktop_unchanged`` — 1280×800 regression on ALL THREE views: no document overflow at any width, and the tables render at container width (the cards need no internal scroll — ``scrollWidth <= clientWidth + 1``, the card is far wider than the mobile 346px). House conventions: a fresh 375×812 page per mobile test via the session ``browser`` fixture (the conftest ``page`` is 1280×800), ``e2e.auth_helpers.login`` for the real form login, ``_wait_settled_admin`` copied from ``test_mobile_hamburger_nav.py`` (suites stay self-contained — helpers copied, only ``auth_helpers`` imported). Data: the History/Tokens contract holds with EMPTY tables (static thead + the 640px ``min-width`` — no rows needed, and the suite never depends on saved chats or issued tokens — test 5 ENFORCES that state, resetting ``saved_chats`` (TRUNCATE, the house precedent) and the ``e2e-``-labeled tokens (label-scoped delete) before pinning, because other suites leave chats on the shared dev DB and a single History row pushes the table's auto-layout minimum width past the 1110px desktop card). The RAG view, by contrast, HIDES its table and shows the "Nothing indexed yet" empty state on an empty KB, so tests 4 and 5 seed the KB the house way (truncate + import the 13 fixture docs, deterministic mock embeddings) and pin nothing about the rows themselves — only that the table is up and full-width. """ from __future__ import annotations import asyncio from pathlib import Path from threading import Thread from typing import Any from playwright.sync_api import Browser, JSHandle, Page, ViewportSize, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" MOBILE: ViewportSize = {"width": 375, "height": 812} # the bug-report phone viewport DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size async def _import_fixtures(mock_port: int) -> ImportSummary: """The house KB seed (copied from ``test_api_tokens.py``): import the fixture docs against the mock LLM's embeddings.""" kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] return await import_sources([FIXTURES], LLMClient(settings)) def _run_in_thread(coro: Any) -> Any: """Run a coroutine on a worker thread. Playwright's sync API keeps an asyncio loop running on the test thread, so ``asyncio.run`` cannot be called directly from a test body. """ box: dict[str, Any] = {} def runner() -> None: try: box["value"] = asyncio.run(coro) except BaseException as e: # noqa: BLE001 — re-raised on the test thread box["error"] = e t = Thread(target=runner) t.start() t.join() if "error" in box: raise box["error"] return box["value"] def _seed_kb(mock_port: int) -> ImportSummary: """House reset (truncate the KB + the prompt-shaping tables so the mock answers stay deterministic) + the fixture re-import — the RAG view hides its table on an empty KB, so the two tests that pin it need at least the fixture docs present. ``saved_chats`` and ``api_tokens`` are deliberately NOT touched (the house pattern).""" with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes")) db.commit() return _run_in_thread(_import_fixtures(mock_port)) def _mobile_page(browser: Browser) -> Page: """A fresh 375×812 page (the conftest ``page`` is 1280×800).""" return browser.new_page(viewport=MOBILE) def _wait_settled_admin(page: Page) -> None: """Wait until whoami has resolved for the admin: the whoami reveal has un-hidden the admin-only nav links — the viewport-independent settled signal (copied from ``test_mobile_hamburger_nav.py``; the sign-out control is viewport-dependent and not a cross-viewport probe).""" page.wait_for_function( "() => !document.querySelector('#nav-sources').hasAttribute('hidden')", timeout=10_000, ) def _open_menu(page: Page) -> None: """Open the mobile menu with a REAL click on the toggle (the phase-85 contract: the gate sits below the header, so a real tap reaches the toggle; for a signed-in admin the gate is hidden anyway).""" page.click("#nav-toggle") expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") def _wrap_handle(page: Page, view: str) -> JSHandle: """The table card (``.table-wrap``) of ``view``: the id'd cards for history/tokens, the RAG card found through its ``.docs-table`` (that card is the only one without an id).""" if view == "sources": return page.evaluate_handle( "() => document.querySelector('.docs-table').closest('.table-wrap')" ) return page.evaluate_handle(f"() => document.querySelector('#{view}-table-wrap')") def _reset_history_and_e2e_tokens() -> None: """The desktop pin's data state (see ``test_desktop_unchanged``): an empty History table + no e2e-issued tokens — the row-width interaction (auto table layout vs the 72rem card) is not the contract under test, and other suites leave saved chats on the shared dev DB. ``saved_chats``: TRUNCATE (the ``test_bottom_chat_actions.py`` precedent); ``api_tokens``: label-scoped ``e2e-`` delete only (the ``test_api_tokens.py`` pattern — the dev DB may hold the owner's real tokens).""" with SessionLocal() as db: db.execute(text("TRUNCATE saved_chats")) db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'")) db.commit() def _assert_viewport_width(page: Page, label: str) -> None: """THE pin (TODO.md L4): the document itself never pans past the viewport — pre-fix, /history.html measured 626 and /tokens.html 618 at this 375px viewport.""" report = page.evaluate( "() => ({ doc: document.documentElement.scrollWidth, inner: window.innerWidth })" ) assert report["doc"] <= report["inner"], ( f"{label}: the document panned to {report['doc']}px at a " f"{report['inner']}px viewport — the TODO.md L4 defect " '(the page is "the width of the table"); the .table-wrap card ' "must be the containing block for the positioned " ".visually-hidden spans (position: relative, phase 86)" ) def _assert_in_card_scroll(page: Page, wrap: JSHandle, label: str) -> None: """The phase-07 / AGENTS.md-rule-5 contract the fix preserves: the 640px table scrolls INSIDE the card (``overflow-x: auto``), the card is the container's content width (≤ the viewport), and the table's ``min-width: 640px`` is what the in-card scroll is over.""" report = wrap.evaluate( """el => { const c = el.closest('.container'); const cs = getComputedStyle(c); const ws = getComputedStyle(el); return { scroll: el.scrollWidth, client: el.clientWidth, // The container's content width minus the card's own // borders (clientWidth excludes them) — the card fills // the row edge to edge. content: c.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - parseFloat(ws.borderLeftWidth) - parseFloat(ws.borderRightWidth), inner: window.innerWidth, table: el.querySelector('table').scrollWidth, }; }""" ) assert report["table"] >= 640, ( f"{label}: the table must keep its 640px min-width, got {report['table']}px" ) assert report["scroll"] > report["client"], ( f"{label}: the 640px table must still scroll INSIDE the card " f"(card {report['client']}px < table {report['table']}px)" ) assert report["client"] <= report["inner"], ( f"{label}: the card must not exceed the viewport " f"({report['client']}px > {report['inner']}px)" ) assert abs(report["client"] - report["content"]) <= 1, ( f"{label}: the card must fill the container's content width " f"(card {report['client']}px vs container {report['content']}px)" ) def _assert_hidden_a11y_spans(page: Page, wrap_sel: str) -> None: """The a11y names survived the fix: the visually-hidden Actions column header and the table caption are still in the DOM (the fix repositioned their CONTAINING BLOCK — it did not remove them).""" expect(page.locator(f"{wrap_sel} th .visually-hidden")).to_have_count(1) expect(page.locator(f"{wrap_sel} th .visually-hidden")).to_have_text("Actions") expect(page.locator(f"{wrap_sel} caption.visually-hidden")).to_have_count(1) # --------------------------------------------------------------------------- # 1. THE TODO.md L4 pin: /history.html is viewport-width at 375px # --------------------------------------------------------------------------- def test_history_page_is_viewport_width( browser: Browser, app_url: str, db_ready: None ) -> None: """THE TODO.md L4 pin: admin at 375×812, direct ``/history.html`` — the document is viewport-width (pre-fix ``scrollWidth`` 626 — the page panned ~250px into a blank region, "half the page being blank"), the 640px table still scrolls INSIDE ``#history-table-wrap`` (the phase-07 / AGENTS.md-rule-5 full-width contract), and the visually-hidden Actions header + caption are still in the DOM (the a11y names survived the fix).""" page = _mobile_page(browser) try: login(page, app_url, next="/history.html") _wait_settled_admin(page) expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000) _assert_viewport_width(page, "/history.html (direct)") _assert_in_card_scroll(page, _wrap_handle(page, "history"), "/history.html") _assert_hidden_a11y_spans(page, "#history-table-wrap") finally: page.close() # --------------------------------------------------------------------------- # 2. The folded-in identical defect: /tokens.html (618 pre-fix) # --------------------------------------------------------------------------- def test_tokens_page_is_viewport_width( browser: Browser, app_url: str, db_ready: None ) -> None: """The owner-confirmed A3 fold-in: the Tokens view carries the byte-identical defect (measured ``scrollWidth`` 618 pre-fix — the same visually-hidden Actions header + caption, no positioned ancestor) and is fixed by the SAME one-rule ``.table-wrap`` fix. Same two pins as test 1, on direct ``/tokens.html`` for ``#tokens-table-wrap``.""" page = _mobile_page(browser) try: login(page, app_url, next="/tokens.html") _wait_settled_admin(page) expect(page.locator("#tokens-table-wrap")).to_be_visible(timeout=15_000) _assert_viewport_width(page, "/tokens.html (direct)") _assert_in_card_scroll(page, _wrap_handle(page, "tokens"), "/tokens.html") _assert_hidden_a11y_spans(page, "#tokens-table-wrap") finally: page.close() # --------------------------------------------------------------------------- # 3. The invariant survives the router (not just direct loads) # --------------------------------------------------------------------------- def test_spa_switch_keeps_page_width( browser: Browser, app_url: str, db_ready: None ) -> None: """At 375px on ``/history.html``: open the mobile menu (a REAL ``#nav-toggle`` click — the phase-85 contract; at this width the nav links live in the dropdown), click the Tokens link — a CLIENT-SIDE view switch in the phase-76 shell, so the same-document proof is the phase-76 canonical one: the window sentinel set before the click is still readable after it (a real document load would wipe ``window`` globals; the navigation-timeline count is deliberately NOT used — a real load resets it, so it cannot distinguish pushState from a reload). On arrival the Tokens page keeps BOTH pins: viewport-width AND the table still scrolling inside its card.""" page = _mobile_page(browser) try: login(page, app_url, next="/history.html") _wait_settled_admin(page) expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000) _assert_viewport_width(page, "/history.html (pre-switch)") # THE SWITCH (mobile menu → Tokens link), same document: page.evaluate("() => { window.__phase86_width = 'phase86'; }") _open_menu(page) page.click("#nav-tokens") expect(page).to_have_url(app_url + "/tokens.html", timeout=15_000) assert page.evaluate("() => window.__phase86_width") == "phase86", ( "a real document load would have wiped the window sentinel — " "the view switch must be same-document (the phase-76 router)" ) expect(page.locator("#tokens-table-wrap")).to_be_visible(timeout=15_000) _assert_viewport_width(page, "/tokens.html (SPA switch)") _assert_in_card_scroll(page, _wrap_handle(page, "tokens"), "/tokens.html (SPA)") finally: page.close() # --------------------------------------------------------------------------- # 4. Regression: the RAG view (already clean) stays clean + full-width # --------------------------------------------------------------------------- def test_rag_view_regression( browser: Browser, app_url: str, mock_llm: int, db_ready: None ) -> None: """``/sources.html`` at 375px (admin): the RAG view measured clean pre-fix (``scrollWidth`` 375 — the ``.docs-table`` headers are visible text, no positioned hidden spans at the table's right edge). Pinned so the shared ``.table-wrap`` rule change cannot regress it — AND its table is still full-width inside the card: the 640px ``min-width`` still engages, so the card keeps its in-card scroll (the shared rule added a containing block, not a width).""" summary = _seed_kb(mock_llm) assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2) page = _mobile_page(browser) try: login(page, app_url, next="/sources.html") _wait_settled_admin(page) expect(page.locator(".docs-table")).to_be_visible(timeout=15_000) _assert_viewport_width(page, "/sources.html (direct)") _assert_in_card_scroll(page, _wrap_handle(page, "sources"), "/sources.html") finally: page.close() # --------------------------------------------------------------------------- # 5. Desktop regression: no overflow anywhere, tables at container width # --------------------------------------------------------------------------- def test_desktop_unchanged( browser: Browser, app_url: str, mock_llm: int, db_ready: None ) -> None: """1280×800 regression on ALL THREE table views: no document overflow at any width, and each table renders at container width — the cards need no internal scroll (``wrap.scrollWidth <= wrap.clientWidth + 1``) and are far wider than the mobile 346px card. The zero-offset ``position: relative`` changed no layout, so desktop is byte-identical in behavior. The KB is seeded the house way for the RAG view's table (it hides itself on an empty KB). Data state: the desktop pin is enforced on the EMPTY-table state the module docstring declares ("the History/Tokens contract holds with EMPTY tables") — the History table's auto layout cannot fit ROWS in the 1110px 72rem card (the nowrap date/share/actions cells + the ellipsized title column at its 34rem ``max-width`` sum to ~1130px of minimum column width), and other suites on the shared dev DB (e.g. ``test_responsive_polish``) leave saved chats behind. So this test resets the state it pins: ``saved_chats`` TRUNCATEd (the ``test_bottom_chat_actions.py`` house precedent — the table is rendered empty here, rows are not under test) and the ``e2e-``-labeled tokens deleted (the ``test_api_tokens.py`` label-scoped pattern — never a TRUNCATE; the shared dev DB may hold the owner's real tokens).""" summary = _seed_kb(mock_llm) assert summary is not None and summary.added == 13 _reset_history_and_e2e_tokens() page = browser.new_page(viewport=DESKTOP) try: login(page, app_url, next="/history.html") _wait_settled_admin(page) views = ( ("history", "/history.html"), ("tokens", "/tokens.html"), ("sources", "/sources.html"), ) for i, (view, path) in enumerate(views): if i: # the first view is the login landing page.goto(app_url + path) _wait_settled_admin(page) marker = ".docs-table" if view == "sources" else f"#{view}-table-wrap" expect(page.locator(marker)).to_be_visible(timeout=15_000) _assert_viewport_width(page, f"{path} (desktop)") report = _wrap_handle(page, view).evaluate( "el => ({ scroll: el.scrollWidth, client: el.clientWidth })" ) assert report["client"] > 346, ( f"{path} (desktop): the table card must render at container " f"width, got {report['client']}px (the mobile card is 346px)" ) assert report["scroll"] <= report["client"] + 1, ( f"{path} (desktop): no in-card scroll is needed at this width " f"(table {report['scroll']}px in a {report['client']}px card)" ) finally: page.close()