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`.
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user