"""Phase 122 E2E (Playwright): standalone image documents — the user path. Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_image_documents.py -v --no-cov The suite's app instance runs with ``BOR_IMAGES=true`` (module env override — the conftest per-suite-app pattern, leak guards included): the standalone PNG arrives through the REAL admin upload flow (a zip with a single image member — phase 90's no-scan contract: the upload unpacks + registers, the RAG page's "Sync sources" button scans), the mock's vision model (``IMAGE_DESCRIPTION_MODE`` branch — ``tests/e2e/mock_llm.py``) writes the description, and the document is first-class at every surface: * the Sources page lists it with the 48px thumbnail (the image bytes route, alt = the summary); * the document viewer renders the image with the description below it (the summary panel is suppressed — the summary IS the description); * a grounded chat question shows the compact inline figure in the answer's sources block (image + the summary caption, the "shown in the chat nicely" contract). The negative case drives a SECOND module app with ``BOR_IMAGES`` forced ``false`` (the DEFAULT contract — an operator's local ``.env`` cannot leak the toggle in either direction: both apps pin the value explicitly): the same upload + sync produces NO image document (the walk is blind to the file, the source row stays a 0-document source). """ from __future__ import annotations import base64 import json import os import re import subprocess import sys import zipfile from collections.abc import Iterator from pathlib import Path import httpx import pytest from playwright.sync_api import Browser, Page, expect from sqlalchemy import select, text from app.config import Settings from app.db import SessionLocal from app.models import Document from app.rag.agent import IMAGE_DOC_MARKER from e2e.auth_helpers import login from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER REPO = Path(__file__).resolve().parents[2] GIT_SOURCES_URL = "/git-sources.html" SOURCE_NAME = "e2e-image" # the archive stem (archive_source_name) DOC_PATH = "pic.png" #: The grounded question, carrying the house scripted-read call #: (``SUMMARY_SEED_READ_TRIGGER`` — the phase-119 A1 convention: a #: zero-read grounded turn chips NOTHING, so the image doc must be #: READ to earn its citation chip + figure): the mock emits the #: scripted ``read e2e-image/pic.png``, then echoes the tool result. #: Grounding: the mock's token-overlap cosine against the #: ``IMAGE_DESCRIPTION_ANSWER`` description is ≈0.31 (over the #: mock-calibrated gate (0.30), and the FTS leg corroborates — #: homelab/network/diagram all hit — the 0.15 floor backstop). IMAGE_QUESTION = ( "Read the suggested document: read e2e-image/pic.png — " "what is shown in the homelab network diagram?" ) #: A real 1×1 transparent PNG (the unit/integration suites' fixture — #: the pipeline is content-agnostic, the well-formed bytes keep the #: upload + serve + render path honest). PNG_1X1 = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" ) UPLOAD_TIMEOUT_MS = 30_000 SYNC_TIMEOUT_MS = 60_000 SYNCED_LABEL = re.compile(r"^Synced \d{1,2}:\d{2}$") # --------------------------------------------------------------------------- # Module apps: images ON (the story) and images forced OFF (the default # contract). Each owns its port + its scratch upload/source/image dirs. # --------------------------------------------------------------------------- def _app_env(mock_llm: int, app_port: int, *, images: bool, scratch: Path) -> dict[str, str]: """The conftest app env (leak guards included) with the phase-122 knobs: ``BOR_IMAGES`` pinned EXPLICITLY (true for the story app, false for the default app — process env ranks above an operator's local gitignored ``.env``, so the contract under test cannot leak in either direction) and the image/upload homes in the suite's scratch dir (the app under test must not write image copies into the owner's real ``~/bor-sources``).""" env = dict(os.environ) env.pop("DEBUGPY", None) env["BOR_ENVIRONMENT"] = "e2e" env["BOR_STATIC_DIR"] = str(REPO / "frontend") env["BOR_LLM_BASE_URL"] = ( "https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_llm}/v1" ) # Mock-calibrated gate (conftest pattern) — the story's question # grounds on these values (see IMAGE_QUESTION). env["BOR_RELEVANCE_THRESHOLD"] = "0.30" env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15" env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15" # Phase 67: instant retry waits + the code-default budget. 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 122: the toggle under test + the suite-private homes. env["BOR_IMAGES"] = "true" if images else "false" env["BOR_UPLOAD_DIR"] = str(scratch / "uploads") env["BOR_SOURCES_DIR"] = str(scratch / "checkouts") env["BOR_IMAGE_DIR"] = str(scratch / "images") return env @pytest.fixture(scope="module") def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]: """The story app — ``BOR_IMAGES=true`` (the module env override; the conftest session app is never started in this isolated run, so no port clash).""" scratch = tmp_path_factory.mktemp("bor_image_on") port = int(os.environ.get("E2E_APP_PORT_IMAGES", "8150")) 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, images=True, scratch=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 @pytest.fixture(scope="module") def default_app_server( mock_llm: int, tmp_path_factory: pytest.TempPathFactory ) -> Iterator[str]: """The default-contract app — ``BOR_IMAGES=false`` (the LOCKED A3 default; only the negative test starts it).""" scratch = tmp_path_factory.mktemp("bor_image_off") port = int(os.environ.get("E2E_APP_PORT_IMAGES_OFF", "8151")) 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, images=False, scratch=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 default_app_url(default_app_server: str) -> str: return default_app_server @pytest.fixture(scope="module") def zip_path(tmp_path_factory: pytest.TempPathFactory) -> Path: """The fixture archive: ONE standalone PNG at the root — source ``e2e-image``, document path ``pic.png``.""" root = tmp_path_factory.mktemp("bor_image_zip") (root / DOC_PATH).write_bytes(PNG_1X1) archive = root / f"{SOURCE_NAME}.zip" with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf: zf.write(root / DOC_PATH, arcname=DOC_PATH) return archive # --------------------------------------------------------------------------- # DB + UI helpers # --------------------------------------------------------------------------- def _truncate_all() -> None: """Fresh KB + registry per test (the E2E isolation pattern): the suites share one Postgres, and a leftover row would corrupt the counts. ``sources_meta`` (the KB generation counter) resets with the KB — the sync re-creates the row.""" with SessionLocal() as db: db.execute(text( "TRUNCATE chunks, documents, query_log, kb_overview, " "git_sources, sources_meta, folder_summaries" )) db.commit() 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 _upload_zip(page: Page, app_url: str, archive: Path) -> None: """The real admin upload flow: form login → the git-sources page → pick the archive → submit → the terminal result line (phase 90: "Uploaded — press Sync sources to import it.").""" login(page, app_url, next=GIT_SOURCES_URL) expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) page.set_input_files("#archive-upload-file", str(archive)) page.click("#archive-upload-btn") result = page.locator("#archive-upload-result") expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) expect(result).to_have_text( f"Uploaded {SOURCE_NAME} — press Sync sources to import it.", timeout=UPLOAD_TIMEOUT_MS, ) def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None: """The RAG page's "Sync sources" button (the scan — phase 90 A3): click → "Syncing…" → the terminal "Synced HH:MM" label + the fresh counts in #sync-result (the never-stale contract, test_git_source_ dates' lifecycle wait).""" page.goto(app_url + "/sources.html") btn = page.locator("#sync-btn") expect(btn).to_be_visible(timeout=30_000) expect(page.locator("#sync-label")).to_have_text( re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$") ) btn.click() expect(btn).to_be_disabled() expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(btn).to_be_enabled() expect(page.locator("#sync-result")).to_have_text(expected_result) def _drill_to_source(page: Page, name: str) -> None: """Top level → the source (the file table then holds its direct files — ours is at the source root).""" page.locator("#folders-tbody .folder-link").first.wait_for(state="visible") page.click(f'#folders-tbody a.folder-link:text-is("{name}")') expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=30_000) # --------------------------------------------------------------------------- # The module seed: upload + sync the fixture image through the real UI # (the story E2E truncate/re-import-per-module pattern). # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def seeded( app_url: str, browser: Browser, zip_path: Path, ) -> Iterator[dict[str, str]]: """Upload the single-PNG zip through the admin UI, then scan it with the RAG page's Sync button (the mock's vision model describes the image in-process). Yields the seeded doc's identity (the bytes route + the description) for the render assertions. The module app's ``/api/config`` flag is asserted first — the env override must have reached the app under test, or the whole premise of the suite is void.""" if not _health_db(app_url): pytest.skip("Postgres not reachable — run `podman compose up -d db` first") cfg = httpx.get(f"{app_url}/api/config", timeout=5).json() assert cfg["images"] is True, "the module app must run with BOR_IMAGES=true" _truncate_all() page = browser.new_page(viewport={"width": 1280, "height": 800}) try: _upload_zip(page, app_url, zip_path) _sync_via_ui(page, app_url, expected_result="1 added") with SessionLocal() as db: doc = db.scalar( select(Document).where( Document.source == SOURCE_NAME, Document.path == DOC_PATH ) ) assert doc is not None, "the uploaded image must become a document" assert doc.is_image is True and doc.image_path is not None # content == summary == the mock's description (the ONLY # embedded text of the doc — the pipeline contract, LOCKED A3). assert doc.content == IMAGE_DESCRIPTION_ANSWER assert doc.summary == IMAGE_DESCRIPTION_ANSWER finally: page.close() yield { "image_url": f"/api/documents/{doc.id}/image", "description": IMAGE_DESCRIPTION_ANSWER, } _truncate_all() # --------------------------------------------------------------------------- # 1. Sources page: the image doc is listed with its thumbnail # --------------------------------------------------------------------------- def test_sources_page_lists_the_image_doc( page: Page, app_url: str, seeded: dict[str, str] ) -> None: """The KB total is the image doc (1 doc, 2 chunks — the one content chunk + the phase-30 ``is_summary`` chunk), the drill-down tree reaches it, and its row carries the FIXED 48px thumbnail box: a lazy ```` from the bytes route (not the glyph fallback — the fixture PNG is served), ``alt`` = the summary (the WCAG contract).""" login(page, app_url) # lands on /sources.html expect(page.locator("#stat-docs")).to_have_text("1") expect(page.locator("#stat-chunks")).to_have_text("2") expect(page.locator("#sources-empty")).to_be_hidden() _drill_to_source(page, SOURCE_NAME) row = page.locator("#docs-tbody tr", has_text=DOC_PATH) expect(row).to_have_count(1) box = row.locator(".kb-doc-thumb") expect(box).to_be_visible() img = row.locator(".kb-doc-thumb-img") expect(img).to_have_count(1) expect(img).to_be_visible(timeout=15_000) # the fetch resolves — no glyph expect(img).to_have_attribute("src", seeded["image_url"]) expect(img).to_have_attribute("loading", "lazy") expect(img).to_have_attribute("alt", seeded["description"]) expect(row.locator(".kb-doc-thumb-glyph")).to_have_count(0) # --------------------------------------------------------------------------- # 2. Document viewer: the image renders, the description below it # --------------------------------------------------------------------------- def test_document_viewer_renders_image_and_description( page: Page, app_url: str, seeded: dict[str, str] ) -> None: """The Sources row's path link opens the same-page document modal (phase 26): the ``is_image`` content block renders the PERSISTENT bytes first (``.doc-image-img`` from the bytes route, ``alt`` = the summary), and the description follows in the normal content slot (``pre.doc-raw`` — the plain-content path). The labeled Summary panel is SUPPRESSED for the verbatim-description case (summary === content — the importer invariant; the panel would duplicate the text right below the image).""" login(page, app_url) _drill_to_source(page, SOURCE_NAME) page.locator(f'#docs-tbody a.doc-link:text-is("{DOC_PATH}")').click() modal = page.locator("#doc-modal") expect(modal).to_be_visible(timeout=30_000) expect(page.locator("#doc-modal-title")).to_have_text("pic") img = modal.locator(".doc-image-img") expect(img).to_have_count(1) expect(img).to_be_visible(timeout=15_000) # the bytes route serves the PNG expect(img).to_have_attribute("src", seeded["image_url"]) expect(img).to_have_attribute("alt", seeded["description"]) # The description below the image (the doc's readable content IS # the vision description). expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"]) # The verbatim case: no duplicate Summary panel. expect(modal.locator(".doc-summary")).to_have_count(0) # No "Image unavailable" note — the copy is intact. expect(modal.locator(".doc-image-unavailable")).to_have_count(0) # --------------------------------------------------------------------------- # 3. Chat: a grounded question shows the inline image in the sources # --------------------------------------------------------------------------- def test_chat_sources_block_shows_the_inline_image( page: Page, app_url: str, seeded: dict[str, str] ) -> None: """A question the mock grounds on the image doc (cosine ≈0.31, FTS-corroborated), scripted to READ it (phase 119 A1 — a zero-read turn chips nothing): the mock's echo carries the agent ``read`` result VERBATIM — including the task-05 ``IMAGE_DOC_MARKER`` line (the model saw the description, not raw bytes) — and the answer's sources block carries the citation chip (the READ doc clears the usefulness floor) AND the compact inline figure — the ```` from the bytes route, the visible caption + ``alt`` settling to the document's summary (the figure fetches it from the content endpoint — the frame carries no summary), the "shown in the chat nicely" contract.""" login(page, app_url, next="/") expect(page.locator("#kb-banner")).to_be_hidden() page.fill("#message-input", IMAGE_QUESTION) page.click("#send-btn") bubble = page.locator(".msg.brain .bubble").last # The scripted read's echoed result — the marker line (task 05) # and the description itself (the model's view of the image). expect(bubble).to_contain_text(IMAGE_DOC_MARKER, timeout=30_000) expect(bubble).to_contain_text(seeded["description"], timeout=30_000) expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) expect(page.locator("#send-label")).to_have_text("Send") # The citation chip (the text affordance stays — the figure is # additive, not a replacement). chip = page.locator(".msg.brain .source-chip") expect(chip).to_have_count(1) expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}") # The inline figure: image + caption, both settling to the summary # (the content fetch resolves the alt/caption after the title). fig = page.locator(".msg.brain .source-image") expect(fig).to_have_count(1) img = fig.locator(".source-image-img") expect(img).to_be_visible(timeout=15_000) expect(img).to_have_attribute("src", seeded["image_url"]) expect(img).to_have_attribute("alt", seeded["description"], timeout=15_000) caption = fig.locator(".source-image-caption") expect(caption).to_have_text(seeded["description"], timeout=15_000) # --------------------------------------------------------------------------- # 4. The default contract: BOR_IMAGES off (the default) → the same # upload + sync produces NO image document # --------------------------------------------------------------------------- def test_default_env_upload_produces_no_image_doc( page: Page, default_app_url: str, zip_path: Path ) -> None: """The off-by-default contract end-to-end: with ``BOR_IMAGES`` false, the SAME upload + sync is byte-identical to the pre-phase walk — the PNG is invisible to the scan (0 files, 0 added), the source row stays a registered 0-document source, and NO documents row (let alone an ``is_image`` one) exists. The file still lands on disk (the unpack is unchanged — only the walk filter changes).""" if not _health_db(default_app_url): pytest.skip("Postgres not reachable — run `podman compose up -d db` first") cfg = httpx.get(f"{default_app_url}/api/config", timeout=5).json() assert cfg["images"] is False, "the default app must run with images off" _truncate_all() try: _upload_zip(page, default_app_url, zip_path) _sync_via_ui( page, default_app_url, expected_result="0 added · 0 unchanged" ) # No document at all — the image file is not even a "file" to # the images-off walk (not unknown, not indexed). with SessionLocal() as db: assert ( db.scalar(select(Document).where(Document.is_image.is_(True))) is None ) assert db.scalar(select(Document).where(Document.source == SOURCE_NAME)) is None # The Sources page: 0 docs, the source row (registered) drills # to an EMPTY file table. page.goto(default_app_url + "/sources.html") expect(page.locator("#stat-docs")).to_have_text("0") expect(page.locator("#stat-chunks")).to_have_text("0") page.locator("#folders-tbody .folder-link").first.wait_for(state="visible") page.click(f'#folders-tbody a.folder-link:text-is("{SOURCE_NAME}")') expect(page.locator("#docs-tbody tr")).to_have_count(0) finally: _truncate_all()