Files
brain-of-reese/tests/e2e/test_wide_desktop_column.py
T
ducoterra 58e9d94cff
Build and Push Containers / build-and-push-app (push) Successful in 4m3s
Build and Push Containers / build-and-push-db (push) Successful in 18s
phase: 100_page_width_consistency
**Phase 100 — final verification pass: all green.**

- Verified the shipped CSS contract directly: `--chat-column: 72rem` in `:root`; 0 literal `max-width: 46rem`; no `@media (min-width: 1500px)` block; exactly 4 token-capped reading columns; tuning/theme/doc-edit shells cap-free, structurally `.sources-shell`; `mock_llm.py` diff is comment-only.
- Defect found & fixed (phase-93 suite): `test_theme_semantic_completion.py::test_reset_removes_tag_byte_identical` raced theme.js's post-PUT refetch — it asserted the `#bor-theme` tag was gone right after the result line, but tag removal lands in the reconcile after the re-fetch (failed ~1 in 5 runs after `test_chat_rag`, reproduced). Fixed with an auto-waiting `expect(...).to_have_count(0)` (settled-state wait, the file's existing house pattern). 6/6 clean on the repro loop after.
- Tests/lint/coverage: `uv run pytest --cov=app --cov-report=term-missing` → 2052 passed, **99%** on `app/`; `uv run ruff check .` + `uv run pyright` → 0 errors.
- E2E in isolation (all passed): `test_wide_desktop_column.py` 3 (the phase suite — chat==tuning==theme==RAG ±4px at 1280 & 1920, ≈1152px; shared ≈1152px; standalone doc ≈1112px; modal unchanged ≈1100px; 360px overflow-free), `test_ui_customization` 4, `test_admin_theme_tab` 5, `test_document_viewer` 7, `test_save_share_ux` 5, `test_sticky_navbar` 3, `test_markdown_tables` 6, `test_responsive_polish` 7, `test_chat_rag` 3, `test_theme_semantic_completion` 8.
- Completion criteria: ① measured 72rem everywhere (≥~1200px, ±4px) + full-width below + 360px clean — **PASS** (E2E); ② zero 46rem rules / no 1500px block / four token selectors — **PASS** (grep + unit pins); ③ B4 byte-identical no-op + mobile squeeze — **PASS** (theme + responsive suites green); ④ full suite / coverage / lint — **PASS**; ⑤ atomic commit — left to the harness per executor protocol (all changes in the working tree, uncommitted).
- Deviations: none from phase decisions; only change in this pass is the race fix above (test-only, behavior unchanged).
- Next pending phase: `98_sync_summary_visibility` (numeric order in `todo/`; also pending: 99, 101–105).
2026-09-12 13:37:19 -04:00

508 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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/<token>``'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/<token>``'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()