From 1b29b1cf9d8dc2b1d017249944492ac716e44a5b Mon Sep 17 00:00:00 2001 From: ducoterra Date: Fri, 21 Aug 2026 19:44:46 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20responsive=20+=20WCAG=20AA=20polish?= =?UTF-8?q?=20pass=20across=20chat=20and=20sources=20=E2=80=94=20v1=20feat?= =?UTF-8?q?ure=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../05_story_suggestion_chips.md | 0 .../06_story_loading_feedback.md | 0 .../07_story_responsive_polish.md | 0 README.md | 28 +- frontend/assets/styles.css | 15 +- frontend/index.html | 2 +- frontend/sources.html | 2 +- tests/e2e/test_responsive_polish.py | 533 ++++++++++++++++++ tests/integration/test_api.py | 17 +- 9 files changed, 582 insertions(+), 15 deletions(-) rename .agent/phases/{todo => complete}/05_story_suggestion_chips.md (100%) rename .agent/phases/{todo => complete}/06_story_loading_feedback.md (100%) rename .agent/phases/{todo => complete}/07_story_responsive_polish.md (100%) create mode 100644 tests/e2e/test_responsive_polish.py diff --git a/.agent/phases/todo/05_story_suggestion_chips.md b/.agent/phases/complete/05_story_suggestion_chips.md similarity index 100% rename from .agent/phases/todo/05_story_suggestion_chips.md rename to .agent/phases/complete/05_story_suggestion_chips.md diff --git a/.agent/phases/todo/06_story_loading_feedback.md b/.agent/phases/complete/06_story_loading_feedback.md similarity index 100% rename from .agent/phases/todo/06_story_loading_feedback.md rename to .agent/phases/complete/06_story_loading_feedback.md diff --git a/.agent/phases/todo/07_story_responsive_polish.md b/.agent/phases/complete/07_story_responsive_polish.md similarity index 100% rename from .agent/phases/todo/07_story_responsive_polish.md rename to .agent/phases/complete/07_story_responsive_polish.md diff --git a/README.md b/README.md index eeff69b..ef75ddf 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ the relevant notes with **Postgres 17 + pgvector** cosine search, feeds the If it doesn't have notes for your question, it admits it: *"I haven't done anything like that"* — plus suggestions for what it **does** know. +> **Updated your notes?** The knowledge base is refreshed by re-running the +> import — it's idempotent and only re-embeds what changed: +> ```bash +> uv run python -m scripts.import_docs --prune +> ``` +> Details in [Updating the documents](#updating-the-documents). + - **Stack:** FastAPI · Pydantic v2 · SQLAlchemy 2 · Alembic · pgvector · vanilla HTML/CSS/JS (no CDN) · Playwright E2E - **Planning:** architecture, LOCKED decisions and the phase roadmap live @@ -60,10 +67,14 @@ uv run uvicorn app.main:app --reload # → http://localhost:8000 (chat) http://localhost:8000/sources.html (KB) ``` +> 📝 **After this, day-to-day is just: edit markdown → re-run the import.** +> See [Updating the documents](#updating-the-documents) below. + ## Updating the documents -The knowledge base is refreshed by **re-running the import**. It is -idempotent and delta-based (sha256 per file): +**This is the workflow you'll use most.** The knowledge base is refreshed by +**re-running the import**. It is idempotent and delta-based (sha256 per +file), so a refresh after a normal editing session takes seconds: ```bash # After editing/adding/removing markdown in your projects: @@ -74,13 +85,18 @@ uv run python -m scripts.import_docs --prune # also drop deleted files uv run python -m scripts.import_docs --source ~/SomeOtherDocs ``` +Then check the **Sources** page (`http://localhost:8000/sources.html`): +the *documents* / *chunks* counters and *last indexed* timestamp should +reflect the new files, and each document row shows when it was last +embedded. + +- The import prints one line per file (`import: added|updated|unchanged| + pruned …`) and ends with a greppable summary (`import: summary files=… + added=… updated=… unchanged=… pruned=… chunks=… embed_batches=…`), so it + is safe to run from a cron job or after every commit. - Only **`*.md`** files are indexed. Directories like `.venv`, `node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build` are skipped (see `.agent/PLAN.md` anchor A9). -- Every file is logged on its own line (`import: added|updated|unchanged| - pruned …`), and the run ends with a one-line summary (`import: summary - files=… added=… updated=… unchanged=… pruned=… chunks=… embed_batches=…`) - so the counts are greppable in logs. - Unchanged files are **not re-embedded** — only new/changed ones, so refreshes are cheap. - To sanity-check the LLM backend (models + embedding dimension) after any diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 00c6048..d83f25c 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -172,6 +172,10 @@ body { } .msg-body { max-width: 85%; + /* min-width: 0 — as a flex item this overrides min-width:auto so a + long unbroken token can wrap (overflow-wrap: anywhere) instead of + widening the bubble past the column (AC7, phase 07). */ + min-width: 0; display: flex; flex-direction: column; gap: 0.35rem; @@ -235,6 +239,8 @@ body { padding: 0.15rem 0.6rem; text-decoration: none; max-width: 100%; + /* min-width: 0 so the nowrap pill ellipsizes instead of widening its row */ + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -252,6 +258,13 @@ body { gap: 0.45rem; padding-inline: 0.25rem; } +/* As flex items these chips must be allowed to shrink (min-width:auto + would let a long title-derived chip exceed the column on phones — + phase 07 overflow fix); the label text then wraps inside the pill. */ +.maybe-try .suggestion-chip { + min-width: 0; + max-width: 100%; +} /* typing indicator */ .typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; } @@ -334,8 +347,6 @@ body { padding: 0.55rem 0.5rem; background: transparent; } -.composer textarea:focus { outline: none; } - .send-btn { display: inline-flex; align-items: center; diff --git a/frontend/index.html b/frontend/index.html index 05b86cc..aa5bb6a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,7 +24,7 @@ -
+
-
+

