"""Phase 58 E2E (Playwright): the 2x reading column on wide desktops — measured bounding-box widths, not CSS pins. TODO.md L5 (owner instruction 2026-08-31, roadmap confirmation D2 + expansion): "The chat response needs to be 2x wider on wide desktops. there's a lot of unused space." — extended to the document view. The owner-locked contract: viewport >=1500px doubles ``--chat-column`` to 92rem (1472px at the 16px root) for the four reading shells — ``.chat-shell``, ``.shared-shell``, ``.doc-md`` and ``.doc-summary:has(+ .doc-md)`` — while everything below the breakpoint renders exactly as before (46rem / 736px) and ``.tuning-shell`` (a form, not a reading surface) never widens (CSS-pinned by the unit suite, task 01 — the browser proof of the MEASURED width is this file). 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_chat_column_wide_vs_base`` — ``/`` at 1920×1080: the ``.chat-shell`` bounding box is 1472px (92rem, ±4px) and centered; at 1280×800 (below the wide breakpoint) it is back to 736px (46rem, ±4px). 2. ``test_document_column_wide_vs_base`` — a seeded markdown document that carries a summary: at 1920 both ``.doc-md`` and the adjacent ``.doc-summary:has(+ .doc-md)`` panel are 1472px; at 1280 ``.doc-md`` is 736px. 3. ``test_shared_column_wide`` — a real auto-saved conversation shared by token: ``/shared/`` at 1920 renders ``.shared-shell`` at 1472px in a fresh anonymous context. 4. ``test_narrow_unchanged`` — 360px: no horizontal overflow and the ``.chat-shell`` is the existing mobile rule — full-bleed at the viewport width (the shell IS its .container; the 0.9rem mobile gutters live in its own padding, inside the measured box), 900px: the shell holds the 46rem base (736px, not the 1472px wide rule — at 900px a leaked wide override would pin the shell to the 860px content box instead, so 736px is the discriminator) — the wide rule does not leak below 1500px. 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 summary document is a direct row insert, the test_document_viewer.py XSS-fixture pattern — the viewer is database-only). """ 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 two proof viewports (task spec): wide desktop vs just below the # 1500px breakpoint, at the 16px root the phase-58 rem contract: # 92rem = 1472px, 46rem = 736px. ±4px tolerance (task spec). WIDE_PX = 92 * 16 # 1472px — the 2x wide override (>=1500px) BASE_PX = 46 * 16 # 736px — the --chat-column base TOL_PX = 4 WIDE_VIEWPORT: ViewportSize = {"width": 1920, "height": 1080} BASE_VIEWPORT: ViewportSize = {"width": 1280, "height": 800} 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 ONLY shape that renders both reading-column pins on one page: ``.doc-summary`` (the phase-36 panel) directly above ``.doc-md`` (the ``:has( + .doc-md)`` sibling match). Direct row insert — the viewer is database-only (the test_document_viewer.py XSS-fixture pattern).""" 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-58 width pin: a markdown document with a " "summary, so the viewer renders the .doc-summary " "panel directly above the .doc-md column." ), summary="A phase-58 fixture summary for the wide-column pin.", content_hash="w" * 64, indexed_at=datetime.now(UTC), ) ) db.commit() # --------------------------------------------------------------------------- # Measurement + chat-turn helpers # --------------------------------------------------------------------------- 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.""" box = page.locator(selector).first.bounding_box() assert box is not None, f"{selector} not rendered ({label})" assert abs(box["width"] - expected_px) <= TOL_PX, ( f"{label}: {selector} is {box['width']:.1f}px, " f"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) # --------------------------------------------------------------------------- # 1. Chat: 1472px at 1920, back to 736px at 1280 (centered both ways) # --------------------------------------------------------------------------- def test_chat_column_wide_vs_base( browser: Browser, app_url: str, db_ready: None ) -> None: """The chat page's .chat-shell doubles at the 1500px breakpoint: 92rem (1472px, ±4px) at 1920×1080, centered; 46rem (736px, ±4px) at 1280×800 — the base below the breakpoint.""" wide = browser.new_page(viewport=WIDE_VIEWPORT) try: wide.goto(f"{app_url}/") wide.locator("#suggestions .suggestion-chip").first.wait_for( state="visible", timeout=10_000 ) _assert_width(wide, ".chat-shell", WIDE_PX, "chat @ 1920px") _assert_centered(wide, ".chat-shell", 1920, "chat @ 1920px") finally: wide.close() base = browser.new_page(viewport=BASE_VIEWPORT) try: base.goto(f"{app_url}/") base.locator("#suggestions .suggestion-chip").first.wait_for( state="visible", timeout=10_000 ) _assert_width(base, ".chat-shell", BASE_PX, "chat @ 1280px") _assert_centered(base, ".chat-shell", 1280, "chat @ 1280px") finally: base.close() # --------------------------------------------------------------------------- # 2. Document viewer: .doc-md (and its .doc-summary panel) at both # widths — the owner-expanded surface # --------------------------------------------------------------------------- def test_document_column_wide_vs_base( browser: Browser, app_url: str, mock_llm: int, db_ready: None ) -> None: """A seeded md document (with a summary) renders .doc-md at 1472px at 1920 and 736px at 1280 — and the adjacent .doc-summary panel matches the column at 1920 (.doc-summary:has(+ .doc-md)).""" _reset_db(mock_llm, seed=True) _seed_summary_doc() wide = browser.new_page(viewport=WIDE_VIEWPORT) try: wide.goto(app_url + FIXTURE_DOC_URL) expect(wide.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000) expect(wide.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000) expect( wide.locator(".doc-summary:has(+ .doc-md)") ).to_have_count(1, timeout=15_000) _assert_width(wide, "#doc-content .doc-md", WIDE_PX, "document @ 1920px") _assert_width( wide, ".doc-summary:has(+ .doc-md)", WIDE_PX, "summary panel @ 1920px" ) finally: wide.close() base = browser.new_page(viewport=BASE_VIEWPORT) try: base.goto(app_url + FIXTURE_DOC_URL) expect(base.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000) expect(base.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000) _assert_width(base, "#doc-content .doc-md", BASE_PX, "document @ 1280px") finally: base.close() # --------------------------------------------------------------------------- # 3. Shared page: .shared-shell at 1472px for a guest at 1920 # --------------------------------------------------------------------------- def test_shared_column_wide( page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None, ) -> None: """A real auto-saved conversation, shared by token, renders its .shared-shell at 1472px (±4px) at 1920 in a FRESH anonymous context (no cookies — the guest's only credential is the token).""" _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) login(page, app_url, next="/") expect(page).to_have_url(app_url + "/", timeout=30_000) q = "How is my Kubernetes cluster set up? (wide-column)" _ask(page, q) cookies = _admin_cookies(page) row = _wait_saved_row(app_url, cookies, _auto_title(q)) chat_id: str = 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=WIDE_VIEWPORT) 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", WIDE_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) # --------------------------------------------------------------------------- # 4. Below the breakpoint: 360px and 900px are byte-for-byte the old # rules — no overflow, no leaked wide column # --------------------------------------------------------------------------- def test_narrow_unchanged( browser: Browser, app_url: str, db_ready: None ) -> None: """The min-width:1500px override must not leak below the breakpoint: at 360px no horizontal overflow and the shell is the existing mobile rule — full-bleed at the viewport width (the shell IS its .container, so the 0.9rem mobile gutters sit in its own padding, inside the measured box); at 900px the shell holds the 46rem base (736px — a leaked 92rem rule would pin it to the 860px content box instead, so 736px is the discriminator).""" phone = browser.new_page(viewport={"width": 360, "height": 740}) try: phone.goto(f"{app_url}/") phone.locator("#suggestions .suggestion-chip").first.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": 800}) try: tablet.goto(f"{app_url}/") tablet.locator("#suggestions .suggestion-chip").first.wait_for( state="visible", timeout=10_000 ) _assert_no_doc_overflow(tablet, "chat @ 900px") _assert_width(tablet, ".chat-shell", BASE_PX, "chat @ 900px") _assert_centered(tablet, ".chat-shell", 900, "chat @ 900px") finally: tablet.close()