"""Phase 181 E2E (Playwright): SVG documents preview as graphics, not XML. Source: ``TODO.md`` L3 — "Need SVG render support on the front end when citing and svg source. Also would be nice to click an svg and see a preview in the RAG tab rather than the xml". Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov The suite's OWN module app (the phase-122 ``test_image_documents.py`` pattern) runs with ``BOR_IMAGES=true`` and every suite-writable home (``BOR_IMAGE_DIR`` included) pinned into the suite's tmp dir — process env ranks above an operator's local gitignored ``.env``, so the toggle cannot leak in either direction. Seeding is the phase-138 pattern (``test_markdown_blockquotes`` / ``test_markdown_inline_links_images``): truncate, then run the REAL importer in-process against a COPY of the phase's dedicated fixture tree ``tests/fixtures/svg_docs/`` (the shared fixture set's counts are pinned by other suites and must not move) with the deterministic mock embeddings — the vector branch of task 02 indexes both SVGs with NO vision call, the PNG half of the last test is described by the mock's ``IMAGE_DESCRIPTION`` branch. Test → story mapping (Playwright Mapping Rule): 1. ``test_viewer_renders_svg_not_xml`` — the Sources row for ``diagrams/topology.svg`` opens the modal showing ``img.doc-image-img--vector`` (``naturalWidth > 0``, non-empty alt), NO ``pre.doc-raw`` and no ```` text below the graphic; the "Full page" escape hatch renders the same graphic at ≥ 600px width at a 1280px viewport (the task-04 vector sizing contract — a ``viewBox``-only SVG would otherwise sit in the 300×150 default box). 2. ``test_chat_figure_and_modal_preview`` — the grounded scripted-read question cites the SVG doc as the ``.source-image`` figure (the ```` loads, the caption is the extracted text), and clicking the figure opens the modal with the full graphic (the "preview in the RAG tab" contract); Escape closes it. 3. ``test_blank_svg_is_indexed_and_searchable`` — ``diagrams/blank.svg`` (pure paths) exists as an image document whose content is the structural LOCKED A2 line ``Vector diagram "blank" — …``, is listed on the Sources page with its thumbnail, and opens in the modal with that line as its description. 4. ``test_markdown_image_ref_renders`` — the two-stage seeding: import the tree, read the SVG doc's id from the DB, write ``notes/inline.md`` containing ``![topology](/api/documents//image)`` and re-import; the viewer for that document shows ``img.md-img`` pointing at the bytes route and LOADING (``naturalWidth > 0``) — the phase-180 renderer + this route end to end. 5. ``test_raster_surfaces_unchanged`` — the smoke guard: a PNG doc in the same tree still renders through ``.doc-image-img`` (no ``--vector`` class) and its bytes route carries NO sandbox CSP (the app-wide policy only) — phase 122/124 behaviour byte-unchanged. """ from __future__ import annotations import asyncio import base64 import json import os import shutil import subprocess import sys from collections.abc import Iterator from pathlib import Path from threading import Thread from typing import Any import httpx import pytest from playwright.sync_api import Page, expect from sqlalchemy import select, text from app.config import Settings from app.db import SessionLocal from app.models import Chunk, Document from app.rag.agent import IMAGE_DOC_MARKER from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient from app.rag.svg_text import extract_svg_text from e2e.auth_helpers import login from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http if USE_REAL_LLM: pytest.skip( "phase 181 pins the deterministic mock's grounded turn and seeded " "figures — the real model answers differently", allow_module_level=True, ) REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "svg_docs" #: The source name = the imported tree root's basename (the importer's #: ``archive_source_name``/directory rule — the phase-180 pattern). SOURCE = "svg_docs" TOPO_PATH = "diagrams/topology.svg" BLANK_PATH = "diagrams/blank.svg" #: The fixture diagram's own words — what the vector branch indexes #: (LOCKED A1) and what every text assertion below pins. Pinned as a #: literal here (not re-derived from the extractor) so a change to the #: extraction contract breaks this suite LOUDLY, next to the product. TOPO_TEXT = ( "Homelab network topology\n" "Wiring diagram of the homelab: the edge router uplinks two managed " "switches, the hypervisor rack and the nas storage shelf.\n" "rack-1 switch\n" "nas storage" ) #: The grounded scripted-read question (phase-118 A1: a zero-read turn #: chips nothing — the mock emits the scripted ``read`` then echoes the #: result, the phase-122 figure precedent). Token-overlap cosines #: against the mock embeddings: topology.svg ≈0.70, the infra.md text #: co-citation ≈0.72 (both over the mock-calibrated 0.30 gate); the #: blank.svg line sits at ≈0.04 and never pollutes the citation set. QUESTION = ( "Read the suggested document: read svg_docs/diagrams/topology.svg — " "what does the homelab network topology diagram show about the edge " "router, rack-1 switch and the nas storage?" ) #: A real 1×1 PNG (the phase-122/124 suites' bytes) — the raster smoke #: guard of the last test, written into the suite's tree copy only there. PNG_1X1 = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" ) # --------------------------------------------------------------------------- # Module app: BOR_IMAGES pinned true, the image dir in the suite's scratch # (the in-process importer and the app under test SHARE that directory — # the copies the seed writes are the bytes the route serves). # --------------------------------------------------------------------------- def _app_env(mock_llm: int, port: int, scratch: Path) -> dict[str, str]: """The conftest app env (leak guards included) with the phase-181 knobs: ``BOR_IMAGES=true`` EXPLICITLY (an operator's local ``.env`` cannot leak the toggle off) and ``BOR_IMAGE_DIR`` pinned into the suite's scratch (the app must never read or write the owner's real image home).""" env = dict(os.environ) env.pop("DEBUGPY", None) env["BOR_ENVIRONMENT"] = "e2e" env["BOR_STATIC_DIR"] = str(REPO / "frontend") env["BOR_LLM_BASE_URL"] = f"http://127.0.0.1:{mock_llm}/v1" # Mock-calibrated gate (conftest pattern) — QUESTION grounds on these. env["BOR_RELEVANCE_THRESHOLD"] = "0.30" env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15" env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15" env["BOR_LLM_RETRY_DELAY"] = "0" env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default) env.setdefault( "BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", ) env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD env["BOR_SESSION_SECRET"] = SESSION_SECRET # Leak guards (conftest pattern). env["BOR_GIT_SOURCES"] = "" env["BOR_DOCS_REPO"] = "" env["BOR_SUGGESTIONS"] = json.dumps( Settings.model_fields["suggestions"].default ) env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default # Phase 181: the toggle under test + the suite-private homes. env["BOR_IMAGES"] = "true" env["BOR_UPLOAD_DIR"] = str(scratch / "uploads") env["BOR_SOURCES_DIR"] = str(scratch / "checkouts") env["BOR_IMAGE_DIR"] = str(_image_dir(scratch)) return env def _image_dir(scratch: Path) -> Path: return scratch / "images" @pytest.fixture(scope="module") def scratch(tmp_path_factory: pytest.TempPathFactory) -> Path: return tmp_path_factory.mktemp("bor_svg_preview") @pytest.fixture(scope="module") def app_server( mock_llm: int, scratch: Path ) -> Iterator[str]: """The suite's own app — ``BOR_IMAGES=true`` (the conftest session app is never started in this isolated run, so no port clash).""" port = int(os.environ.get("E2E_APP_PORT_SVG_PREVIEW", "8196")) proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"], cwd=REPO, env=_app_env(mock_llm, port, scratch), ) try: _wait_http(f"http://127.0.0.1:{port}/api/health") yield f"http://127.0.0.1:{port}" finally: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() @pytest.fixture(scope="module") def app_url(app_server: str) -> str: return app_server # --------------------------------------------------------------------------- # Seeding: the phase-138 truncate-then-import pattern against a per-test # COPY of the dedicated fixture tree (the two-stage test appends a file to # the copy, never to the tracked fixture). # --------------------------------------------------------------------------- def _fresh_tree(scratch: Path) -> Path: """A pristine copy of the fixture tree at ``/src/svg_docs`` (the basename IS the source name). Rmtree-first: the two-stage test leaves an ``inline.md`` behind, and every other test must not see it.""" tree = scratch / "src" / SOURCE if tree.exists(): shutil.rmtree(tree) tree.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(FIXTURES, tree) return tree async def _import(tree: Path, mock_llm: int, scratch: Path) -> ImportSummary: """The REAL importer over the fixture copy (task 02's vector branch runs in-process against the mock — the same LLM the app under test talks to). A coroutine: the caller runs it through :func:`_run_in_thread` (Playwright owns the test thread's loop).""" settings = Settings( # the owner's .env cannot leak the vector knobs (_env_file is a # runtime-only pydantic-settings kwarg — pyright cannot see it). _env_file=None, # pyright: ignore[reportCallIssue] llm_base_url=f"http://127.0.0.1:{mock_llm}/v1", images=True, # the toggle under test (LOCKED A7's on state) image_dir=str(_image_dir(scratch)), image_extensions=Settings.model_fields["image_extensions"].default, ) return await import_sources([tree], LLMClient(settings)) def _run_in_thread(coro: Any) -> Any: """Run a coroutine on a worker thread — Playwright's sync API keeps an asyncio loop on the test thread (the house helper, phase 180).""" 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 _truncate() -> None: """Fresh KB per test (the suites share one Postgres). ``sources_meta`` resets with the KB — the re-import's bump UPSERTS the row back (the phase-122 truncate list, ``bump_sources_version``).""" with SessionLocal() as db: db.execute( text( "TRUNCATE chunks, documents, query_log, steering_notes, " "kb_overview, folder_summaries, sources_meta" ) ) db.commit() def _seed(mock_llm: int, scratch: Path) -> ImportSummary: """Truncate + import the fresh fixture copy. Both SVGs arrive through the VECTOR branch (``svg_indexed == 2`` — no vision call happened), infra.md as the text co-citation: three docs total.""" _truncate() summary = _run_in_thread(_import(_fresh_tree(scratch), mock_llm, scratch)) assert (summary.files, summary.added, summary.images_failed) == (3, 3, 0) assert summary.svg_indexed == 2, summary return summary def _doc(source: str, path: str) -> Document: with SessionLocal() as db: doc = db.scalar( select(Document).where(Document.source == source, Document.path == path) ) assert doc is not None, f"no documents row for ({source!r}, {path!r})" return doc def _health_db(app_url: str) -> bool: try: return httpx.get(f"{app_url}/api/health", timeout=5).json()["db"] == "up" except Exception: # noqa: BLE001 — unreachable is the skip case return False def _skip_without_db(app_url: str) -> None: if not _health_db(app_url): pytest.skip("Postgres not reachable — run `podman compose up -d db` first") def _drill(page: Page, name: str) -> None: """One level of the KB tree (the phase-122/180 click, made settle- safe): the drill keeps rendering the CURRENT level while the node fetch runs (phase 167), so the level is settled exactly when the clicked link is gone from the replaced table (intermediate levels with no direct files must not wait on an empty ``#docs-tbody``).""" link = page.locator(f'#folders-tbody a.folder-link:text-is("{name}")') expect(link).to_be_visible(timeout=30_000) link.click() expect(link).to_have_count(0) def _open_doc_modal(page: Page, app_url: str, folder: str, doc_path: str) -> Any: """Sources → source → folder → the document's row → the same-page modal. Returns the modal locator (loaded).""" login(page, app_url) # lands on /sources.html (admin) _drill(page, SOURCE) _drill(page, folder) row = page.locator("#docs-tbody tr", has_text=doc_path) expect(row).to_have_count(1) row.locator("td:nth-child(2) a.doc-link").click() modal = page.locator("#doc-modal") expect(modal).to_be_visible(timeout=30_000) return modal def _seed_page(page: Page, app_url: str, mock_llm: int, scratch: Path) -> None: _skip_without_db(app_url) page.set_default_timeout(30_000) _seed(mock_llm, scratch) # --------------------------------------------------------------------------- # 1. The viewer: the SVG paints as a graphic — the XML is nowhere # --------------------------------------------------------------------------- def test_viewer_renders_svg_not_xml( page: Page, app_url: str, mock_llm: int, scratch: Path ) -> None: """TODO.md L3's viewer half: the modal shows the RENDERED vector graphic (the ``--vector`` sizing class, task 04) from the bytes route, alt = the extracted text; NO raw-XML surface exists — no ``pre.doc-raw``, no ```` sentence reads as the description below the graphic. The "Full page" escape hatch (the ``/document.html`` viewer) spans the drawing: ≥ 600px wide at a 1280px viewport, where the 300×150 replaced-element default box would have sat small (the phase's sizing contract).""" _seed_page(page, app_url, mock_llm, scratch) doc = _doc(SOURCE, TOPO_PATH) assert doc.is_image is True # the premise: a vector IMAGE row modal = _open_doc_modal(page, app_url, "diagrams", TOPO_PATH) expect(page.locator("#doc-modal-title")).to_have_text("topology") img = modal.locator("img.doc-image-img--vector") expect(img).to_have_count(1) expect(img).to_be_visible(timeout=15_000) assert img.evaluate("el => el.naturalWidth") > 0, "the SVG bytes did not paint" assert img.evaluate("el => el.naturalHeight") > 0 alt = img.get_attribute("alt") assert alt is not None and "Homelab network topology" in alt assert img.get_attribute("src") == f"/api/documents/{doc.id}/image" expect(modal.locator(".doc-image-unavailable")).to_have_count(0) # The honest negative (the owner's complaint, verbatim): NO XML text # and no raw-code surface for an SVG row. expect(modal.locator("pre.doc-raw")).to_have_count(0) body_text = modal.inner_text() assert "" not in body_text # The extracted words ARE the document's readable content, below # the graphic (the phase-165 prose arm — summary === content). prose = modal.locator(".doc-prose") expect(prose).to_have_count(1) expect(prose).to_contain_text("Homelab network topology") expect(prose).to_contain_text("rack-1 switch") # The Full page escape hatch: the same graphic, full column width. href = page.locator("#doc-modal-open").get_attribute("href") assert href is not None and "/document.html" in href page.goto(app_url + href) viewer_img = page.locator(".doc-image-img--vector") expect(viewer_img).to_be_visible(timeout=15_000) box = viewer_img.bounding_box() assert box is not None and box["width"] >= 600, ( f"the viewBox-only SVG did not span the viewer: {box!r}" ) # --------------------------------------------------------------------------- # 2. The chat figure + the click-through preview in the RAG tab # --------------------------------------------------------------------------- def test_chat_figure_and_modal_preview( page: Page, app_url: str, mock_llm: int, scratch: Path ) -> None: """TODO.md L3's citation half: the grounded question whose answer cites the SVG doc (the scripted read echoes the read result — the ``IMAGE_DOC_MARKER`` line + the extracted words) shows the compact ``.source-image`` figure whose ```` LOADS from the bytes route with the caption settling to the extracted text; clicking the figure opens the SAME-page modal showing the full graphic (the "click an svg … see a preview in the RAG tab rather than the xml" contract) and Escape closes it.""" _seed_page(page, app_url, mock_llm, scratch) doc = _doc(SOURCE, TOPO_PATH) login(page, app_url, next="/") expect(page.locator("#kb-banner")).to_be_hidden() page.fill("#message-input", QUESTION) page.click("#send-btn") bubble = page.locator(".msg.brain .bubble").last expect(bubble).to_contain_text(IMAGE_DOC_MARKER, timeout=30_000) expect(bubble).to_contain_text("Homelab network topology", timeout=30_000) expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) expect(page.locator(".msg.brain.is-deflected")).to_have_count(0) # The citation figure (phase 130: the figure IS the citation). # Scoped by the figure's aria-label (the source/path label) — the # seed also carries blank.svg, and an image doc the seed surfaces # is auto-cited too (phase 124); the ASSERTED figure is the # topology doc's. row = page.locator(".msg.brain .msg-meta.msg-images") expect(row).to_have_count(1) fig = row.locator(f'.source-image[aria-label="{SOURCE}/{TOPO_PATH}"]') expect(fig).to_have_count(1) fig_img = fig.locator(".source-image-img") expect(fig_img).to_be_visible(timeout=15_000) assert fig_img.evaluate("el => el.naturalWidth") > 0, ( "the chat figure did not load the SVG bytes" ) assert fig_img.get_attribute("src") == f"/api/documents/{doc.id}/image" caption = fig.locator(".source-image-caption") expect(caption).to_contain_text("Homelab network topology", timeout=15_000) # The click → modal preview: the full graphic, no XML, and Escape. fig.click() modal = page.locator("#doc-modal") expect(modal).to_be_visible(timeout=30_000) modal_img = modal.locator("img.doc-image-img--vector") expect(modal_img).to_have_count(1) expect(modal_img).to_be_visible(timeout=15_000) assert modal_img.evaluate("el => el.naturalWidth") > 0 assert " None: """LOCKED A2: ``diagrams/blank.svg`` carries NO words — pure paths — yet it is NOT skipped: it exists as an image document whose indexed text is the structural line built only from facts in the file (``Vector diagram "blank" — shapes (…), viewBox 640×480``), the line is embedded (searchable), and the Sources page lists the doc with its rendered thumbnail; the modal shows the graphic with that line as the description below it.""" _seed_page(page, app_url, mock_llm, scratch) doc = _doc(SOURCE, BLANK_PATH) assert doc.is_image is True and doc.image_path is not None assert doc.content.startswith('Vector diagram "blank" —') assert "viewBox 640×480" in doc.content assert doc.summary == doc.content # Searchable: the structural line is an EMBEDDED chunk (both the # content chunk and the is_summary mirror). with SessionLocal() as db: chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all() assert [c.content for c in chunks] and all( c.content == doc.content for c in chunks ) assert all(c.embedding is not None and len(c.embedding) == 768 for c in chunks) login(page, app_url) _drill(page, SOURCE) _drill(page, "diagrams") row = page.locator("#docs-tbody tr", has_text=BLANK_PATH) expect(row).to_have_count(1) thumb = row.locator(".kb-doc-thumb-img") expect(thumb).to_have_count(1) expect(thumb).to_be_visible(timeout=15_000) assert thumb.evaluate("el => el.naturalWidth") > 0, "the thumbnail is not painted" # The modal: the graphic + the honest structural description (the # file's only possible words). row.locator("td:nth-child(2) a.doc-link").click() modal = page.locator("#doc-modal") expect(modal).to_be_visible(timeout=30_000) expect(modal.locator("img.doc-image-img--vector")).to_have_count(1) expect(modal.locator(".doc-prose")).to_contain_text('Vector diagram "blank"') expect(modal.locator("pre.doc-raw")).to_have_count(0) # --------------------------------------------------------------------------- # 4. The phase-180 synergy: ![alt](/api/documents//image) paints # --------------------------------------------------------------------------- def test_markdown_image_ref_renders( page: Page, app_url: str, mock_llm: int, scratch: Path ) -> None: """The two-stage seeding the phases' hand-off needs: import the tree, read the SVG doc's id from the DB, THEN write ``notes/inline.md`` containing ``![topology](/api/documents//image)`` and re-import (one doc added, the SVG row untouched — the fast path). The viewer for that document renders ``img.md-img`` pointing at the bytes route and LOADING it (``naturalWidth > 0``) — phase 180's allowlist accepts the route shape, phase 181's route answers it, and the reference never prints as text.""" _seed_page(page, app_url, mock_llm, scratch) topo = _doc(SOURCE, TOPO_PATH) image_url = f"/api/documents/{topo.id}/image" assert extract_svg_text # the extractor produced the row's text (premise) tree = scratch / "src" / SOURCE (tree / "notes" / "inline.md").write_text( "# Inline reference notes\n\n" "The topology drawing, inline:\n\n" f"![topology]({image_url})\n\n" "Same route, inside a link: [the diagram](/sources.html).\n", encoding="utf-8", ) second = _run_in_thread(_import(tree, mock_llm, scratch)) assert second.added == 1, second assert second.svg_indexed == 0, "the unchanged SVG must take the fast path" modal = _open_doc_modal(page, app_url, "notes", "notes/inline.md") md_img = modal.locator("img.md-img") expect(md_img).to_have_count(1) assert md_img.get_attribute("src") == image_url assert md_img.get_attribute("alt") == "topology" expect(md_img).to_be_visible(timeout=15_000) assert md_img.evaluate("el => el.naturalWidth") > 0, ( "the bytes route did not answer the markdown reference" ) # The reference renders — it never prints. assert "![topology](" not in modal.inner_text() # --------------------------------------------------------------------------- # 5. The smoke guard: raster image docs are byte-unchanged # --------------------------------------------------------------------------- def test_raster_surfaces_unchanged( page: Page, app_url: str, mock_llm: int, scratch: Path ) -> None: """Phase 122/124 behaviour survives the vector branch: a PNG doc in the SAME tree is described through the vision seam (the mock's fixed answer), renders through the plain ``.doc-image-img`` with NO ``--vector`` class, and its bytes route carries NO sandbox CSP — the app-wide middleware policy only, the pre-phase header set.""" _seed_page(page, app_url, mock_llm, scratch) tree = scratch / "src" / SOURCE (tree / "diagrams" / "raster.png").write_bytes(PNG_1X1) summary = _run_in_thread(_import(tree, mock_llm, scratch)) assert (summary.files, summary.added) == (4, 1) assert summary.svg_indexed == 0 doc = _doc(SOURCE, "diagrams/raster.png") assert doc.is_image is True image_url = f"/api/documents/{doc.id}/image" login(page, app_url) _drill(page, SOURCE) _drill(page, "diagrams") row = page.locator("#docs-tbody tr", has_text="diagrams/raster.png") expect(row).to_have_count(1) row.locator("td:nth-child(2) a.doc-link").click() modal = page.locator("#doc-modal") expect(modal).to_be_visible(timeout=30_000) img = modal.locator(".doc-image-img") expect(img).to_have_count(1) expect(img).to_be_visible(timeout=15_000) # The raster class string is UNTOUCHED by the vector modifier. assert (img.get_attribute("class") or "") == "doc-image-img" expect(modal.locator("img.doc-image-img--vector")).to_have_count(0) resp = page.request.get(app_url + image_url) assert resp.status == 200 assert resp.headers["content-type"] == "image/png" csp = resp.headers.get("content-security-policy", "") assert "sandbox" not in csp, "the SVG sandbox policy leaked onto a raster route"