Files
brain-of-reese/tests/e2e/test_document_viewer.py
T
ducoterra 58e9d94cff
Build and Push Containers / build-and-push-app (push) Successful in 4m3s
Build and Push Containers / build-and-push-db (push) Successful in 18s
phase: 100_page_width_consistency
**Phase 100 — final verification pass: all green.**

- Verified the shipped CSS contract directly: `--chat-column: 72rem` in `:root`; 0 literal `max-width: 46rem`; no `@media (min-width: 1500px)` block; exactly 4 token-capped reading columns; tuning/theme/doc-edit shells cap-free, structurally `.sources-shell`; `mock_llm.py` diff is comment-only.
- Defect found & fixed (phase-93 suite): `test_theme_semantic_completion.py::test_reset_removes_tag_byte_identical` raced theme.js's post-PUT refetch — it asserted the `#bor-theme` tag was gone right after the result line, but tag removal lands in the reconcile after the re-fetch (failed ~1 in 5 runs after `test_chat_rag`, reproduced). Fixed with an auto-waiting `expect(...).to_have_count(0)` (settled-state wait, the file's existing house pattern). 6/6 clean on the repro loop after.
- Tests/lint/coverage: `uv run pytest --cov=app --cov-report=term-missing` → 2052 passed, **99%** on `app/`; `uv run ruff check .` + `uv run pyright` → 0 errors.
- E2E in isolation (all passed): `test_wide_desktop_column.py` 3 (the phase suite — chat==tuning==theme==RAG ±4px at 1280 & 1920, ≈1152px; shared ≈1152px; standalone doc ≈1112px; modal unchanged ≈1100px; 360px overflow-free), `test_ui_customization` 4, `test_admin_theme_tab` 5, `test_document_viewer` 7, `test_save_share_ux` 5, `test_sticky_navbar` 3, `test_markdown_tables` 6, `test_responsive_polish` 7, `test_chat_rag` 3, `test_theme_semantic_completion` 8.
- Completion criteria: ① measured 72rem everywhere (≥~1200px, ±4px) + full-width below + 360px clean — **PASS** (E2E); ② zero 46rem rules / no 1500px block / four token selectors — **PASS** (grep + unit pins); ③ B4 byte-identical no-op + mobile squeeze — **PASS** (theme + responsive suites green); ④ full suite / coverage / lint — **PASS**; ⑤ atomic commit — left to the harness per executor protocol (all changes in the working tree, uncommitted).
- Deviations: none from phase decisions; only change in this pass is the race fix above (test-only, behavior unchanged).
- Next pending phase: `98_sync_summary_visibility` (numeric order in `todo/`; also pending: 99, 101–105).
2026-09-12 13:37:19 -04:00

459 lines
20 KiB
Python
Raw 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 26 E2E (Playwright): documents open in the almost-fullscreen
modal — not in a new page.
Story: ``.agents/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
``<pre.doc-raw>``, 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, the 72rem-container md column —
phase 100: ≈1112px at the 1280px fixture viewport, the container's
inner content).
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."""
login(page, app_url, next="/") # phase 79: chat is require_user-gated
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()
def _drill(page: Page, *names: str) -> None:
"""Phase 97: the catalog is the drill-down tree the agent's `ls`
sees — click through the source/folder rows (exact name match,
one per name) to the level that holds the asserted file. The drill
is the only change from the flat-table era; the row itself is
unchanged."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
# ---------------------------------------------------------------------------
# 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
# Phase 97: the row lives at its folder level (docs → homelab →
# container_gitlab) — the drill is the only change.
_drill(page, "docs", "homelab", "container_gitlab")
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 <script> line. The viewer
# is database-only, so it can be seeded straight into the KB.
with SessionLocal() as db:
db.add(
Document(
source="docs",
path="notes/xss-fixture.md",
full_path="/tmp/xss-fixture.md",
title="Xss Fixture",
content="# Xss Fixture\n\n<script>alert(1)</script>\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)
# Phase 97: the seeded doc's row lives at the notes/ level — the
# drill is the only change.
_drill(page, "docs", "notes")
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("<script>alert(1)</script>")
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)
# Phase 79: the viewer content is gated — the signed-in session sees
# the phase-10 contract unchanged (the not-found cards below come
# from the 404 / missing-params paths, not the auth gate).
login(page, app_url, next="/")
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(15, 10, 10)", "the dark-red rebrand canvas (#0f0a0a)"
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: the 72rem container's inner content (phase 100 —
# the 46rem cap and its wide-desktop doubling are retired): at the
# 1280px fixture viewport the container is 1152px border-box, so
# .doc-md (width:100% inside it) measures 1152 − 2×1.25rem = 1112px.
box = page.locator("#doc-content .doc-md").bounding_box()
assert box is not None, "the standalone .doc-md column is not rendered"
assert abs(box["width"] - 1112) <= 4, (
f"the standalone .doc-md column is {box['width']:.0f}px, "
f"want 1112px (the 72rem container's inner content) ±4px"
)
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, dark-red rebrand 2026-08-28): the page
# background is untouched, and the modal panel sits on the --surface
# colour (#1a0f0f).
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(15, 10, 10)"
surface = page.evaluate(
"() => getComputedStyle(document.querySelector('.doc-modal-panel')).backgroundColor"
)
assert surface == "rgb(26, 15, 15)", f"panel not on the --surface colour: {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}"