556 lines
23 KiB
Python
556 lines
23 KiB
Python
"""Phase 07 E2E (Playwright): responsive + WCAG 2.1 AA polish sweep.
|
|
|
|
Story: ``.agent/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:
|
|
page.goto(f"{app_url}/")
|
|
page.locator("#suggestions .suggestion-chip").first.wait_for(
|
|
state="visible", timeout=10_000
|
|
)
|
|
_assert_no_doc_overflow(page, f"chat @ {width}px")
|
|
|
|
login(page, app_url, next="/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)
|
|
wrap_box = page.locator(".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('.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."""
|
|
for path in ("/", "/sources.html"):
|
|
page.goto(f"{app_url}{path}")
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
# 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.
|
|
page.goto(f"{app_url}/")
|
|
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:
|
|
page.goto(app_url)
|
|
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:
|
|
rpage.goto(app_url)
|
|
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:
|
|
chat.goto(app_url)
|
|
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()
|