Files
brain-of-reese/tests/e2e/test_chat_image_questions.py
ducoterra bef24e05e2
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 123_chat_image_questions
All gates green. Verification complete.

**Phase 123 — final verification pass (all 4 tasks already in `complete/`)**

- Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer.
- `uv run pytest` → **2796 passed**, exit 0 (unit + integration).
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%).
- `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation.
- `uv run ruff check . && uv run pyright` → clean (0 errors).

**Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run).

No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
2026-09-25 05:19:18 -04:00

627 lines
25 KiB
Python
Raw Permalink 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 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)