"""Phase 100 E2E (Playwright): every page matches the RAG page's width — the full 72rem container at every viewport, measured bounding boxes. Owner request (chat, 2026-09-12): "The theme, tuning, and chat pages are still pretty narrow, I want you to match the width of the RAG page for all other pages to keep things consistent." — SUPERSEDES the phase-58 2026-08-31 46rem/92rem reading-column contract (recorded per the phase-94/96/97 convention in `.agents/phases/todo/ 100_page_width_consistency/00_phase.md`, decisions D1–D4). The phase-58 suite this file used to be updates IN PLACE to the new contract (the phase-97 task-07/08 precedent: a completed phase's suite follows its changed contract; its seeding helpers are kept). The measured contract (the 16px root, the ±4px tolerance is the task spec): * the container is ``min(100%, 72rem)`` border-box with the 2×1.25rem gutters INSIDE the box — at any viewport ≥ ~1200px every shell (``.container.*-shell``) measures **1152px** (72rem), identical on the chat page, Tuning, Theme, and the RAG page; * the standalone ``document.html`` page's ``.doc-md`` is the container's inner content — **1112px** (1152 − 2×1.25rem); * the same-page document MODAL is untouched (D2): the panel stays **~1100px** and the panel binds the ``.doc-md`` inside it — **1058px** (1100 − 2×1px borders − 2×1.25rem of ``.doc-modal-content`` padding), NOT 1152; * below the container cap everything is full-width as before: at 360px no horizontal overflow and the shell is the viewport width; at 900px chat/tuning/theme all measure 900px (equal — the cap never bound below 72rem anyway). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_wide_desktop_column.py -v --no-cov Test → contract mapping: 1. ``test_all_columns_match_the_rag_page`` — the core pin: at 1280×800 the ``.chat-shell`` (``/``), ``.tuning-shell`` (``/tuning.html``), ``.theme-shell`` (``/theme.html``) and the RAG page's ``.sources-shell`` (``/sources.html``) bounding boxes are ALL EQUAL (±4px) — the owner's "match the width of the RAG page" as one assertion; at 1920×1080 the same four are still equal to each other and each measures ≈1152px (72rem, ±4px). 2. ``test_reader_columns_wide`` — the token's consumers at 1920×1080: the modal panel is still ~1100px (D2: the modal is UNCHANGED) with its ``.doc-md`` at ~1058px; ``/shared/``'s ``.shared-shell`` is ≈1152px; the standalone ``document.html`` page's ``.doc-md`` is ≈1112px (the 72rem container's inner content). 3. ``test_narrow_unchanged`` — the no-regression leg: at 360×800 no horizontal overflow and ``.chat-shell`` is the viewport width (full-bleed — the mobile gutters sit in the container's padding, inside the measured box); at 900×600 chat/tuning/theme are all 900px wide, equal to each other. DB isolation: the shared-chat row is deleted in a ``finally`` (admin cookie — the house pattern of test_share_chat.py, whose distinctive question text keeps the auto-title unique); the fixture document rows follow the story-fixture convention of the sibling suites (truncate + re-import; the viewer fixture is a direct row insert — the viewer is database-only, the test_document_viewer.py XSS-fixture pattern). """ from __future__ import annotations import asyncio import re import time from datetime import UTC, datetime from pathlib import Path from threading import Thread from typing import Any import httpx from playwright.sync_api import Browser, Page, ViewportSize, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal from app.models import Document 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" # The phase-100 measured contract (16px root, the task spec): # * CONTAINER_PX — 72rem border-box, the 2×1.25rem gutters INSIDE the # box; every .container.*-shell measures this at any viewport # >= ~1200px (the RAG page's width — the owner's ask). # * DOC_MD_STANDALONE_PX — the standalone document page's .doc-md: the # container's inner content (CONTAINER_PX minus the gutters). # * MODAL_PANEL_PX / DOC_MD_MODAL_PX — the SAME-PAGE modal is untouched # (D2): the 1100px border-box panel (minus its 1px borders) and # .doc-modal-content's 2×1.25rem padding bind the .doc-md inside it. CONTAINER_PX = 72 * 16 # 1152px — the 72rem container (border-box) DOC_MD_STANDALONE_PX = CONTAINER_PX - 2 * 20 # 1112px — inner content MODAL_PANEL_PX = 1100 # the phase-26 panel ceiling (D2: unchanged) DOC_MD_MODAL_PX = MODAL_PANEL_PX - 2 - 2 * 20 # 1058px — panel-inner TOL_PX = 4 # the task spec's ±4px tolerance VIEWPORT_1280: ViewportSize = {"width": 1280, "height": 800} VIEWPORT_1920: ViewportSize = {"width": 1920, "height": 1080} MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$") #: The seeded markdown document (direct row insert — the viewer is #: database-only, so no fixture file is needed). Encoded viewer URL #: values: slashes come out as %2F, same as the chips build them. FIXTURE_SOURCE = "docs" FIXTURE_PATH = "notes/wide-column-fixture.md" FIXTURE_TITLE = "Wide Column Fixture" FIXTURE_DOC_URL = "/document.html?source=docs&path=notes%2Fwide-column-fixture.md" # --------------------------------------------------------------------------- # KB seeding (house pattern — test_document_viewer.py / test_share_chat.py) # --------------------------------------------------------------------------- async def _import_fixtures(mock_port: int) -> ImportSummary: 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 _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: """Truncate the KB (and query log + steering notes — deterministic mock answers), then optionally re-import fixtures. ``saved_chats`` is deliberately NOT touched: the shared test cleans up its own row in a ``finally``.""" with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes")) db.commit() if not seed: return None return _run_in_thread(_import_fixtures(mock_port)) def _seed_summary_doc() -> None: """One markdown document carrying a stored summary — the viewer fixture the phase-100 suite opens in BOTH the same-page modal and the standalone document page (the test_document_viewer.py XSS-fixture pattern: a direct row insert, the viewer is database-only).""" with SessionLocal() as db: db.add( Document( source=FIXTURE_SOURCE, path=FIXTURE_PATH, full_path="/tmp/wide-column-fixture.md", title=FIXTURE_TITLE, content=( "# Wide Column Fixture\n\n" "Phase-100 width pin: a markdown document with a " "summary, opened in the same-page modal and the " "standalone document page." ), summary="A phase-100 fixture summary for the width pin.", content_hash="w" * 64, indexed_at=datetime.now(UTC), ) ) db.commit() # --------------------------------------------------------------------------- # Measurement + chat-turn helpers # --------------------------------------------------------------------------- def _drill(page: Page, *names: str) -> None: """Phase 97: the catalog is the drill-down tree — click through the source/folder rows (exact name match, one per name) to the level that holds the asserted file.""" for name in names: page.click(f'#folders-tbody a.folder-link:text-is("{name}")') def _box_width(page: Page, selector: str, label: str) -> float: """The element's bounding-box width (the measured rendered width, not the computed style) — None means the element is not rendered, which is a test error, not a measurement.""" box = page.locator(selector).first.bounding_box() assert box is not None, f"{selector} not rendered ({label})" return box["width"] def _assert_width(page: Page, selector: str, expected_px: int, label: str) -> None: """The element's bounding-box width is ``expected_px`` ±4px (task spec) — the measured rendered width, not the computed style.""" width = _box_width(page, selector, label) assert abs(width - expected_px) <= TOL_PX, ( f"{label}: {selector} is {width:.1f}px, want {expected_px}px ±{TOL_PX}px" ) def _assert_centered(page: Page, selector: str, viewport_w: int, label: str) -> None: """margin-inline: auto — the column center is within ±2% of the viewport center (the test_responsive_polish.py assertion style).""" box = page.locator(selector).first.bounding_box() assert box is not None, f"{selector} not rendered ({label})" center = box["x"] + box["width"] / 2 assert abs(center - viewport_w / 2) <= 0.02 * viewport_w, ( f"{label}: {selector} center {center:.1f}px is not within ±2% of " f"the {viewport_w}px viewport center" ) def _doc_overflow(page: Page) -> tuple[int, int]: """(scrollWidth, clientWidth) of the documentElement.""" return page.evaluate( "() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]" ) # pyright: ignore[reportReturnType] def _assert_no_doc_overflow(page: Page, label: str) -> None: scroll, client = _doc_overflow(page) assert scroll <= client, f"horizontal overflow at {label}: {scroll} > {client}" def _ask(page: Page, question: str) -> None: """Send one turn and wait until the grounded answer has fully landed (the ``done`` event restored the Send button).""" page.fill("#message-input", question) page.click("#send-btn") expect(page.locator(".msg.user .bubble").last).to_contain_text(question) expect(page.locator(".msg.brain .bubble").last).to_contain_text( MOCK_ANSWER_MARKER, timeout=30_000 ) expect(page.locator("#send-btn")).to_be_enabled() expect(page.locator("#send-label")).to_have_text("Send") def _admin_cookies(page: Page) -> dict[str, str]: """The signed session cookies the browser holds after a form login.""" return { c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c } def _auto_title(question: str) -> str: """The phase-50 auto-title convention: the first question, whitespace-collapsed, capped at 120 chars.""" return " ".join(question.split())[:120] def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]: r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies) assert r.status_code == 200 return r.json()["chats"] def _wait_saved_row( app_url: str, cookies: dict[str, str], title: str, messages: int = 2, ) -> dict[str, Any]: """Wait for the auto-saved row (phase 55: auto-saves are SILENT — poll the admin list until the row with the auto-title appears with the expected message count).""" deadline = time.monotonic() + 15 last: dict[str, Any] | None = None while time.monotonic() < deadline: last = next( (c for c in _chats(app_url, cookies) if c["title"] == title), None ) if last is not None and last["message_count"] >= messages: return last time.sleep(0.2) raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})") def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None: """Best-effort row cleanup (a 404 — already deleted — is fine).""" httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) def _measure_shells( browser: Browser, app_url: str, viewport: ViewportSize ) -> dict[str, float]: """One signed-in page at ``viewport``; measure the bounding-box width of every shell under test after its view is shown: * ``.chat-shell`` on ``/`` (the chat view — the div IS ``.container.chat-shell``); * ``.tuning-shell`` on ``/tuning.html``, ``.theme-shell`` on ``/theme.html``, and the RAG page's ``.sources-shell`` on ``/sources.html`` (admin deep-links — A11's one shell document). """ page = browser.new_page(viewport=viewport) widths: dict[str, float] = {} try: login(page, app_url, next="/") page.locator(".chat-shell").wait_for(state="visible", timeout=10_000) widths[".chat-shell"] = _box_width(page, ".chat-shell", "chat") for path, selector in ( ("/tuning.html", ".tuning-shell"), ("/theme.html", ".theme-shell"), ("/sources.html", ".sources-shell"), ): page.goto(app_url + path) page.locator(selector).wait_for(state="visible", timeout=10_000) widths[selector] = _box_width(page, selector, path) finally: page.close() return widths # --------------------------------------------------------------------------- # 1. The core pin: chat == tuning == theme == RAG page (±4px), at both # proof viewports; each ≈1152px (the 72rem container) at 1920 # --------------------------------------------------------------------------- def test_all_columns_match_the_rag_page( browser: Browser, app_url: str, db_ready: None ) -> None: """The owner's "match the width of the RAG page" as ONE assertion: at 1280×800 the four shells are all EQUAL (±4px); at 1920×1080 they are still equal to each other and each measures ≈1152px (72rem at the 16px root, ±4px — the box includes the container's 2×1.25rem padding, border-box).""" # --- 1280×800 (just above the container cap — the owner's report) --- widths = _measure_shells(browser, app_url, VIEWPORT_1280) ref_selector, ref = next(iter(widths.items())) for selector, width in widths.items(): assert abs(width - ref) <= TOL_PX, ( f"@1280px {selector} is {width:.1f}px but {ref_selector} is " f"{ref:.1f}px — the columns must match the RAG page (±{TOL_PX}px)" ) # --- 1920×1080: equal AND each the 72rem container (≈1152px) ------ widths = _measure_shells(browser, app_url, VIEWPORT_1920) ref_selector, ref = next(iter(widths.items())) for selector, width in widths.items(): assert abs(width - ref) <= TOL_PX, ( f"@1920px {selector} is {width:.1f}px but {ref_selector} is " f"{ref:.1f}px — the columns must match the RAG page (±{TOL_PX}px)" ) assert abs(width - CONTAINER_PX) <= TOL_PX, ( f"@1920px {selector} is {width:.1f}px, want " f"{CONTAINER_PX}px (the 72rem container) ±{TOL_PX}px" ) # --------------------------------------------------------------------------- # 2. The token's consumers at 1920×1080: the modal (unchanged, D2), the # shared page, and the standalone document page # --------------------------------------------------------------------------- def test_reader_columns_wide( browser: Browser, app_url: str, mock_llm: int, db_ready: None ) -> None: """At 1920×1080: the same-page document MODAL is untouched (D2 — the panel is still ~1100px and IT binds the ``.doc-md`` inside, ~1058px, not 1152); ``/shared/``'s ``.shared-shell`` is ≈1152px; the standalone ``document.html`` page's ``.doc-md`` is ≈1112px (the 72rem container's inner content).""" _reset_db(mock_llm, seed=True) _seed_summary_doc() page = browser.new_page(viewport=VIEWPORT_1920) try: # (a) the same-page modal — the 1100px panel stays its effective # ceiling (D2: pin the PANEL, not the 1152 the column would be). login(page, app_url, next="/sources.html") page.locator("#folders-tbody .folder-link").first.wait_for( state="visible", timeout=15_000 ) _drill(page, FIXTURE_SOURCE, "notes") row = page.locator("#docs-tbody tr", has_text="wide-column-fixture.md") expect(row).to_have_count(1) row.locator("td:nth-child(2) a.doc-link").click() expect(page.locator(".doc-modal")).to_be_visible() expect(page.locator("#doc-modal-title")).to_have_text(FIXTURE_TITLE) _assert_width(page, "#doc-modal-panel", MODAL_PANEL_PX, "modal panel @ 1920px") _assert_width( page, "#doc-modal-content .doc-md", DOC_MD_MODAL_PX, "modal .doc-md @ 1920px", ) # (b) the standalone document page: the 72rem container's inner # content (1152 − 2×1.25rem gutters). page.goto(app_url + FIXTURE_DOC_URL) expect(page.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000) expect(page.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000) _assert_width( page, "#doc-content .doc-md", DOC_MD_STANDALONE_PX, "standalone .doc-md @ 1920px", ) # (c) the shared page: .shared-shell at the full 72rem container. page.goto(app_url + "/") q = "How is my Kubernetes cluster set up? (wide-column)" _ask(page, q) cookies = _admin_cookies(page) saved_row = _wait_saved_row(app_url, cookies, _auto_title(q)) chat_id: str = saved_row["id"] anon_ctx = None try: # Public since phase 55 — the share endpoint takes no session. r = httpx.post(f"{app_url}/api/chats/{chat_id}/share", timeout=10) assert r.status_code == 200 share_url = r.json()["share_url"] assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}" anon_ctx = browser.new_context(viewport=VIEWPORT_1920) anon = anon_ctx.new_page() anon.set_default_timeout(30_000) anon.goto(app_url + share_url) expect(anon.locator("#shared-title")).to_have_text( _auto_title(q), timeout=15_000 ) expect(anon.locator(".msg.brain .bubble")).to_contain_text( MOCK_ANSWER_MARKER, timeout=30_000 ) _assert_width(anon, ".shared-shell", CONTAINER_PX, "shared @ 1920px") _assert_centered(anon, ".shared-shell", 1920, "shared @ 1920px") finally: if anon_ctx is not None: anon_ctx.close() _delete_chat(app_url, cookies, chat_id) finally: page.close() # --------------------------------------------------------------------------- # 3. Below the container cap: full-width as today — 360px overflow-free, # 900px chat == tuning == theme (the cap never bound below 72rem) # --------------------------------------------------------------------------- def test_narrow_unchanged( browser: Browser, app_url: str, db_ready: None ) -> None: """The mobile/tablet layouts are UNCHANGED (D3): at 360×800 no horizontal overflow and ``.chat-shell`` is the viewport width (full-bleed — the shell IS its .container, so the mobile gutters sit in its padding, inside the measured box); at 900×600 the chat/tuning/theme shells are ALL 900px wide, equal to each other (the cap never bound below 72rem anyway).""" phone = browser.new_page(viewport={"width": 360, "height": 800}) try: login(phone, app_url, next="/") phone.locator(".chat-shell").wait_for(state="visible", timeout=10_000) _assert_no_doc_overflow(phone, "chat @ 360px") _assert_width(phone, ".chat-shell", 360, "chat @ 360px") finally: phone.close() tablet = browser.new_page(viewport={"width": 900, "height": 600}) try: login(tablet, app_url, next="/") tablet.locator(".chat-shell").wait_for(state="visible", timeout=10_000) _assert_no_doc_overflow(tablet, "chat @ 900px") widths = {".chat-shell": _box_width(tablet, ".chat-shell", "chat @ 900px")} for path, selector in ( ("/tuning.html", ".tuning-shell"), ("/theme.html", ".theme-shell"), ): tablet.goto(app_url + path) tablet.locator(selector).wait_for(state="visible", timeout=10_000) widths[selector] = _box_width(tablet, selector, f"{path} @ 900px") ref_selector, ref = next(iter(widths.items())) for selector, width in widths.items(): assert abs(width - ref) <= TOL_PX, ( f"@900px {selector} is {width:.1f}px but {ref_selector} is " f"{ref:.1f}px — the cap must not bind below the container cap" ) assert abs(width - 900) <= TOL_PX, ( f"@900px {selector} is {width:.1f}px, want the full 900px " f"viewport width ±{TOL_PX}px" ) finally: tablet.close()