"""Phase 10 E2E (Playwright): the clickable document viewer. Story: ``.agent/user_stories/document-viewer.md`` Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_document_viewer.py -v --no-cov Seeding reuses the real importer against ``tests/fixtures/docs/`` with the deterministic mock embeddings (same pattern as the earlier story suites). Test → story mapping (Playwright Mapping Rule): 1. ``test_source_chip_opens_document`` — chip → NEW TAB → viewer with title + known content string + format badge. 2. ``test_sources_row_links_to_viewer`` — Sources path link (yaml fixture) → viewer with raw content in a ``pre``. 3. ``test_markdown_renders_and_stays_xss_safe`` — md fixture containing ```` renders as visible escaped text (no execution). 4. ``test_missing_doc_shows_not_found`` — unknown doc → not-found state + Sources link; no console crash. 5. ``test_viewer_theme_and_no_cdn`` — dark theme + every ``script[src]`` / ``link[href]`` local or ``data:`` + a11y frame. """ from __future__ import annotations import asyncio import re from datetime import UTC, datetime from pathlib import Path from threading import Thread from typing import Any from playwright.sync_api import Page, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal from app.models import Document 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" QUESTION = "How is my Kubernetes cluster set up?" async def _import_fixtures(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([FIXTURES], LLMClient(settings)) 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"] 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_fixtures(mock_port)) # --------------------------------------------------------------------------- # 1. Chat source chip → new tab → full document # --------------------------------------------------------------------------- def test_source_chip_opens_document( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) page.goto(app_url) page.fill("#message-input", QUESTION) page.click("#send-btn") chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md") expect(chip).to_have_count(1, timeout=30_000) # New-tab contract: same-origin viewer URL, both query values encoded # (the path's slashes come out as %2F — exactly why encoding matters). expect(chip.first).to_have_attribute( "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md" ) expect(chip.first).to_have_attribute("target", "_blank") expect(chip.first).to_have_attribute("rel", "noopener") with page.expect_popup() as popup_info: chip.first.click() viewer = popup_info.value expect(viewer).to_have_url( re.compile( re.escape(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md") ) ) expect(viewer.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster") # Meta row: source badge · format badge · mono path · indexed · chunks. expect(viewer.locator("#doc-meta .doc-source-badge")).to_have_text("docs") expect(viewer.locator("#doc-meta .format-badge")).to_have_text("md") expect(viewer.locator("#doc-meta .doc-path")).to_have_text("homelab/kubernetes.md") expect(viewer.locator("#doc-meta .doc-indexed")).to_contain_text("Indexed") assert re.fullmatch(r"\d+ chunks?", viewer.locator("#doc-meta .doc-chunks").inner_text()) # Full document, rendered markdown in the centered column (not a pre). expect(viewer.locator("#doc-content .doc-md")).to_have_count(1) expect(viewer.locator("#doc-content")).to_contain_text("Talos Linux on three nodes") # --------------------------------------------------------------------------- # 2. Sources table path link → viewer (yaml → raw pre) # --------------------------------------------------------------------------- def test_sources_row_links_to_viewer( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.goto(f"{app_url}/sources.html") row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml") expect(row).to_have_count(1) link = row.locator("td:nth-child(2) a.doc-link") expect(link).to_have_count(1) # Encoded URL: the slashes in the path value come out as %2F. expect(link).to_have_attribute( "href", "/document.html?source=docs&path=homelab%2Fcontainer_gitlab%2Fgitlab-compose.yaml", ) expect(link).to_have_attribute("target", "_blank") expect(link).to_have_attribute("rel", "noopener") expect(link).to_have_attribute("title", "homelab/container_gitlab/gitlab-compose.yaml") with page.expect_popup() as popup_info: link.click() viewer = popup_info.value expect(viewer.locator("#doc-title")).to_have_text("gitlab-compose") expect(viewer.locator("#doc-meta .format-badge")).to_have_text("yaml") # Non-markdown formats render as escaped monospace text in a pre. pre = viewer.locator("#doc-content pre.doc-raw") expect(pre).to_have_count(1) expect(pre).to_contain_text("gitlab/gitlab-ce:17.2.1-ce.0") font = pre.evaluate("el => getComputedStyle(el).fontFamily") assert "mono" in font # --------------------------------------------------------------------------- # 3. Markdown renders through the shared renderer and stays XSS-safe # --------------------------------------------------------------------------- def test_markdown_renders_and_stays_xss_safe( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) # A document whose content carries a hostile \n\nXSS-FIXTURE-MARKER", content_hash="c" * 64, indexed_at=datetime.now(UTC), ) ) db.commit() dialogs: list[str] = [] def _catch_dialog(d) -> None: # a fired dialog == executed script dialogs.append(d.message) d.dismiss() page.on("dialog", _catch_dialog) page.goto(f"{app_url}/document.html?source=docs&path=notes%2Fxss-fixture.md") expect(page.locator("#doc-title")).to_have_text("Xss Fixture") # The tag shows up as VISIBLE, ESCAPED text — rendered, never executed. expect(page.locator("#doc-content")).to_contain_text("") expect(page.locator("#doc-content")).to_contain_text("XSS-FIXTURE-MARKER") assert page.locator("#doc-content script").count() == 0, "hostile script became live HTML" assert dialogs == [], f"dialog fired — script executed: {dialogs}" # --------------------------------------------------------------------------- # 4. Missing document → designed not-found state, no console crash # --------------------------------------------------------------------------- def test_missing_doc_shows_not_found( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) errors: list[str] = [] page.on("pageerror", lambda e: errors.append(str(e))) page.goto(f"{app_url}/document.html?source=docs&path=definitely/not/here.md") expect(page.locator("#doc-title")).to_have_text("Document not found") card = page.locator("#doc-not-found") expect(card).to_be_visible() expect(card).to_contain_text("Document not found") expect(card.locator("a.doc-open-sources")).to_have_attribute("href", "/sources.html") expect(page.locator("#doc-content")).to_be_empty() # Missing params → the same designed state (no fetch, no crash). page.goto(f"{app_url}/document.html") expect(page.locator("#doc-not-found")).to_be_visible() assert errors == [], f"console crashes: {errors}" # --------------------------------------------------------------------------- # 5. Dark theme + all assets local + a11y frame # --------------------------------------------------------------------------- def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: _reset_db(mock_llm, seed=True) page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md") expect(page.locator("#doc-content .doc-md")).not_to_be_empty() # Dark theme inherited from phase 08 (same sampling as that story). bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor") assert bg == "rgb(10, 14, 23)" # No-CDN: every script/link reference is same-origin or a data: URI. refs = page.evaluate( """() => [...document.querySelectorAll("script[src], link[href]")] .map((el) => el.src || el.href)""" ) assert refs, "expected local asset references on /document.html" for ref in refs: assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}" # A11y frame: landmarks, skip link, aria-live around the load→content # swap, and focus moved to main on load. expect(page.locator("header.doc-header")).to_have_count(1) expect(page.locator("main#main")).to_have_count(1) expect(page.locator("footer.app-footer")).to_have_count(1) expect(page.locator(".skip-link")).to_have_count(1) expect(page.locator(".doc-shell")).to_have_attribute("aria-live", "polite") assert page.evaluate("() => document.activeElement && document.activeElement.id") == "main" # Markdown column centered and capped at 46rem (736px at 16px root). box = page.locator("#doc-content .doc-md").bounding_box() assert box is not None and box["width"] <= 736 + 1