Files
brain-of-reese/tests/e2e/test_responsive_polish.py
ducoterra ecc921098a
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 104_chip_sizing_question_cap
All completion criteria verified. Final report:

**Phase 104 — final verification pass: all green**
- Verified (no code changes needed): single-line ellipsized `.suggestion-chip` CSS + deleted `.maybe-try` override, `renderChips` full-text `title` + `aria-label`-when-clipped, `maxlength="4000"` + `#char-count` counter + `handleSend` over-cap guard, unit pins file, schemas boundary pins, dedicated E2E suite.
- E2E (isolation): `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → **6 passed**; regressions: `test_suggestion_chips.py` 8 passed, `test_pinned_composer.py` 4 passed, `test_responsive_polish.py` 7 passed, `test_chat_history.py` 5 passed.
- `uv run pytest` → **2102 passed**; `--cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- Criteria: chip E2E (single-line, clipped, title+aria-label full text) ✅; paste caps at exactly 4,000, send streams, counter hides ✅; programmatic 5,000-char fill → banner, no turn, text kept ✅; 4,000/4,001 boundary pinned + HTML maxlength == JS constant cross-file pin ✅.
- Diff scope: `frontend/`, new unit file, `tests/unit/test_schemas.py`, new E2E file, phase files — **no `app/` diff, no migration, no `shared.js` diff**.
- Deviations: 4 regression test files touched — 2 genuine DOM-pin conflicts from the new `#char-count` child (explicitly anticipated by the overview) + 3 documented **pre-existing E2E flake fixes** (smooth-scroll race, tab-walk heuristic, 10 ms timeout), each verified pre-existing on the pre-phase-104 tree.
- No commit made (harness commits per the execution protocol override).
- Next pending phase: `98_sync_summary_visibility`.
2026-09-12 19:45:00 -04:00

660 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 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 (72rem — EQUAL to the .container's cap at every
viewport, phase 100 / owner instruction 2026-09-12: the 46rem base
+ the >=1500px 92rem doubling are retired): at 1600px (a wide
desktop) the ``.chat-shell`` is the 72rem container (1152px, ±2%)
and horizontally centered (±2%); at 1280px the same 1152px (the
container cap binds, not the viewport); at 768px the column is
full-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))
# Phase 100: the ONE width — the 72rem container, border-box (the
# 2×1.25rem gutters are inside the measured box). The 46rem base and
# the 92rem wide-desktop doubling are retired (owner instruction
# 2026-09-12: "match the width of the RAG page for all other pages").
CHAT_SHELL_CONTAINER_PX = 72 * 16 # 1152px — the --chat-column = container cap
# 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.
The walk covers a FULL focus cycle (it starts wherever Chromium
resumes after the skip-link check — mid-document, right after
#main — and ends when focus wraps back to the FIRST element it
visited). The wrap is detected by TRUE element identity: each
newly focused element is stamped with a unique ``data-tabwalk-id``
and the cycle ends when a STAMPED element is focused again.
Phase 104 (task 04, 2026-09-12): the pre-104 heuristic keyed on
the first 24 chars of the element's text — two chips sharing that
prefix (e.g. the two identical "How is my Kubernetes cluster set
up? write a long answer" opener chips left in ``saved_chats`` by
test_pinned_composer.py, which runs suites in sequence on the
shared e2e DB) collided and cut the walk short at two entries,
flaking ``len(seen) >= 3``. Verified pre-existing on the
pre-phase-104 tree (the mid-document Tab start is Chromium
behavior, not a phase-104 change)."""
first_id: 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);
let id = el.getAttribute("data-tabwalk-id");
if (!id) {
id = "tw" + ((window.__twSeq = (window.__twSeq || 0) + 1));
el.setAttribute("data-tabwalk-id", id);
}
return {
id: id,
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_id is None:
first_id = info["id"]
seen.append(info)
if len(seen) > 1 and info["id"] == first_id:
break # wrapped back to the first focusable (same ELEMENT)
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 100 contract): the reading column is the 72rem
container at every viewport — 1600px here (the cap binds, not the
viewport) and 1280px alike (1152px, ±2%, centered), and still
full-width on tablets (no mid-column dead zones). The phase-58
46rem/92rem contract it used to pin is retired."""
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_CONTAINER_PX * 0.98 <= box["width"] <= CHAT_SHELL_CONTAINER_PX * 1.02, (
f"at 1600px the chat column is {box['width']:.0f}px, "
f"not the 72rem container (1152px, ±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
# 1280 > 1152: the container cap binds — the SAME 72rem width as
# at 1600px (the cap no longer depends on the viewport at all).
assert box["width"] <= CHAT_SHELL_CONTAINER_PX * 1.02, (
f"at 1280px the chat column {box['width']:.0f}px exceeds "
f"the 72rem container 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()