**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).
508 lines
21 KiB
Python
508 lines
21 KiB
Python
"""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()
|