Files
brain-of-reese/tests/e2e/test_responsive_polish.py
T
ducoterra ad7585d474
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 97_kb_tree_catalog
All completion criteria verified — everything is green, no defects found. Final report:

## Phase 97 final verification pass — ALL GREEN

**Verified (no code changes needed):**
- `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css`
- `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate
- Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved

**Gates (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
- `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation
- 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview`

**Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run)

**Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
2026-09-11 22:48:02 -04:00

631 lines
27 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
# Phase 97: the top level lists the sources (the file table
# is per-level, always hidden at the top) — the source row
# is the catalog-rendered signal.
page.locator("#folders-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
# Phase 97: the top level lists the sources (the file table is
# per-level, hidden at the top) — the source row is the
# catalog-rendered signal, and the visible catalog card is
# #folders-wrap (the ONE .table-wrap without an id in the RAG
# view is no longer unique — the shell carries the git-sources
# one too, so scope by the id).
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=10_000)
wrap_box = page.locator("#folders-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
# Phase 97: the top-level catalog card is #folders-wrap (the
# .kb-folders-table keeps its min-width and scrolls in-card).
mobile.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=10_000)
scroll, client = mobile.evaluate(
"() => { const el = document.querySelector('#folders-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). Phase 97: the shell carries a SECOND
# nav in the RAG view (#kb-crumb, the catalog breadcrumb —
# hidden at the top level, pinned in test_kb_tree), so the
# primary-nav landmark is pinned by its label.
assert page.locator("header.app-header").count() == 1, f"header missing on {path}"
assert page.locator("nav[aria-label='Primary']").count() == 1, (
f"labeled primary 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
# Phase 97: the catalog is the drill-down tree — the row lives
# at the deep level of the longkb source (the drill is the only
# change).
for name in ("longkb", "deep"):
phone.click(f'#folders-tbody a.folder-link:text-is("{name}")')
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()