Files
brain-of-reese/tests/e2e/test_image_upload_feedback.py
T
ducoterra 4cb85e5baa
Build and Push Containers / build-and-push-app (push) Successful in 2m25s
Build and Push Containers / build-and-push-db (push) Successful in 17s
phase: 132_image_upload_feedback
All verification complete. Final report:

**Phase 132 (image upload feedback) — final verification pass: 100% green**

- **Verified** all 3 tasks' implementations in the working tree: `index.html` status part (hidden-by-default, arc + "Uploading image…", pinned order), `app.js` `enterUploadState`/`exitUploadState` (entered after `attachUpload` guard pre-await, exited on BOTH resolution paths, `setUiState` untouched, idle "Send" restore byte-identical), `styles.css` `.is-uploading` rules (house `spin` keyframes, reduced-motion block, existing `:disabled` Send language), unit pins, E2E suite with route-delayed upload window.
- **Completion criteria** — (1) mid-flight spinner + "Uploading image…", remove + Send disabled with "Uploading…": ✅ E2E-asserted; (2) second Send press = no second upload (request count exactly 1): ✅; (3) success clears + turn proceeds (bubble carries image); failure clears + banner + text/attachment remain: ✅; (4) `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov`: ✅ 3 passed (18.5 s, in isolation); (5) no behavior change in completed phases: ✅ (full suite green, A5 path-never-bytes re-pinned in E2E).
- **Full suite**: `uv run pytest --cov=app --cov-report=term-missing` → **3108 passed**, `app/` coverage **99%** (>90% gate); `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
- **Documentation**: README.md fully reviewed (setup, pages, all `.env.example` vars + every default vs `app/config.py`, tools incl. phase-131 `read(offset,limit)`, image/PDF/sync/deploy sections) and AGENTS.md fully reviewed (rules, quick-reference commands, gitignore/gpg claims) — **verified consistent; no changes needed**. Phase-132 README bullet already in the working tree; CSS `:root` contrast-note corrections (15.3:1 / 8.2:1) re-verified mathematically correct (comments only).
- **Decisions**: none — all task work was already implemented and correct; no fixes required.
- **Next pending phase**: `133_pdf_screenshot_fallback`.
2026-09-28 13:45:31 -04:00

603 lines
28 KiB
Python
Raw 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 132 task 03 E2E (Playwright, mock-only): the chat image upload
shows visible "uploading" feedback (TODO L8).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the settled-turn
pins key on the mock's byte-stable grounded answer
(``Deterministic mock answer for E2E``), which the live model will not
produce. The in-flight mechanics themselves are LLM-independent.
THE UPLOAD WINDOW (the route-delay rationale): in production
``POST /api/chat-images`` resolves in ~50 ms — the in-flight window is
unsamplable. ``page.route`` intercepts the upload and the handler
DELAYS (``asyncio.sleep``) before ``route.continue_()``: the server's
REAL handler runs, the response is just late (the upload is genuine —
the file lands, the path is returned, the turn proceeds). The window is
therefore ≥ ``HOLD_SECONDS`` (3.0 s) and the in-flight state — set by
``enterUploadState`` synchronously with the fetch, cleared by
``exitUploadState`` on resolution — is directly observable.
WHY THE ASYNC API (not the house sync pattern): a sync-API route
handler runs in a greenlet on the TEST thread — its ``time.sleep``
blocks the thread for the whole hold, so no ``expect()`` can poll the
DOM during the window (only the post-release residual state is visible
— a race against the server's response time). The async API runs the
handler as an independent event-loop task: the sleep suspends ONLY the
handler, the test coroutine keeps polling through the driver while the
upload is genuinely in flight. The in-flight assertions are therefore
waits on a directly-set DOM state, not a race.
Context: SIGNED IN via the house form login (admin — the same context
the phase-123 image suite drives; the async twin of
``e2e.auth_helpers.login``, no cookie surgery). The paperclip's reveal
gate is the ``/api/config`` ``images: true`` flag, not identity — but
the shell's anonymous boot shows the phase-79 token gate (locked +
inert), so a signed-in context is the house chat context. The
suite's module app boots with ``BOR_IMAGES=true`` pinned EXPLICITLY
(process env ranks above an operator's local gitignored ``.env`` — the
leak-guard convention) and a suite-private ``BOR_CHAT_IMAGE_DIR``.
KB fixture: ONE local source (``tmp_path_factory`` dir, registered
through the authenticated API + the real in-process ``POST /api/sync``
— the ``test_chat_answer_clusters.py`` pattern) holding ONE text doc
whose body shares the question's tokens (token-overlap cosine ~0.46–0.67
vs the mock-calibrated 0.30 gate): the turn after a successful upload
completes GROUNDED, exactly as pre-phase. The image fixture is the
house 1×1 transparent PNG (inline constant — the pipeline is
content-agnostic; the well-formed bytes keep the upload + render pins
honest).
Test → observable mapping (Playwright Mapping Rule):
1. ``test_upload_window_shows_uploading_and_blocks_resend`` — attach →
type → send, the upload route-delayed: MID-FLIGHT (asserted by
waiting on the class while the ≥3 s window is open) the strip has
``is-uploading`` + the visible ``.attach-preview-status`` reading
"Uploading image…" (the thumbnail stays visible — the user watches
THEIR image moving), ``#attach-remove`` is disabled, ``#send-btn``
is disabled with the "Uploading…" label; the owner's exact spam —
two more presses on the disabled button (the browser suppresses the
DOM event on a disabled control: genuinely dead presses) plus an
Enter-to-submit (the form's ``sendBtn.disabled`` guard) — then, past
the window: EXACTLY ONE upload request reached the interceptor
(no double upload) and EXACTLY ONE user + ONE brain bubble (no
second turn), the state cleared, the turn settled.
2. ``test_upload_success_clears_and_turn_proceeds`` — the same flow,
past the window: the state is cleared (no ``is-uploading``, the
status hidden, the remove re-enabled, the label back to "Send"), the
strip HIDEs with the send (the phase-123 contract), the user bubble
carries the attached image (the live data URL, alt = the filename),
the brain bubble is the mock's GROUNDED answer (not a deflection),
and the localStorage record carries the stored PATH — never base64
(A5).
3. ``test_upload_failure_clears_state_and_keeps_the_draft`` — a route
that DELAYS then ABORTS the upload (the network-drop path —
``fetch`` rejects, ``uploadAttachedImage``'s catch): mid-flight the
same uploading state; after resolution the state is CLEARED (Send
re-enabled with "Send"), the OUT-OF-TURN banner shows the
network-drop copy ("Couldn't attach the image — try again."), the
input text is INTACT, the strip is back in its NORMAL preview
(the attachment remains for the retry — visible, no
``is-uploading``), and NO turn ran (no bubbles at all).
"""
from __future__ import annotations
import asyncio
import base64
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import httpx
import pytest
from playwright.async_api import Page, async_playwright, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
REPO = Path(__file__).resolve().parents[2]
# Per-module app port (the conftest session app owns its port in a
# combined run — the module app binds its own; env-overridable).
APP_PORT = int(os.environ.get("E2E_APP_PORT_UPLOADFB", "8180"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The upload hold (the task's ≥2 s window; 3.0 s keeps the in-flight
#: assertions + the spam comfortably inside the window even under slow
#: driver polling — the hold is the ONLY thing between the press and
#: the resolution, so the window is hold + intercept latency).
HOLD_SECONDS = 3.0
SETTLE_TIMEOUT_MS = 30_000
# --------------------------------------------------------------------------
# The fixtures' constants
# --------------------------------------------------------------------------
#: The house 1×1 transparent PNG (the inline constant of the image E2E
#: suites — no new binary committed).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
PNG_NAME = "diagram.png"
SOURCE_NAME = "e2e-uploadfb" # the directory basename = the source name
NOTE_PATH = "note.md"
#: The one text doc — its body carries the question's tokens, so the
#: mock's token-overlap cosine against the (single) chunk clears the
#: mock-calibrated 0.30 gate deterministically (~0.46–0.67 measured):
#: the post-upload turn GROUNDS (HIGH), the mock's default grounded
#: answer streams, and the turn completes exactly as pre-phase.
NOTE_CONTENT = (
"# Homelab Router Note\n"
"\n"
"The homelab router note for the e2e upload-feedback suite: the core\n"
"router runs the firewall rules and the VLAN interfaces, and the\n"
"uplink carries the whole homelab network.\n"
)
QUESTION = "What does my homelab note say about the core router?"
#: The phase-50 auto-title (first question, whitespace-collapsed, 120-
#: char cap) of the two settled turns — the shared e2e Postgres keeps
#: ``saved_chats`` across runs, so the module boundary owns this
#: suite's rows by title (the test_chat_answer_clusters cleanup).
SUITE_TITLE = " ".join(QUESTION.split())[:120]
MOCK_MARKER = "Deterministic mock answer for E2E"
DEFLECT_PHRASE = r"haven't done anything like that"
IMAGE_PATH_RE = re.compile(r"^/api/chat-images/[0-9a-f]{32}\.png$")
STORAGE_KEY = "bor.chat.v1"
#: The module counter the task's double-send assertion keys on — every
#: intercepted upload request (method + path checked in the handler).
UPLOAD_REQUESTS: list[str] = []
# ---------------------------------------------------------------------------
# Module app: images ON, suite-private image store, the one-doc local
# source as the sole registered source
# ---------------------------------------------------------------------------
def _app_env(mock_llm: int, scratch: Path) -> dict[str, str]:
"""The conftest app env (leak guards included) with the phase-123
image knob pinned EXPLICITLY (the ``/api/config`` ``images: true``
gate reveals ``#attach-btn``) and the question-image store in the
suite's scratch dir (the app under test never writes 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 one-doc KB grounds
# the question on these values (see NOTE_CONTENT).
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",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The repo's .env file carries the owner's BOR_GIT_SOURCES (the app
# reads it from cwd) — override it with an EMPTY value: the registry
# must hold EXACTLY this suite's local directory.
env["BOR_GIT_SOURCES"] = ""
# Leak guards (conftest pattern): docs-push inert; UI strings at the
# code defaults.
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
# The story's knob + the suite-private stores.
env["BOR_IMAGES"] = "true"
env["BOR_ANSWER_IMAGES"] = "false" # the mock never show_images anyway
env["BOR_CHAT_IMAGE_DIR"] = str(scratch / "chat-images")
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 kb_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The one-source fixture tree (see the module docstring): the app
runs on the same host, so the path is visible to it."""
scratch = tmp_path_factory.mktemp("bor_uploadfb")
src = scratch / SOURCE_NAME
src.mkdir()
(src / NOTE_PATH).write_text(NOTE_CONTENT, encoding="utf-8")
return src
@pytest.fixture(scope="module")
def app_server(mock_llm: int, kb_dir: Path) -> Iterator[str]:
"""The real app under test — per-module app (the conftest pattern;
the session app is never started in this isolated run, so no port
clash)."""
scratch = kb_dir.parent
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=_app_env(mock_llm, scratch),
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
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
# ---------------------------------------------------------------------------
# DB + admin-HTTP helpers (the house patterns)
# ---------------------------------------------------------------------------
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern): the suites share
one Postgres, and a leftover row would corrupt the one-doc premise.
``saved_chats`` is never truncated (the house pattern) — the two
settled turns auto-save rows under this suite's title, and
:func:`_cleanup_saved_rows` owns them."""
with SessionLocal() as db:
db.execute(text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, sources_meta, folder_summaries"
))
db.commit()
def _cleanup_saved_rows() -> None:
"""Delete THIS suite's auto-saved rows by title (best-effort: a
failed delete — DB unreachable, the skip case — never masks the
skip; re-runnable on the shared DB)."""
try:
with SessionLocal() as db:
db.execute(
text("DELETE FROM saved_chats WHERE title = :title"),
{"title": SUITE_TITLE},
)
db.commit()
except Exception: # noqa: BLE001 — DB unreachable: the skip handles it
pass
@contextmanager
def _admin_client(app_url: str) -> Iterator[httpx.Client]:
"""An httpx client holding a fresh admin session (the form login's
API twin — the house httpx pattern of test_chat_answer_clusters)."""
client = httpx.Client(base_url=app_url, timeout=30.0)
try:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
yield client
finally:
client.close()
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
"""Poll the (cookie-authenticated) status endpoint until the run
reaches a terminal state."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
if body["state"] in ("success", "failed"):
return body
time.sleep(0.5)
raise AssertionError(f"sync did not reach a terminal state: {body}")
@pytest.fixture(autouse=True)
def seeded_kb(app_url: str, kb_dir: Path, db_ready: None) -> Iterator[None]:
"""The story's precondition per test: a FRESH registry holding ONLY
this suite's local source, synced by the REAL in-process
``POST /api/sync`` pipeline (walk → chunk → embed → overview →
version bump) under the deterministic mock — exactly ONE document.
(Re-seeded per test — the shared e2e Postgres makes leftovers
possible, and the one-doc premise is load-bearing for the gate.)"""
_cleanup_saved_rows() # a previous run's rows would be stale
_truncate_all()
with _admin_client(app_url) as client:
r = client.post("/api/git-sources", json={"kind": "local", "path": str(kb_dir)})
assert r.status_code == 201, r.text
r = client.post("/api/sync")
assert r.status_code == 202, r.text
body = _wait_sync_done_http(client)
assert body["state"] == "success", body
assert body["detail"]["added"] == 1, body
yield
_truncate_all()
_cleanup_saved_rows()
# ---------------------------------------------------------------------------
# Browser helpers (async API — see the header's WHY THE ASYNC API)
# ---------------------------------------------------------------------------
def _png_file(tmp_path: Path) -> Path:
png = tmp_path / PNG_NAME
png.write_bytes(PNG_1X1)
return png
async def _admin_login(page: Page, app_url: str) -> None:
"""The house form login (the async twin of ``e2e.auth_helpers.
login``): the REAL form on /login.html — fill → submit → redirect.
The phase-79 gate locks the anonymous shell (inert + the token
gate is the only interactive surface), so the suite drives the same
signed-in context the other chat suites do."""
await page.goto(f"{app_url}/login.html?next=/")
await expect(page.locator("#login-password")).to_be_visible()
await page.fill("#login-password", ADMIN_PASSWORD)
await page.click("#login-form button[type=submit]")
await expect(page).to_have_url(app_url + "/", timeout=SETTLE_TIMEOUT_MS)
def _upload_route_handler(fail: bool) -> Any:
"""The route-delayed upload (or the delayed ABORT — the failure
test's network drop). Only the POST upload is held; anything else
(the bytes route's GETs) passes straight through. The request is
counted the moment it is intercepted — the double-send pin."""
async def handle(route: Any) -> None:
if (
route.request.method == "POST"
and urlsplit(route.request.url).path == "/api/chat-images"
):
UPLOAD_REQUESTS.append(route.request.url)
await asyncio.sleep(HOLD_SECONDS)
if fail:
await route.abort("failed")
return
await route.continue_()
return handle
async def _attach_via_button(page: Page, png: Path) -> None:
"""The real user path: click the paperclip (revealed by the
``images: true`` config gate) → the file chooser (the button's
programmatic input click) → ``set_files``. The preview strip must
reveal with the data-URL thumbnail + the filename (phase 123)."""
btn = page.locator("#attach-btn")
await expect(btn).to_be_visible()
await expect(btn).to_have_attribute("aria-label", "Attach an image")
async with page.expect_file_chooser() as fc_info:
await btn.click()
await (await fc_info.value).set_files(str(png))
await expect(page.locator("#attach-preview")).to_be_visible()
await expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
src = await page.locator("#attach-preview img").get_attribute("src")
assert (src or "").startswith("data:image/png;base64,"), "the thumbnail is the live data URL"
async def _assert_uploading(page: Page) -> None:
"""The in-flight state (phase 132 task 01) — every assertion is a
WAIT on the directly-set DOM state (the UI is no-poll: the state is
set synchronously with the fetch), so while the window is open each
wait settles on the first tick inside it."""
await expect(page.locator("#attach-preview")).to_have_class(
re.compile(r"\bis-uploading\b")
)
# The thumbnail stays visible — the user watches THEIR image moving.
await expect(page.locator("#attach-preview img")).to_be_visible()
await expect(page.locator(".attach-preview-status")).to_be_visible()
await expect(page.locator(".attach-preview-status-text")).to_have_text("Uploading image…")
await expect(page.locator("#attach-remove")).to_be_disabled()
await expect(page.locator("#send-btn")).to_be_disabled()
await expect(page.locator("#send-label")).to_have_text("Uploading…")
async def _send_with_held_upload(
page: Page, app_url: str, png: Path, *, fail: bool = False, spam: bool = False
) -> None:
"""Chat view (house form login) → attach → type → (hold the upload
route) → send → assert the in-flight state → optionally the owner's
exact spam (mid-flight: two presses on the disabled button + an
Enter-to-submit)."""
UPLOAD_REQUESTS.clear()
await _admin_login(page, app_url)
await _attach_via_button(page, png)
await page.fill("#message-input", QUESTION)
await page.route("**/api/chat-images", _upload_route_handler(fail))
await page.click("#send-btn")
await _assert_uploading(page)
if spam:
# The owner's exact spam while the window is open: two more
# presses on the disabled button (force=True dispatches the
# mouse events; the BROWSER suppresses the DOM event on a
# disabled control — genuinely dead presses, like a user's)
# and an Enter-to-submit (the form's sendBtn.disabled guard).
await page.click("#send-btn", force=True)
await page.click("#send-btn", force=True)
await page.locator("#message-input").press("Enter")
async def _wait_grounded_settle(page: Page) -> None:
"""Past the window: the upload resolved, the state CLEARED, and the
turn settled GROUNDED on the one-doc KB (the mock's byte-stable
answer — not a deflection), the button back to the idle "Send"."""
brain = page.locator(".msg.brain .bubble").first
await expect(brain).to_contain_text(MOCK_MARKER, timeout=SETTLE_TIMEOUT_MS)
await expect(brain).not_to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE))
await expect(page.locator("#send-btn")).to_be_enabled()
await expect(page.locator("#send-label")).to_have_text("Send")
# The uploading state is gone at every writer's exit point…
strip = page.locator("#attach-preview")
assert "is-uploading" not in (await strip.get_attribute("class") or "")
await expect(page.locator(".attach-preview-status")).to_be_hidden()
await expect(page.locator("#attach-remove")).to_be_enabled()
# …and the strip must not linger into the turn (the phase-123
# contract — the preview clears when the question goes out).
await expect(strip).to_be_hidden()
# A successful turn shows no out-of-turn banner.
await expect(page.locator("#kb-banner")).to_be_hidden()
# ---------------------------------------------------------------------------
# 1. The window: uploading state visible, the spam is visibly AND
# functionally dead (exactly ONE upload, ONE turn)
# ---------------------------------------------------------------------------
async def _scenario_window_blocks_resend(app_url: str, tmp_path: Path) -> None:
png = _png_file(tmp_path)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
try:
page = await browser.new_page(viewport={"width": 1280, "height": 800})
page.set_default_timeout(30_000)
await _send_with_held_upload(page, app_url, png, spam=True)
await _wait_grounded_settle(page)
# The spam is FUNCTIONALLY dead too: exactly ONE upload
# request reached the interceptor (the second press never
# fired an upload; the Enter never fired a send).
assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
# …and no second turn: exactly one question + one answer.
await expect(page.locator(".msg.user .bubble")).to_have_count(1)
await expect(page.locator(".msg.brain .bubble")).to_have_count(1)
finally:
await browser.close()
def test_upload_window_shows_uploading_and_blocks_resend(
app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
) -> None:
asyncio.run(_scenario_window_blocks_resend(app_url, tmp_path))
# ---------------------------------------------------------------------------
# 2. Success: the indicator clears, the strip hides, the bubble carries
# the image, the record carries the PATH, the turn completes
# ---------------------------------------------------------------------------
async def _scenario_success_clears_and_proceeds(app_url: str, tmp_path: Path) -> None:
png = _png_file(tmp_path)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
try:
page = await browser.new_page(viewport={"width": 1280, "height": 800})
page.set_default_timeout(30_000)
await _send_with_held_upload(page, app_url, png)
await _wait_grounded_settle(page)
# The user bubble renders the attached image (the phase-123
# attachBubbleImage — the live data URL, alt = the filename).
img = page.locator(".msg.user .msg-image").first
await expect(img).to_be_visible()
src = await 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 await img.get_attribute("alt") == PNG_NAME
# A5 at the storage boundary: the record carries the stored
# PATH, and NOTHING base64 crossed into the payload.
raw = await page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
stored = json.loads(raw) if raw else None
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}"
assert "base64" not in json.dumps(stored)
assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
finally:
await browser.close()
def test_upload_success_clears_and_turn_proceeds(
app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
) -> None:
asyncio.run(_scenario_success_clears_and_proceeds(app_url, tmp_path))
# ---------------------------------------------------------------------------
# 3. Failure (the network drop): the state clears, the banner shows,
# the draft + the attachment remain for the retry, NO turn ran
# ---------------------------------------------------------------------------
async def _scenario_failure_clears_and_keeps_draft(app_url: str, tmp_path: Path) -> None:
png = _png_file(tmp_path)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
try:
page = await browser.new_page(viewport={"width": 1280, "height": 800})
page.set_default_timeout(30_000)
# The route DELAYS (the in-flight state is assertable, same
# as the other tests) then ABORTS — the fetch rejects and
# uploadAttachedImage's catch settles the network-drop
# banner.
await _send_with_held_upload(page, app_url, png, fail=True)
# The OUT-OF-TURN banner (the network-drop copy).
banner = page.locator("#kb-banner")
await expect(banner).to_be_visible(timeout=SETTLE_TIMEOUT_MS)
await expect(banner).to_have_class(re.compile(r"\bis-error\b"))
await expect(page.locator("#kb-banner-text")).to_contain_text(
"Couldn't attach the image — try again."
)
# The uploading state is CLEARED on the failure path too.
strip = page.locator("#attach-preview")
assert "is-uploading" not in (await strip.get_attribute("class") or "")
await expect(page.locator(".attach-preview-status")).to_be_hidden()
await expect(page.locator("#attach-remove")).to_be_enabled()
await expect(page.locator("#send-btn")).to_be_enabled()
await expect(page.locator("#send-label")).to_have_text("Send")
# The strip is back in its NORMAL preview — the attachment
# remains for the retry (the thumbnail + the filename).
await expect(strip).to_be_visible()
await expect(
page.locator("#attach-preview .attach-preview-name")
).to_have_text(PNG_NAME)
src = await page.locator("#attach-preview img").get_attribute("src") or ""
assert src.startswith("data:image/png;base64,"), "the preview keeps the data URL"
# The typed question is INTACT (the send was blocked — A8).
assert await page.locator("#message-input").input_value() == QUESTION
# NO turn ran (the question never went out without the image).
await expect(page.locator(".msg.user")).to_have_count(0)
await expect(page.locator(".msg.brain")).to_have_count(0)
# The request was intercepted exactly once (it never reached
# the server — the abort dropped it).
assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
finally:
await browser.close()
def test_upload_failure_clears_state_and_keeps_the_draft(
app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
) -> None:
asyncio.run(_scenario_failure_clears_and_keeps_draft(app_url, tmp_path))