"""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} 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()