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/`.
627 lines
25 KiB
Python
627 lines
25 KiB
Python
"""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)
|