**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**; ⑤`` 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`.
608 lines
26 KiB
Python
608 lines
26 KiB
Python
"""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 ````
|
||
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:  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 ```` 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"\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 "
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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"
|