Files
brain-of-reese/tests/e2e/test_responsive_polish.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

612 lines
26 KiB
Python

"""Phase 07 E2E (Playwright): responsive + WCAG 2.1 AA polish sweep.
Story: ``.agents/user_stories/responsive-polish.md``
This phase IS the visual/a11y verification (PLAN §7 end-to-end); the suite
below is the acceptance test. Run in isolation (DB must be up:
``podman compose up -d db``):
uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov
Test → story mapping:
1. ``test_no_horizontal_overflow_at_viewports`` — 360/375/768/1280/1600 on
both pages: ``documentElement.scrollWidth <= clientWidth``.
2. ``test_chat_column_capped_and_centered`` — the reading column rides
--chat-column (46rem base; 92rem at >=1500px, phase 58 / owner
instruction 2026-08-31 TODO L5): at 1600px (a wide desktop) the
``.chat-shell`` is 92rem (1472px, ±2%) and horizontally centered
(±2%); at 1280px (below the wide breakpoint) it stays ≤ 46rem
(736px, +2%); at 768px the column uses most of the width (no
mid-column dead zones).
3. ``test_sources_table_full_width`` — at 1280px ``.table-wrap`` ≥ 80% of
the container; below 640px the table keeps its 640px min-width and the
wrapper scrolls horizontally instead of squeezing.
4. ``test_a11y_landmarks_and_labels`` — landmarks on both pages, skip link
focuses ``#main``, the composer input has a programmatically associated
label, every button has an accessible name, and a real keyboard Tab
walk shows a visible ``:focus-visible`` outline on every focusable.
5. ``test_contrast_pairs_pass_aa`` — the PLAN §7.2 color pairs, computed
from computed styles (WCAG relative-luminance ratio ≥ 4.5:1).
6. ``test_reduced_motion_respected`` — with ``reducedMotion: 'reduce'``
the typing dots have no running animation and the spinner is either
stopped or slowed to ≥2s; the turn still completes.
7. ``test_long_content_wraps_without_overflow`` — a 60+ char file path in
the Sources table ellipsizes (full path in ``title``) and 60+ char
unbroken tokens in chat bubbles wrap without any document overflow.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Browser, Page, 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"
VIEWPORTS = ((360, 740), (375, 812), (768, 1024), (1280, 800), (1600, 900))
CHAT_SHELL_CAP_PX = 46 * 16 # 736px — the --chat-column base (PLAN §7.1 lineage)
CHAT_SHELL_WIDE_PX = 92 * 16 # 1472px — the 2x wide override (phase 58, >=1500px)
# Mock-LLM marker for a 3s pre-token window (see tests/e2e/mock_llm.py).
SLOW_QUESTION = "pretend to think slowly, please"
TYPING = "#typing-indicator"
ANSWER = ".msg.brain .bubble:not(.typing)"
# --------------------------------------------------------------------------
# KB seeding (same pattern as the earlier story suites)
# --------------------------------------------------------------------------
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"]
async def _import_dirs(dirs: list[Path], 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([Path(d) for d in dirs], LLMClient(settings))
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_dirs([FIXTURES], mock_port))
# --------------------------------------------------------------------------
# Browser helpers
# --------------------------------------------------------------------------
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 _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
"""Real keyboard Tab walk; returns each focused element's outline."""
first_key: str | None = None
seen: list[dict[str, str]] = []
for _ in range(max_tabs):
page.keyboard.press("Tab")
info = page.evaluate(
"""() => {
const el = document.activeElement;
const cs = getComputedStyle(el);
const cls = String(el.className).split(" ")[0];
const label = (el.getAttribute("aria-label")
|| el.textContent || "").trim().slice(0, 24);
return {
key: el.tagName + "#" + (el.id || "") + "." + cls + ":" + label,
outline_style: cs.outlineStyle,
outline_width: cs.outlineWidth,
};
}"""
)
if info["key"].startswith("BODY"):
continue # focus has not entered the document yet
if first_key is None:
first_key = info["key"]
seen.append(info)
if len(seen) > 1 and info["key"] == first_key:
break # wrapped back to the first focusable
return seen
# --------------------------------------------------------------------------
# WCAG 2.1 contrast (computed from computed styles, not eyeballed)
# --------------------------------------------------------------------------
def _rgb(value: str) -> tuple[int, int, int]:
value = value.strip()
hex_match = re.match(r"^#([0-9a-f]{6})$", value, re.IGNORECASE)
if hex_match:
h = hex_match.group(1)
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
match = re.match(r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
assert match, f"unparsable color: {value!r}"
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
def chan(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(fg: str, bg: str) -> float:
l1, l2 = _rel_luminance(_rgb(fg)), _rel_luminance(_rgb(bg))
if l1 < l2:
l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)
def _assert_aa(pair: Any, label: str) -> None:
fg, bg = str(pair[0]), str(pair[1])
ratio = contrast_ratio(fg, bg)
assert ratio >= 4.5, f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
# --------------------------------------------------------------------------
# Tests (story → test mapping, see module docstring)
# --------------------------------------------------------------------------
def test_no_horizontal_overflow_at_viewports(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC2: no horizontal page overflow at any target viewport, both pages."""
_reset_db(mock_llm, seed=True)
for width, height in VIEWPORTS:
page = browser.new_page(viewport={"width": width, "height": height})
try:
login(page, app_url, next="/") # phase 79: the chips need a session
page.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
_assert_no_doc_overflow(page, f"chat @ {width}px")
page.goto(f"{app_url}/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
_assert_no_doc_overflow(page, f"sources @ {width}px")
finally:
page.close()
def test_chat_column_capped_and_centered(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC1 (phase 58 contract): the reading column doubles to 92rem on
wide desktops (>=1500px — 1600px here), stays at the 46rem base
below the breakpoint (1280px), and still uses most of the width on
tablets (no mid-column dead zones)."""
page = browser.new_page(viewport={"width": 1600, "height": 900})
try:
page.goto(f"{app_url}/")
box = page.locator(".chat-shell").bounding_box()
assert box is not None
assert CHAT_SHELL_WIDE_PX * 0.98 <= box["width"] <= CHAT_SHELL_WIDE_PX * 1.02, (
f"at 1600px (>=1500px) the chat column is {box['width']:.0f}px, "
f"not the 92rem wide override (±2%)"
)
center = box["x"] + box["width"] / 2
assert abs(center - 1600 / 2) <= 0.02 * 1600, (
f"chat column center {center:.0f}px is not within ±2% of the viewport center"
)
finally:
page.close()
narrow = browser.new_page(viewport={"width": 1280, "height": 800})
try:
narrow.goto(f"{app_url}/")
box = narrow.locator(".chat-shell").bounding_box()
assert box is not None
assert box["width"] <= CHAT_SHELL_CAP_PX * 1.02, (
f"at 1280px (<1500px) the chat column {box['width']:.0f}px exceeds "
f"the 46rem base cap (+2%)"
)
finally:
narrow.close()
tablet = browser.new_page(viewport={"width": 768, "height": 1024})
try:
tablet.goto(f"{app_url}/")
box = tablet.locator(".chat-shell").bounding_box()
assert box is not None
assert box["width"] >= 0.85 * 768, (
f"at 768px the chat column ({box['width']:.0f}px) leaves a dead zone"
)
finally:
tablet.close()
def test_sources_table_full_width(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC1: the Sources table is full-width (≥80% of the container) at
1280px; below 640px it keeps a 640px min-width and scrolls
horizontally inside its wrapper instead of squeezing."""
_reset_db(mock_llm, seed=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
login(page, app_url, next="/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap
# (the git-sources one ships hidden) — scope to the RAG view.
wrap_box = page.locator("#view-rag .table-wrap").bounding_box()
shell_box = page.locator(".sources-shell").bounding_box()
assert wrap_box is not None and shell_box is not None
assert wrap_box["width"] >= 0.80 * shell_box["width"], (
f"table wrapper {wrap_box['width']:.0f}px < 80% of container "
f"{shell_box['width']:.0f}px"
)
finally:
page.close()
mobile = browser.new_page(viewport={"width": 375, "height": 812})
try:
login(mobile, app_url, next="/sources.html") # phase 16: admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
scroll, client = mobile.evaluate(
"() => { const el = document.querySelector('#view-rag .table-wrap');"
" return [el.scrollWidth, el.clientWidth]; }"
)
assert scroll > client, (
"at 375px the table must keep its min-width and scroll horizontally"
)
_assert_no_doc_overflow(mobile, "sources @ 375px (scrolling wrapper)")
finally:
mobile.close()
def test_a11y_landmarks_and_labels(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC3: landmarks, working skip link, labeled input, named controls,
and a visible :focus-visible outline on every keyboard focusable.
Phase 79 (task 05): the anonymous visitor meets the token gate —
the page body is inert behind it, and the gate's reveal contract
puts focus on the token input — so the skip-link / full-tab-walk
contract is checked for the SIGNED-IN admin (byte-identical to
before), and the gate gets its own focus contract: reveal focuses
the input, and input + submit both show the global 3px
:focus-visible outline."""
def _outline() -> dict[str, str]:
return page.evaluate(
"""() => {
const el = document.activeElement;
const cs = getComputedStyle(el);
return {id: el.id,
cls: String(el.className).split(" ")[0],
outline_style: cs.outlineStyle,
outline_width: cs.outlineWidth};
}"""
)
def _assert_outlined(info: dict[str, str], label: str, path: str) -> None:
width_px = float(info["outline_width"].replace("px", ""))
assert info["outline_style"] == "solid" and width_px >= 2, (
f"no visible focus outline on the gate {label} on {path} "
f"({info['outline_style']} {info['outline_width']})"
)
# --- Anonymous: the token gate's focus contract (phase 79) --------
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
page.wait_for_load_state("networkidle")
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
# Reveal contract: focus lands on the token input — the first
# (and only) body control an anonymous visitor can act on.
info = _outline()
assert info["id"] == "auth-gate-input", (
f"gate reveal must focus the token input ({path}), on {info['id']!r}"
)
_assert_outlined(info, "input", path)
# Tab from the input lands on the gate submit, also outlined.
page.keyboard.press("Tab")
info = _outline()
assert info["cls"] == "auth-gate-submit", (
f"Tab from the gate input must land on the gate submit ({path}), "
f"on {info['id'] or info['cls']!r}"
)
_assert_outlined(info, "submit", path)
# --- Signed-in: the original AC3 contract (admin, gate hidden) ----
login(page, app_url)
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
page.wait_for_load_state("networkidle")
expect(page.locator("#auth-gate")).to_be_hidden()
# Landmarks (PLAN §7.2).
assert page.locator("header.app-header").count() == 1, f"header missing on {path}"
assert page.locator("nav[aria-label]").count() == 1, f"labeled nav missing on {path}"
assert page.locator("main#main").count() == 1, f"main#main missing on {path}"
assert page.locator("footer.app-footer").count() == 1, f"footer missing on {path}"
# Skip link: present and its target actually receives focus.
# The link is off-screen until focused, so it is driven by the
# keyboard (Tab to it, Enter to follow) — the real user path.
skip = page.locator('a.skip-link[href="#main"]')
assert skip.count() == 1, f"skip link missing on {path}"
page.keyboard.press("Tab")
assert page.evaluate("() => document.activeElement.className") == "skip-link", (
f"first Tab must land on the skip link ({path})"
)
page.keyboard.press("Enter")
assert page.evaluate("() => document.activeElement.id") == "main", (
f"skip link must move focus to #main ({path})"
)
# Every button has an accessible name; every img has alt text.
unnamed = page.evaluate(
"""() => [...document.querySelectorAll("button")]
.filter((b) => !(b.getAttribute("aria-label") || b.textContent.trim()))
.length"""
)
assert unnamed == 0, f"{unnamed} button(s) without an accessible name on {path}"
bad_imgs = page.evaluate(
"() => [...document.querySelectorAll('img')].filter((i) => !i.alt).length"
)
assert bad_imgs == 0, f"{bad_imgs} <img> without alt on {path}"
# A real keyboard Tab walk: every focused element shows a visible
# :focus-visible outline (solid, >= 2px). Start from the top of the
# document (the skip-link check left focus on #main).
page.evaluate(
"() => { if (document.activeElement instanceof HTMLElement)"
" document.activeElement.blur(); }"
)
seen = _tab_outline_walk(page)
assert len(seen) >= 3, f"expected several focusables on {path}, tabbed {len(seen)}"
for info in seen:
width_px = float(info["outline_width"].replace("px", ""))
assert info["outline_style"] == "solid" and width_px >= 2, (
f"no visible focus outline on {info['key']} "
f"({info['outline_style']} {info['outline_width']}) on {path}"
)
# The composer input (chat page) is programmatically labeled.
page.goto(f"{app_url}/")
labeled = page.evaluate(
"""() => {
const input = document.querySelector("#message-input");
return !!input
&& (!!document.querySelector('label[for="message-input"]')
|| !!input.getAttribute("aria-label"));
}"""
)
assert labeled, "#message-input has no associated label"
def test_contrast_pairs_pass_aa(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC4: every PLAN §7.2 color pair computed from the live computed
styles meets WCAG 2.1 AA (>= 4.5:1)."""
# Chat page: ink/surface, white/brand, chip-ink/chip-bg, deflection
# pair. Phase 79: the chips need /api/suggestions (require_user-
# gated) — sign in first; the color pins are auth-independent.
login(page, app_url, next="/")
page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
const cs = (sel, prop) => getComputedStyle(document.querySelector(sel))[prop];
const root = getComputedStyle(document.documentElement);
return {
ink_on_surface: [
cs(".empty-state-title", "color"),
cs(".empty-state", "backgroundColor"),
],
white_on_brand: [
cs(".send-btn", "color"), cs(".send-btn", "backgroundColor"),
],
chip: [
cs(".suggestion-chip", "color"),
cs(".suggestion-chip", "backgroundColor"),
],
deflection: [
root.getPropertyValue("--accent-ink").trim(),
root.getPropertyValue("--accent-bg").trim(),
],
};
}"""
)
_assert_aa(pairs["ink_on_surface"], "ink on surface (chat)")
_assert_aa(pairs["white_on_brand"], "white on brand (send button)")
_assert_aa(pairs["chip"], "chip ink on chip bg")
_assert_aa(pairs["deflection"], "deflection ink on deflection bg")
# Sources page: ink-soft/surface, white/brand (active nav).
# (Phase 16: the stat cards are admin-only — sign in first.)
login(page, app_url, next="/sources.html")
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
const cs = (sel, prop) => getComputedStyle(document.querySelector(sel))[prop];
const root = getComputedStyle(document.documentElement);
return {
ink_soft_on_surface: [
cs(".stat-label", "color"), cs(".stat-card", "backgroundColor"),
],
white_on_brand: [
cs(".nav-link.is-active", "color"),
cs(".nav-link.is-active", "backgroundColor"),
],
deflection: [
root.getPropertyValue("--accent-ink").trim(),
root.getPropertyValue("--accent-bg").trim(),
],
};
}"""
)
_assert_aa(pairs["ink_soft_on_surface"], "ink-soft on surface (stat labels)")
_assert_aa(pairs["white_on_brand"], "white on brand (active nav)")
_assert_aa(pairs["deflection"], "deflection ink on deflection bg (sources)")
def test_reduced_motion_respected(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC6: with prefers-reduced-motion the typing dots do not animate (and
the spinner is stopped or slowed to >=2s); the turn still completes.
A control pass on the default context proves the dots really animate
without the preference (so the check is discriminating)."""
_reset_db(mock_llm, seed=True)
# Control: default context — the dots run the `typing` animation.
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
expect(page.locator(TYPING)).to_be_visible(timeout=1_000)
anim_name = page.evaluate(
"() => getComputedStyle"
"(document.querySelector('#typing-indicator .typing span')).animationName"
)
assert anim_name == "typing", f"control: dots should animate by default, got {anim_name!r}"
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
finally:
page.close()
# Reduced motion: no running (fast) animation on dots or spinner.
context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
)
rpage = context.new_page()
try:
login(rpage, app_url, next="/") # phase 79: chat is require_user-gated
rpage.fill("#message-input", SLOW_QUESTION)
rpage.click("#send-btn")
expect(rpage.locator(TYPING)).to_be_visible(timeout=1_000)
report = rpage.evaluate(
"""() => {
const calm = (cs) =>
cs.animationName === "none" || parseFloat(cs.animationDuration) >= 2;
return {
dots: [...document.querySelectorAll("#typing-indicator .typing span")]
.map((s) => calm(getComputedStyle(s))),
spinner: calm(getComputedStyle(document.querySelector("#send-btn .spinner"))),
};
}"""
)
assert all(report["dots"]), (
"typing dots must not animate (or must be >=2s) under prefers-reduced-motion"
)
assert report["spinner"], (
"spinner must not animate (or must be >=2s) under prefers-reduced-motion"
)
# Feedback is calmed, never removed — the turn still completes.
expect(rpage.locator(ANSWER)).to_be_visible(timeout=30_000)
expect(rpage.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(rpage.locator(TYPING)).to_be_hidden()
finally:
context.close()
def test_long_content_wraps_without_overflow(
browser: Browser, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
) -> None:
"""AC7: a 60+ char file path ellipsizes in the Sources table (full path
in the title attribute) and 60+ char unbroken tokens wrap in chat
bubbles — never breaking the bubble or the page."""
# A doc whose relative path is well over 60 chars.
src = tmp_path / "longkb"
(src / "deep").mkdir(parents=True)
long_name = "a" * 40 + "_backup_rotation_and_restore_procedures.md"
assert len(long_name) >= 60
(src / "deep" / long_name).write_text(
"# Long Path Test\n\n" + "Content line for retrieval. " * 60 + "\n",
encoding="utf-8",
)
_reset_db(mock_llm, seed=True)
summary = _run_in_thread(_import_dirs([src], mock_llm))
assert summary is not None and summary.added == 1
# Sources @ 360px: the long path ellipsizes, full path stays in `title`.
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
login(phone, app_url, next="/sources.html") # phase 16: admin-only
row = phone.locator("#docs-tbody tr", has_text="backup_rotation").first
row.wait_for(state="visible", timeout=10_000)
cell = row.get_by_role("cell").nth(1)
assert cell.get_attribute("title") == f"deep/{long_name}"
scroll, client = cell.evaluate(
"(el) => [el.scrollWidth, el.clientWidth]"
)
assert scroll > client, "the 60+ char path must be visually ellipsized"
_assert_no_doc_overflow(phone, "sources @ 360px (long path)")
finally:
phone.close()
# Chat @ 375px: unbroken 60-char tokens wrap inside both bubbles.
chat = browser.new_page(viewport={"width": 375, "height": 812})
try:
login(chat, app_url, next="/") # phase 79: chat is require_user-gated
chat.set_default_timeout(30_000)
chat.fill("#message-input", f"what do the notes say about {'x' * 60}")
chat.click("#send-btn")
answer = chat.locator(ANSWER)
answer.wait_for(state="visible", timeout=30_000)
expect(chat.locator("#send-label")).to_have_text("Send", timeout=30_000)
for sel in (".msg.user .bubble", ".msg.brain .bubble"):
bubble = chat.locator(sel).first
scroll, client = bubble.evaluate("(el) => [el.scrollWidth, el.clientWidth]")
assert scroll <= client + 2, (
f"{sel}: long token did not wrap (scrollWidth {scroll} > clientWidth {client})"
)
_assert_no_doc_overflow(chat, "chat @ 375px (long tokens)")
finally:
chat.close()