Knowledge base

diff --git a/tests/e2e/test_responsive_polish.py b/tests/e2e/test_responsive_polish.py new file mode 100644 index 0000000..f785f7a --- /dev/null +++ b/tests/e2e/test_responsive_polish.py @@ -0,0 +1,533 @@ +"""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`` — at 1600px ``.chat-shell`` + ≤ 46rem (736px, +2% tolerance) and horizontally centered (±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 + +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 — PLAN §7.1 + +# 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") + + page.goto(f"{app_url}/sources.html") + 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: chat column stays ≤46rem centered at wide viewports 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 box["width"] <= CHAT_SHELL_CAP_PX * 1.02, ( + f"chat column {box['width']:.0f}px exceeds the 46rem cap (+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() + + 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: + page.goto(f"{app_url}/sources.html") + 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: + mobile.goto(f"{app_url}/sources.html") + 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). + page.goto(f"{app_url}/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: + phone.goto(f"{app_url}/sources.html") + 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() diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 5e474da..ee3af1b 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import pytest + from app.config import get_settings @@ -48,12 +50,17 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None: assert r.json() == {"suggestions": override} -def test_index_html_served_locally(client) -> None: - """No-CDN check: the page is served by FastAPI and references only - same-origin assets (no https:// script/link tags).""" - r = client.get("/") +@pytest.mark.parametrize( + ("path", "marker"), + [("/", "Brain of Reese"), ("/sources.html", "Knowledge base")], +) +def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None: + """No-CDN check (PLAN §7.3, re-verified on BOTH pages in phase 07): + each page is served by FastAPI and references only same-origin assets + (no https:// script/link tags).""" + r = client.get(path) assert r.status_code == 200 - assert "Brain of Reese" in r.text + assert marker in r.text assert 'src="https://' not in r.text assert 'href="https://' not in r.text