Files
brain-of-reese/tests/e2e/test_svg_document_preview.py
T
ducoterra 814a400e6d
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 181_svg_document_preview
**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**

- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`![alt](/api/documents/<id>/image)` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
2026-10-14 22:54:43 -04:00

608 lines
26 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 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 ``<svg`` XML text anywhere in the modal
body, the extracted ``<title>`` 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
``<img>`` 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/<id>/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 ``<scratch>/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 ``<svg`` markup text anywhere in the modal body
— and the extracted ``<title>`` 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 "<svg" not in body_text, "raw XML text painted into the modal"
assert "</title>" 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 ``<img>`` 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 "<svg" not in modal.inner_text()
page.keyboard.press("Escape")
expect(modal).to_be_hidden()
# ---------------------------------------------------------------------------
# 3. The textless drawing: indexed with the structural line, previewable
# ---------------------------------------------------------------------------
def test_blank_svg_is_indexed_and_searchable(
page: Page, app_url: str, mock_llm: int, scratch: Path
) -> 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" — <N> 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/<id>/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/<id>/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"