"""Phase 26 E2E (Playwright): documents open in the almost-fullscreen modal — not in a new page. Story: ``.agent/user_stories/document-modal.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 harness as the phase-10 suite — only the assertions changed: chips/row links now open the SAME-PAGE modal, no ``expect_popup``). Test → story mapping (Playwright Mapping Rule): 1. ``test_source_chip_opens_modal`` — chat chip → modal opens in-page (NO new tab, URL unchanged), title + ``.doc-md`` content + meta row. 2. ``test_sources_row_opens_modal`` — Sources path link → modal, yaml in ````, mono font, URL unchanged. 3. ``test_modal_closes_on_button_escape_and_backdrop`` — close via ``#doc-modal-close``, via backdrop click, via ``Escape``. 4. ``test_modal_focus_and_a11y`` — ``role="dialog"`` + ``aria-modal``, focus inside the panel on open, close button has an ``aria-label``. 5. ``test_modal_xss_safe`` — hostile md document opened through the modal renders as escaped text; no dialog fires. 6. ``test_standalone_page_still_works`` — the dedicated ``/document.html`` page keeps its phase-10 contract (title/content/badges, not-found, dark theme, no-CDN, a11y frame, ≤736px md column). 7. ``test_modal_theme_and_no_cdn`` — dark page background, the panel on the Phase-08 surface colour, every asset same-origin or ``data:``. """ 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 from e2e.auth_helpers import login 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)) def _ask_for_chip(page: Page, app_url: str) -> Any: """Drive one chat turn and return the kubernetes.md source chip.""" 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) return chip def _assert_closed(page: Page) -> None: """The modal is fully closed: the hidden attribute is back and the overlay is gone from view.""" expect(page.locator("#doc-modal")).to_have_attribute("hidden", "") expect(page.locator(".doc-modal")).not_to_be_visible() # --------------------------------------------------------------------------- # 1. Chat source chip → SAME-PAGE modal (no new tab) # --------------------------------------------------------------------------- def test_source_chip_opens_modal( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) chip = _ask_for_chip(page, app_url) # The encoded viewer URL stays as the no-JS / context-menu escape # hatch — but phase 26 removed target=_blank: the left click is # intercepted and opens the modal in place. expect(chip.first).to_have_attribute( "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F" ) expect(chip.first).not_to_have_attribute("target") before = len(page.context.pages) chip.first.click() # No new tab: the click must not have spawned a page. assert len(page.context.pages) == before, "clicking a chip must not open a new tab" # The almost-fullscreen modal becomes visible (hidden attribute gone). expect(page.locator("#doc-modal")).not_to_have_attribute("hidden") expect(page.locator(".doc-modal")).to_be_visible() # "Almost-fullscreen": the panel is min(1100px, 96vw) × 92vh, centered # (at a 1280px viewport the 1100px cap wins over 96vw = 1228.8px). box = page.locator("#doc-modal-panel").bounding_box() assert box is not None, "modal panel not rendered" expected_w = min(1100, 0.96 * 1280) expected_h = 0.92 * 800 assert abs(box["width"] - expected_w) < 2, f"panel width {box['width']} (want ~{expected_w})" assert abs(box["height"] - expected_h) < 2, f"panel height {box['height']} (want ~{expected_h})" # Same content the /document.html page renders: title, markdown in # the centered .doc-md column (the modal's content target is # #doc-modal-content — the modal variant of the page's #doc-content). expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster") expect(page.locator("#doc-modal-content .doc-md")).to_have_count(1) expect(page.locator("#doc-modal-content")).to_contain_text("Talos Linux on three nodes") # Meta row mirrors the viewer: source · format · mono path · indexed · chunks. expect(page.locator("#doc-modal-meta .doc-source-badge")).to_have_text("docs") expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("md") expect(page.locator("#doc-modal-meta .doc-path")).to_have_text("homelab/kubernetes.md") expect(page.locator("#doc-modal-meta .doc-indexed")).to_contain_text("Indexed") assert re.fullmatch( r"\d+ chunks?", page.locator("#doc-modal-meta .doc-chunks").inner_text() ) # Still the chat page: no navigation happened. assert page.url == app_url + "/", f"navigated away: {page.url}" # --------------------------------------------------------------------------- # 2. Sources table path link → same-page modal (yaml → raw pre) # --------------------------------------------------------------------------- def test_sources_row_opens_modal( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) login(page, app_url) # phase 16: the Sources catalog is admin-only 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 kept as the escape hatch (slashes come out as %2F); # no target=_blank any more. expect(link).to_have_attribute( "href", "/document.html?source=docs&path=homelab%2Fcontainer_gitlab%2Fgitlab-compose.yaml", ) expect(link).not_to_have_attribute("target") expect(link).to_have_attribute("title", "homelab/container_gitlab/gitlab-compose.yaml") before = len(page.context.pages) link.click() assert len(page.context.pages) == before, "clicking a row link must not open a new tab" expect(page.locator(".doc-modal")).to_be_visible() expect(page.locator("#doc-modal-title")).to_have_text("gitlab-compose") expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("yaml") # Non-markdown formats render as escaped monospace text in a pre. pre = page.locator("#doc-modal-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 # Still on the Sources page: no navigation happened. assert page.url == app_url + "/sources.html", f"navigated away: {page.url}" # --------------------------------------------------------------------------- # 3. Close on button, backdrop, and Escape # --------------------------------------------------------------------------- def test_modal_closes_on_button_escape_and_backdrop( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) chip = _ask_for_chip(page, app_url) def open_and_loaded() -> None: chip.first.click() expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster") # 1) The close button. open_and_loaded() page.click("#doc-modal-close") _assert_closed(page) # 2) The backdrop — a point outside the centered 96vw × 92vh panel # (panel starts at 4vh from the top / 2vw from the edge). open_and_loaded() page.locator("#doc-modal-backdrop").click(position={"x": 5, "y": 5}) _assert_closed(page) # 3) Escape — the capture is document-level, so it works from any # focus position inside (or outside) the panel. open_and_loaded() page.keyboard.press("Escape") _assert_closed(page) # After closing, the page behind is untouched: chat is still there. assert page.url == app_url + "/" expect(page.locator("#composer")).to_be_visible() # --------------------------------------------------------------------------- # 4. Focus management + dialog a11y frame # --------------------------------------------------------------------------- def test_modal_focus_and_a11y( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) chip = _ask_for_chip(page, app_url) chip.first.click() expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster") # The panel is a proper modal dialog, labelled by its title. panel = page.locator("#doc-modal-panel") expect(panel).to_have_attribute("role", "dialog") expect(panel).to_have_attribute("aria-modal", "true") expect(panel).to_have_attribute("aria-labelledby", "doc-modal-title") # On open, focus moves into the dialog's content target. focus_id = page.evaluate("() => document.activeElement && document.activeElement.id") assert focus_id == "doc-modal-content", f"focus {focus_id!r} did not move into the modal" # The close control carries an accessible name (icon-only button). expect(page.locator("#doc-modal-close")).to_have_attribute("aria-label", "Close document") # The "Full page" escape hatch is rebuilt to the same encoded viewer # URL (no back param — the dedicated page's own default applies). expect(page.locator("#doc-modal-open")).to_be_visible() expect(page.locator("#doc-modal-open")).to_have_attribute( "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md" ) # Closing returns focus to the triggering control. page.keyboard.press("Escape") _assert_closed(page) focus_id = page.evaluate("() => document.activeElement && document.activeElement.className") assert "source-chip" in (focus_id or ""), f"focus {focus_id!r} did not return to the chip" # --------------------------------------------------------------------------- # 5. Modal rendering stays XSS-safe (hostile md, opened via the modal) # --------------------------------------------------------------------------- def test_modal_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) # The Sources table lists every indexed document — the admin entry # point into the modal for a doc the chat never cited. login(page, app_url) row = page.locator("#docs-tbody tr", has_text="xss-fixture.md") expect(row).to_have_count(1) row.locator("td:nth-child(2) a.doc-link").click() expect(page.locator(".doc-modal")).to_be_visible() expect(page.locator("#doc-modal-title")).to_have_text("Xss Fixture") # The tag shows up as VISIBLE, ESCAPED text — rendered, never executed. expect(page.locator("#doc-modal-content")).to_contain_text("") expect(page.locator("#doc-modal-content")).to_contain_text("XSS-FIXTURE-MARKER") assert page.locator("#doc-modal-content script").count() == 0, "hostile script became live HTML" assert dialogs == [], f"dialog fired — script executed: {dialogs}" # --------------------------------------------------------------------------- # 6. The dedicated /document.html page keeps its phase-10 contract # --------------------------------------------------------------------------- def test_standalone_page_still_works( 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))) # Direct link renders exactly as before (phase 10): title, meta row, # markdown in the centered column. page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md") expect(page.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster") expect(page.locator("#doc-meta .doc-source-badge")).to_have_text("docs") expect(page.locator("#doc-meta .format-badge")).to_have_text("md") expect(page.locator("#doc-content .doc-md")).to_have_count(1) expect(page.locator("#doc-content")).to_contain_text("Talos Linux on three nodes") # Not-found state: an unknown pair AND missing params — no console # crash, the designed card with the Sources link. 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() page.goto(f"{app_url}/document.html") expect(page.locator("#doc-not-found")).to_be_visible() # Dark theme + all assets local + a11y frame + capped md column. page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md") expect(page.locator("#doc-content .doc-md")).not_to_be_empty() bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor") assert bg == "rgb(10, 14, 23)" 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}" 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 assert errors == [], f"console crashes: {errors}" # --------------------------------------------------------------------------- # 7. Modal theme + no CDN on the touched page # --------------------------------------------------------------------------- def test_modal_theme_and_no_cdn( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) chip = _ask_for_chip(page, app_url) chip.first.click() expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster") expect(page.locator("#doc-modal-content .doc-md")).not_to_be_empty() # Dark theme (phase 08): the page background is untouched, and the # modal panel sits on the Phase-08 surface colour (#121a2e). bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor") assert bg == "rgb(10, 14, 23)" surface = page.evaluate( "() => getComputedStyle(document.querySelector('.doc-modal-panel')).backgroundColor" ) assert surface == "rgb(18, 26, 46)", f"panel not on the Phase-08 surface: {surface}" # No-CDN: every script/link reference on the chat page (the touched # page) is same-origin or a data: URI — the modal adds no assets. refs = page.evaluate( """() => [...document.querySelectorAll("script[src], link[href]")] .map((el) => el.src || el.href)""" ) assert refs, "expected local asset references on the chat page" for ref in refs: assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}"