"""Phase 123 E2E (Playwright): chat image questions — attach an image to a question (TODO L6). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_chat_image_questions.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 question-image store is a suite-private scratch dir so the app under test never writes into the owner's real ``~/bor-sources``), and a SECOND module app runs with ``BOR_IMAGES`` forced ``false`` (the default contract — both apps pin the toggle EXPLICITLY, so an operator's local ``.env`` cannot leak it in either direction). The KB is deliberately NOT seeded (each test truncates it): the question-image turn is a DETERMINISTIC deflection (no chunks → LOW → the mock's honest "I haven't done anything like that" answer) — the deflected branch is a construction site for the multimodal user message (pinned by ``app/api/chat.py``'s docstring), and the answer the mock streams is independent of the image part (the mock's ``_content_text`` maps a part list to its text parts). * attach a fixture PNG in the composer → the preview strip shows (thumbnail ≤48px + the filename) → remove → the strip clears and the file state is gone (a fresh pick re-renders in place); * re-attach → send → exactly ONE upload, the user bubble shows the image (the live data URL, alt = the filename), the preview strip must not linger into the turn, the mock's (text-only) answer streams, and — via the mock's capture (``/v1/e2e/captured``) — the model RECEIVED the multimodal user content list (the text part == the question + the ``image_url`` data URL that decodes to the uploaded bytes); * the saved record carries the stored PATH (A5: never base64 — nothing base64 crosses the localStorage boundary); * ``page.reload()`` → the user bubble restores WITH its image from the stored path (the image route's request count confirms a PATH fetch, not an inline data URL); * share the chat → a fresh anonymous context opening the shared link sees the user's image on the shared page (the serve route is public — the shared view is faithful); * default-off negative (the flag-off app): ``#attach-btn`` stays hidden for good (the default-off contract) and a direct ``POST /api/chat`` with an ``image`` settles the hinted error frame with ZERO model calls (the capture stays empty). """ from __future__ import annotations import base64 import json import os import re import subprocess import sys import time from collections.abc import Iterator from pathlib import Path from typing import Any import httpx import pytest from playwright.sync_api import Page, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal from e2e.auth_helpers import login from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http REPO = Path(__file__).resolve().parents[2] #: 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 + capture pins honest). PNG_1X1 = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" ) PNG_NAME = "diagram.png" QUESTION = "What is in this screenshot? (chat-images)" TEXT_ONLY_QUESTION = "How is my Kubernetes cluster set up? (chat-images text-only)" STORAGE_KEY = "bor.chat.v1" DEFLECT_PHRASE = r"haven't done anything like that" IMAGE_PATH_RE = re.compile(r"^/api/chat-images/[0-9a-f]{32}\.png$") SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$") TURN_TIMEOUT_MS = 30_000 # --------------------------------------------------------------------------- # Module apps: images ON (the story) and images forced OFF (the default # contract). Each owns its port + its scratch question-image dir. # --------------------------------------------------------------------------- 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-123 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 question-image store in the suite's scratch dir (the app under test must not write 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) — with an UNSEEDED KB # (this suite's determinism) every turn is a deflection anyway; # the pins keep the gate's quadrant stable if the dev KB leaks in. 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_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 123: the toggle under test + the suite-private image store. env["BOR_IMAGES"] = "true" if images else "false" env["BOR_CHAT_IMAGE_DIR"] = str(scratch / "chat-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_chat_image_on") port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES", "8160")) 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`` (only the negative test starts it).""" scratch = tmp_path_factory.mktemp("bor_chat_image_off") port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES_OFF", "8161")) 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 def _reset_db(mock_port: int, seed: bool) -> None: """Truncate the KB (and the turn log) so every turn in this suite is a deterministic deflection. ``seed`` is always False here — a question image is a separate concern from document ingestion (it is NEVER indexed as a document), so the suite never imports.""" with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes")) db.commit() assert seed is False # --------------------------------------------------------------------------- # Browser helpers (the composer's attach flow + the turn's settle) # --------------------------------------------------------------------------- def _png_file(tmp_path: Path) -> Path: png = tmp_path / PNG_NAME png.write_bytes(PNG_1X1) return png def _attach(page: Page, png: Path) -> None: """One file pick in the composer's HIDDEN file input (the paperclip button's backend — the native picker is replaced by ``set_input_files``, the E2E's standard input simulation): the preview strip must reveal with the data-URL thumbnail + name.""" page.set_input_files("#attach-file", str(png)) strip = page.locator("#attach-preview") expect(strip).to_be_visible(timeout=TURN_TIMEOUT_MS) expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME) thumb = page.locator("#attach-preview img") src = thumb.get_attribute("src") or "" assert src.startswith("data:image/png;base64,"), "the thumbnail is the live data URL" def _wait_deflected_turn(page: Page) -> None: """Wait until the (unseeded-KB) turn has fully settled — the deflected answer streamed and the Send button is back (the ``done`` frame restored it).""" bubble = page.locator(".msg.brain.is-deflected .bubble").first bubble.wait_for(state="visible", timeout=TURN_TIMEOUT_MS) expect(bubble).to_contain_text( re.compile(DEFLECT_PHRASE, re.IGNORECASE), timeout=TURN_TIMEOUT_MS ) expect(page.locator("#send-btn")).to_be_enabled() expect(page.locator("#send-label")).to_have_text("Send") def _stored(page: Page) -> dict[str, Any] | None: raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") return json.loads(raw) if raw else None def _admin_cookies(page: Page) -> dict[str, str]: return { c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c } def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]: r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies) assert r.status_code == 200 return r.json()["chats"] def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None: return next((c for c in rows if c["title"] == title), None) def _auto_title(question: str) -> str: """The phase-50 auto-title convention: the first question, whitespace-collapsed, capped at 120 chars.""" return " ".join(question.split())[:120] def _wait_saved_row( app_url: str, cookies: dict[str, str], title: str, messages: int = 2, ) -> dict[str, Any]: """Wait for the auto-saved row (phase 55: auto-saves are SILENT — A2 — so there is no status line to wait on).""" deadline = time.monotonic() + 15 last: dict[str, Any] | None = None while time.monotonic() < deadline: last = _find_row(_chats(app_url, cookies), title) if last is not None and last["message_count"] >= messages: return last time.sleep(0.2) raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})") def _delete_row(app_url: str, cookies: dict[str, str], chat_id: str) -> None: """Best-effort row cleanup (a 404 — already deleted — is fine).""" httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) def _send_with_attachment(page: Page, png: Path) -> tuple[str, list[str]]: """Attach → type → send → wait for the deflected settle. Returns (the record's stored image PATH, the upload request URLs).""" uploads: list[str] = [] def _on_request(req: Any) -> None: if req.method == "POST" and req.url.endswith("/api/chat-images"): uploads.append(req.url) page.on("request", _on_request) _attach(page, png) page.fill("#message-input", QUESTION) page.click("#send-btn") img = page.locator(".msg.user .msg-image").first expect(img).to_be_visible(timeout=TURN_TIMEOUT_MS) _wait_deflected_turn(page) stored = _stored(page) assert stored is not None, "the conversation must be persisted (save point 1)" path = stored["messages"][0]["image"] assert IMAGE_PATH_RE.fullmatch(path), f"the record must carry the stored PATH: {path!r}" return path, uploads # --------------------------------------------------------------------------- # The mock's capture (phase 123, task 04 — the request the SERVER built) # --------------------------------------------------------------------------- def _mock_base(mock_llm: int) -> str: return f"http://127.0.0.1:{mock_llm}" def _reset_capture(mock_llm: int) -> None: r = httpx.post(f"{_mock_base(mock_llm)}/v1/e2e/captured/reset", timeout=10) assert r.status_code == 200 def _captured(mock_llm: int) -> list[dict[str, Any]]: r = httpx.get(f"{_mock_base(mock_llm)}/v1/e2e/captured", timeout=10) assert r.status_code == 200 return r.json() # --------------------------------------------------------------------------- # 1. Attach → preview → remove (the composer's draft state) # --------------------------------------------------------------------------- def test_attach_preview_shows_and_remove_clears( page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path ) -> None: _reset_db(mock_llm, seed=False) page.set_default_timeout(30_000) login(page, app_url, next="/") # Flag on (the story app): the paperclip is revealed at boot, with # its accessible name (the SVG is decorative). btn = page.locator("#attach-btn") expect(btn).to_be_visible() expect(btn).to_have_attribute("aria-label", "Attach an image") png = _png_file(tmp_path) _attach(page, png) # The strip: the data-URL thumbnail + the filename (the readable # label) + the remove ✕ (its accessible name). expect(page.locator("#attach-preview img")).to_be_visible() remove = page.locator("#attach-remove") expect(remove).to_be_visible() expect(remove).to_have_attribute("aria-label", "Remove the attached image") # Remove: the strip clears and the file state is GONE — a fresh # pick re-renders the strip in place (the state was truly reset, # not merely hidden under a stale one). page.click("#attach-remove") expect(page.locator("#attach-preview")).to_be_hidden() _attach(page, png) expect(page.locator("#attach-preview")).to_be_visible() expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME) # --------------------------------------------------------------------------- # 2. Send with an attachment: one upload, the image in the bubble, the # multimodal request at the mock, the PATH (never base64) in storage # --------------------------------------------------------------------------- def test_send_with_attached_image_delivers_the_multimodal_request( page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path ) -> None: _reset_db(mock_llm, seed=False) page.set_default_timeout(30_000) login(page, app_url, next="/") png = _png_file(tmp_path) _reset_capture(mock_llm) # exactly the requests of THIS turn path, uploads = _send_with_attachment(page, png) # A8's ordering at the wire level: EXACTLY one upload (the double- # fire guard never let a second through) and it preceded the turn. assert len(uploads) == 1 # The live user bubble: the data URL (no fetch), alt = the # filename; the preview strip must not linger into the turn. img = page.locator(".msg.user .msg-image").first src = img.get_attribute("src") or "" assert src.startswith("data:image/png;base64,"), "the live bubble uses the data URL" assert base64.b64decode(src.split(",", 1)[1]) == PNG_1X1 assert img.get_attribute("alt") == PNG_NAME expect(page.locator("#attach-preview")).to_be_hidden() # The mock (text-only) answer streamed normally (the mock ignores # the image part) — and the REQUEST it received is the multimodal # user content list: text part == the question + the image_url # data URL that decodes to the uploaded bytes (the server built # it from the stored file + the phase-122 mime map). captured = _captured(mock_llm) assert len(captured) == 1, "the deflected turn is exactly one model request" user = captured[0]["messages"][-1] assert user["role"] == "user" assert user["content"] == [ {"type": "text", "text": QUESTION}, { "type": "image_url", "image_url": { "url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}" }, }, ] # A5 at the storage boundary: the record carries the PATH, and # NOTHING base64 crossed into the localStorage payload. stored = _stored(page) assert stored is not None assert stored["messages"][0]["image"] == path assert "base64" not in json.dumps(stored) # Cleanup: drop the auto-saved row (the dev DB is shared). cookies = _admin_cookies(page) row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION)) _delete_row(app_url, cookies, row["id"]) # --------------------------------------------------------------------------- # 3. Reload: the user bubble restores WITH its image (from the stored # path — the image route's request count confirms the path fetch) # --------------------------------------------------------------------------- def test_reload_restores_the_image_from_the_stored_path( page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path ) -> None: _reset_db(mock_llm, seed=False) page.set_default_timeout(30_000) login(page, app_url, next="/") png = _png_file(tmp_path) path, _uploads = _send_with_attachment(page, png) # Track the image route's fetches from here on — the restored # bubble must load the image by FETCHING the stored path (not an # inline data URL). fetches: list[str] = [] def _on_request(req: Any) -> None: if "/api/chat-images/" in req.url: fetches.append(req.url) page.on("request", _on_request) page.reload() expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000) # The restored user bubble carries the image — its src is the # STORED PATH (the record's key), not the live data URL. img = page.locator(".msg.user .msg-image").first expect(img).to_be_visible(timeout=30_000) assert (img.get_attribute("src") or "") == path, "the restore renders from the stored path" # The path fetch must actually fire (the lazy img loading it) — # poll with a deadline. ``wait_for_timeout`` (not ``time.sleep``) # is the tick: the sync API dispatches the ``request`` events # queued during it, and a bare sleep would starve the listener. deadline = time.monotonic() + 10 while not any(u.rstrip("/").endswith(path) for u in fetches): if time.monotonic() > deadline: raise AssertionError( f"no fetch of the stored path ({len(fetches)} image requests: {fetches!r})" ) page.wait_for_timeout(100) # The rest of the conversation is unchanged. expect(page.locator(".msg.user .bubble")).to_have_count(1) expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION) expect(page.locator(".msg.brain .bubble")).to_have_count(1) expect(page.locator(".msg.brain .bubble")).to_contain_text( re.compile(DEFLECT_PHRASE, re.IGNORECASE) ) # Cleanup. cookies = _admin_cookies(page) row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION)) _delete_row(app_url, cookies, row["id"]) # --------------------------------------------------------------------------- # 4. Share: a fresh anonymous context sees the user's image on the # shared page (the public serve route — the shared view is faithful) # --------------------------------------------------------------------------- def test_shared_page_shows_the_user_image( page: Page, browser, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path ) -> None: _reset_db(mock_llm, seed=False) page.set_default_timeout(30_000) login(page, app_url, next="/") png = _png_file(tmp_path) path, _uploads = _send_with_attachment(page, png) cookies = _admin_cookies(page) row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION)) # The saved row's user record carries the PATH (the share's source # of truth — A5: never base64). detail = httpx.get(f"{app_url}/api/chats/{row['id']}", timeout=10, cookies=cookies) assert detail.status_code == 200 saved_user = detail.json()["messages"][0] assert saved_user["image"] == path # Share (the chat page's pill — the owner-locked one action): # grant the clipboard so the copy path runs (the status line is # the assertion surface). page.context.grant_permissions( ["clipboard-read", "clipboard-write"], origin=app_url ) page.locator("#share-chat-btn").click() expect(page.locator("#send-status")).to_have_text("Share link copied.", timeout=15_000) row = _find_row(_chats(app_url, cookies), _auto_title(QUESTION)) assert row is not None and row.get("share_url") share_url: str = row["share_url"] assert SHARE_URL_RE.fullmatch(share_url) try: # A FRESH context (no cookies, no localStorage): the shared # page renders the user's image (public route, same bubble # treatment — alt = the record's text). anon = browser.new_context() try: anon_page = anon.new_page() anon_page.goto(app_url + share_url) anon_img = anon_page.locator(".msg.user .msg-image").first expect(anon_img).to_be_visible(timeout=30_000) assert (anon_img.get_attribute("src") or "") == path # The answer text is there too (the shared view is the # full conversation, read-only). expect(anon_page.locator(".msg.brain .bubble")).to_contain_text( re.compile(DEFLECT_PHRASE, re.IGNORECASE) ) finally: anon.close() finally: _delete_row(app_url, cookies, row["id"]) # --------------------------------------------------------------------------- # 5. Default-off negative: the control stays hidden; an API request # with an image gets the hinted error frame, with NO model call # --------------------------------------------------------------------------- def _post_chat_sse( app_url: str, cookies: dict[str, str], body: dict[str, Any] ) -> list[dict[str, Any]]: """``POST /api/chat`` straight from the test process (the hand- crafted request the absent composer control would otherwise make).""" frames: list[dict[str, Any]] = [] with httpx.stream( "POST", f"{app_url}/api/chat", json=body, cookies=cookies, timeout=30 ) as r: assert r.status_code == 200, r.read() buf = "" for part in r.iter_text(): buf += part while "\n\n" in buf: frame, buf = buf.split("\n\n", 1) frame = frame.strip() if frame.startswith("data:"): frames.append(json.loads(frame.removeprefix("data:").strip())) return frames def test_flag_off_hides_the_control_and_rejects_the_request( page: Page, default_app_url: str, mock_llm: int, db_ready: None, ) -> None: _reset_db(mock_llm, seed=False) page.set_default_timeout(30_000) login(page, default_app_url, next="/") # The config says images off — and the control stays hidden for # GOOD (the default-off contract: the static markup ships hidden, # the reveal gate never fires, the rendered DOM is pre-phase). cfg = httpx.get(f"{default_app_url}/api/config", timeout=10).json() assert cfg["images"] is False expect(page.locator("#attach-btn")).to_be_hidden() expect(page.locator("#attach-file")).to_be_hidden() expect(page.locator("#attach-preview")).to_be_hidden() # The server-side contract (the API is the authority — a hand- # crafted request with an image): the phase-114 error frame with # the EXACT detail + hint, ONE terminal frame (no ``done``), and # ZERO model calls (the capture stays empty — the embed never ran). _reset_capture(mock_llm) cookies = _admin_cookies(page) frames = _post_chat_sse( default_app_url, cookies, {"message": "What is in this image?", "image": "/api/chat-images/" + "b" * 32 + ".png"}, ) assert [f["type"] for f in frames] == ["error"] assert frames[0] == { "type": "error", "detail": "Image support is turned off on this server.", "hint": "Enable BOR_IMAGES in the server's .env (and restart) to ask with an image.", } assert _captured(mock_llm) == [] # no model call (the rejected turn)