phase: 132_image_upload_feedback
Build and Push Containers / build-and-push-app (push) Successful in 2m25s
Build and Push Containers / build-and-push-db (push) Successful in 17s

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:
2026-09-28 13:45:31 -04:00
parent 101156bada
commit 4cb85e5baa
22 changed files with 1696 additions and 2 deletions
@@ -0,0 +1,10 @@
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`.
@@ -0,0 +1,115 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 97%]
........................................................................ [ 99%]
............ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 268 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 410 0 100%
--------------------------------------------------
TOTAL 5206 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
All done. Final report:
**Implemented (phase 132, task 01 — uploading state):**
- `frontend/index.html`: `#attach-preview` gains the hidden status part (house loading arc svg, `aria-hidden` + pinned "Uploading image…" text) between the filename and remove ✕ — ships hidden per the house rule
- `frontend/assets/app.js`: module-scope `attachStatus` ref (null-guard pattern); `enterUploadState()`/`exitUploadState()` helpers beside `showAttachPreview`/`clearAttachedImage` (strip `.is-uploading` + status unhidden + remove disabled + Send disabled "Uploading…" / mirror exit restoring "Send" byte-identical to `setUiState`'s idle label); `handleSend` enters after the `attachUpload` guard before the await, exits at `attachUpload = false` (both success & failure — helper never throws). `setUiState`/`uiState`/turn loader untouched (pre-turn)
- `tests/unit/test_chat_image_questions.py`: +4 tests — markup pin (order, exact arc, hidden), helper affordance pin (order, guards, pre-turn contract, byte-identical "Send"), `handleSend` exact-ordering pin (guard → arm → enter → await → release → exit → block, single enter/exit, no `setUiState(` call, byte-untouched phase-123 contract), node-harness behavioral test (enter / exit-success / exit-failure / re-enter rows)
**Gates (all green):**
- `uv run pytest --cov=app --cov-report=term -q` → exit 0, **99%** on `app/` (>90% ✓)
- `uv run ruff check .` → clean; `uv run pyright` → exit 0
- Regression E2E in isolation: `test_chat_image_questions.py` (phase 123 contract on the modified surface) 5 passed; `test_smoke.py`, `test_answer_images.py`, `test_image_anchor_context.py`, `test_image_documents.py` all pass
**Docs:** no doc impact (no setup/commands/env/API changes)
**Decisions:** used the task's exact arc snippet (the "check `#turn-loader`" hint is stale — that loader is the brain-wave glyph, not an arc; the arc matches the house `.spinner` language for task 02's spin CSS); removed a stray `✕` glyph my comment introduced (caught by the emoji guard).
**Next pending task:** `02_css_upload_state.md`
@@ -0,0 +1,115 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
..... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 268 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 410 0 100%
--------------------------------------------------
TOTAL 5206 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
Task 02 complete. Final report:
**Implemented**
- `frontend/assets/styles.css`: Phase 132 uploading-state CSS in the `.attach-preview*` family — thumbnail dims to 0.5, name hides via `visibility` (slot kept, no reflow), `.attach-preview-status` row (`--ink-soft`, 0.85rem, `min-width: 0`), 14px arc on the house `spin` keyframes at the sync spinner's 1s pace, `prefers-reduced-motion` stills the arc, remove ✕ dims 0.5 with its disabled attribute
- Verified in real Chromium (320/375/480/768/1440): "Uploading image…" never clips (one line at 320px), strip height stable, arc animates `spin 1s` / `none` under reduced motion
- Disabled Send: existing `.send-btn:disabled` (`--brand-busy` + `not-allowed`) already reads disabled — verified + pinned, no new visual (B4)
- Theme audit: block ships no raw color; `--ink-soft` on `--surface` computed 8.2:1 (≥4.5:1) from `:root` literals
- 7 new unit pins appended to `tests/unit/test_chat_image_questions.py` (Phase 132 task 02 section)
**Tests / lint**
- `uv run pytest --cov=app`: **3108 passed**, exit 0, `app/` coverage **99%** (>90% gate)
- `uv run ruff check .`: All checks passed · `uv run pyright`: 0 errors, 0 warnings
- Regression: `tests/e2e/test_chat_image_questions.py` (5 passed) + `test_pinned_composer.py` + `test_responsive_polish.py` (11 passed) in isolation
**Docs:** no README/AGENTS.md impact (visual-state CSS only)
**Decisions / deviations:** fixed stale `:root` contrast comments (5.1:1→8.2:1, 13.8:1→15.3:1 — palette changed at the phase-08 re-land, comments never updated; both pairs pass AA). ~40 other historical phase comments still cite the old low ratios (all underestimates, AA-safe) — left as out-of-scope for this task. Initial "✕" glyphs in my CSS comment tripped the phase-08 no-emoji guard — replaced with ASCII.
**Next pending task:** `.agents/phases/todo/132_image_upload_feedback/03_e2e_upload_feedback.md`
@@ -0,0 +1,115 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 97%]
........................................................................ [ 99%]
............ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 268 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 410 0 100%
--------------------------------------------------
TOTAL 5206 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
All gates green. Final report:
**Implemented**
- `tests/e2e/test_image_upload_feedback.py` (new, mock-only, run in isolation): module app with `BOR_IMAGES=true` + suite-private image store, one-doc local source seeded through the real in-process sync, house 1×1 PNG constant.
- Three tests per the mapping: (1) mid-flight uploading state + owner-spam (2 dead button presses + Enter) → exactly ONE upload request, ONE turn; (2) success clears state, strip hides, bubble carries the data-URL image, record carries the PATH (A5), grounded turn settles; (3) delayed-`route.abort` network drop → banner copy, draft + attachment intact, no turn.
- In-flight window via `page.route` + async `asyncio.sleep(3.0)` handler, then `route.continue_()` (real server runs, response just late); module counter pins the double-send.
**Results**
- `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov` → 3 passed (5 consecutive runs, 18 s each; instrumented run confirmed in-flight asserts settle ~10 ms after the click, inside the 3 s hold — not a race)
- `uv run pytest --cov=app --cov-report=term-missing` → 3108 passed, `app/` coverage 99%
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings
**Docs:** README "Question images" gained the uploading-state bullet (phase 132 behavior); AGENTS.md — no doc impact (no new rules/commands).
**Notable decisions:** async Playwright API instead of the sync house pattern — a sync route handler's sleep blocks the test thread for the whole hold, making mid-flight DOM polling impossible (verified empirically; header docstring explains). Admin form login (async twin of `auth_helpers.login`) instead of anonymous: the phase-79 token gate locks the anonymous shell, and phase 123 (the referenced suite) uses the signed-in context. Same-question auto-saves are cleaned by the suite title (house pattern).
**Next pending task:** `.agents/phases/todo/133_pdf_screenshot_fallback/01_diagnosis_regression_test.md`
@@ -0,0 +1,115 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 97%]
........................................................................ [ 99%]
............ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 268 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 179 1 99%
app/api/git_sources.py 241 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 156 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 256 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 132 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 412 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 38 0 100%
app/rag/importer.py 571 9 98%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 81 0 100%
app/rag/prompts.py 144 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 49 0 100%
app/schemas.py 410 0 100%
--------------------------------------------------
TOTAL 5206 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+7
View File
@@ -482,6 +482,13 @@ image:
to `POST /api/chat-images` (capped at `BOR_CHAT_IMAGE_MAX_MB`,
default 10 MiB — over is a 413) and stored under `BOR_CHAT_IMAGE_DIR`.
Saved and shared chats carry the returned *path* — never base64.
- **Visible while uploading.** While the upload is in flight the
preview strip shows a spinner + *"Uploading image…"* (its remove ✕
disabled) and the Send button is disabled with the *"Uploading…"*
label — a second press is dead, visually and functionally (no second
upload, no second turn). On success the state clears and the turn
proceeds; on failure it clears, the banner shows, and the typed text
+ the attachment stay for the retry (phase 132).
- **Current turn only.** The model receives a multimodal message for
this question; prior turns' images are never replayed into history
(the text of a prior turn stands alone).
+59
View File
@@ -314,6 +314,10 @@ const attachBtn = document.querySelector("#attach-btn");
const attachFile = document.querySelector("#attach-file");
const attachPreview = document.querySelector("#attach-preview");
const attachRemove = document.querySelector("#attach-remove");
// Phase 132 (task 01, TODO L8): the strip's uploading status part —
// ships hidden; enterUploadState / exitUploadState (below) are its
// sole `hidden` writers (shown iff the image upload is in flight).
const attachStatus = document.querySelector(".attach-preview-status");
const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
@@ -2757,6 +2761,49 @@ function clearAttachedImage() {
if (attachPreview) attachPreview.hidden = true;
}
/* Phase 132 (task 01, TODO L8; A5, owner-confirmed 2026-09-27): the
* PRE-turn uploading state — the visible proof that the attached
* image is moving while POST /api/chat-images is in flight. The
* affordance is BOTH halves at once: the preview strip shows the
* spinner + "Uploading image…" (the status part unhidden, the
* .is-uploading class for task 02's CSS) with its remove X disabled
* (the attachment is mid-flight — it can't be removed out from under
* the upload), AND the Send button is DISABLED with the "Uploading…"
* label — the user-facing half of the attachUpload double-fire guard
* (a second press does nothing: the top-of-handleSend
* `sendBtn.disabled` guard AND the `attachUpload` guard both no-op).
* exitUploadState is the mirror image: the strip returns to its
* normal preview (thumbnail + filename stay visible), the remove X
* re-enables, and the button restores EXACTLY the idle look —
* enabled + "Send" (byte-identical to setUiState's idle label),
* restored BEFORE the success path calls runTurn (whose
* setUiState("thinking") then takes the button over as usual) and
* before the failure path returns (banner shown, input text intact,
* the attachment still attached — the user can retry or remove).
* setUiState / uiState / the turn loader are deliberately UNTOUCHED:
* the upload is PRE-turn (uiState stays idle; the turn's
* thinking/streaming feedback starts only when runTurn does).
* Both are idempotent and null-guarded on the attach family (the
* house pattern — a New chat mid-upload hides the strip; the class
* on a hidden element is harmless and still cleared at resolution);
* the send refs are unconditional (the composer is required for the
* chat view to exist at all). */
function enterUploadState() {
if (attachPreview) attachPreview.classList.add("is-uploading");
if (attachStatus) attachStatus.hidden = false;
if (attachRemove) attachRemove.disabled = true;
sendBtn.disabled = true;
sendLabel.textContent = "Uploading…";
}
function exitUploadState() {
if (attachPreview) attachPreview.classList.remove("is-uploading");
if (attachStatus) attachStatus.hidden = true;
if (attachRemove) attachRemove.disabled = false;
sendBtn.disabled = false;
sendLabel.textContent = "Send";
}
async function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
@@ -2787,8 +2834,20 @@ async function handleSend(e) {
if (attachedImage) {
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
attachUpload = true;
// Phase 132 (task 01, TODO L8): the uploading state is entered
// exactly ONCE per upload — after the guard, before the await —
// so the gap between the press and the resolution is never
// frozen-looking (the strip's spinner + "Uploading image…", the
// disabled "Uploading…" Send).
enterUploadState();
const path = await uploadAttachedImage(attachedImage.file);
attachUpload = false; // the helper never throws (every failure path is a banner + null)
// Phase 132: exited on BOTH resolution paths — the helper never
// throws, so this line is reached on success AND failure. Before
// the success path continues to runTurn (whose setUiState takes
// the button over) and before the failure path returns below
// (banner + editable composer + the attachment for the retry).
exitUploadState();
if (path === null) return; // A8: the send is blocked — the question stays
image = {
path, // the record + the /api/chat body (A5: the path, never base64)
+52 -2
View File
@@ -7,9 +7,9 @@
:root {
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
--bg: #0f0a0a; /* page: ink on bg 16.7:1 */
--surface: #1a0f0f; /* ink on surface 13.8:1 */
--surface: #1a0f0f; /* ink on surface 15.3:1 */
--ink: #f0e6e6;
--ink-soft: #b8a8a8; /* 5.1:1 on --surface */
--ink-soft: #b8a8a8; /* 8.2:1 on --surface */
--line: #2d1a1a; /* decorative 1px borders */
--grid-line: #4a2626; /* background grid texture — decorative, no
contrast duty (like --line) */
@@ -1745,6 +1745,56 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
}
.attach-preview-remove svg { width: 18px; height: 18px; }
.attach-preview-remove:hover { background: var(--err-bg); color: var(--err-ink); }
/* Phase 132 (task 02, TODO L8): the strip's UPLOADING look — the
visual half of the owner-confirmed double-spam block (task 01's JS
state proves the image is moving while POST /api/chat-images is in
flight; before this the strip looked frozen and the user spammed
Send). The thumbnail DIMS to 0.5 (it is decorative — no contrast
duty — and 0.5 is the house dim value the disabled remove button
below); the filename HIDES with visibility (the flex: 1 slot keeps
its measured width — the status takes the row's right hand WITHOUT
reflowing the pinned 44px remove button); the house loading arc
(the status part's svg — task 01's markup, currentColor = the
status's --ink-soft) SPINS at the sync spinner's pace (the house
`spin` keyframes at 1s linear — .sync-icon.is-spinning's in-flight
pace; the .spinner's 0.8s is the busy-turn arc's). The status text
is --ink-soft on the strip's --surface — the :root-pinned pair,
8.2:1 computed against WCAG 2.1 (AA ≥ 4.5:1), at the strip's
small text size (the name's 0.85rem). The remove button (task 01
disables it mid-flight) dims with its disabled attribute — the
attribute is the state, the 0.5 is its visual half; the Send rides
the same house :disabled language (.send-btn:disabled's
--brand-busy + not-allowed below — which reads as disabled already,
so NO new color or weight was added, B4). prefers-reduced-motion stills the arc (the house block): the
"Uploading image…" TEXT + the dimmed thumbnail carry the state
without motion (B5 — never motion alone). Every value is a theme
variable or a neutral number — the block ships no raw color. */
.attach-preview.is-uploading .attach-preview-img {
opacity: 0.5;
}
.attach-preview.is-uploading .attach-preview-name {
visibility: hidden; /* keep the flex: 1 slot; the status takes its right hand */
}
.attach-preview-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--ink-soft);
font-size: 0.85rem; /* the strip's small text — the name's size */
min-width: 0; /* the row's clamp: at 320 px the text WRAPS (no clip) */
}
.attach-preview-status-icon {
width: 14px;
height: 14px;
flex: 0 0 auto;
animation: spin 1s linear infinite; /* the house spin keyframes (the sync pace) */
}
@media (prefers-reduced-motion: reduce) {
.attach-preview-status-icon { animation: none; }
}
.attach-preview.is-uploading .attach-preview-remove {
opacity: 0.5; /* the disabled attribute's visual half (task 01 sets it) */
}
.composer {
display: flex;
align-items: flex-end;
+16
View File
@@ -280,6 +280,22 @@
<div class="attach-preview" id="attach-preview" hidden>
<img class="attach-preview-img" alt="">
<span class="attach-preview-name"></span>
<!-- Phase 132 (task 01, TODO L8): the strip's UPLOADING
status part — the house loading arc (the svg is
decorative, aria-hidden) + the pinned "Uploading image…"
text (it carries the meaning). SHIPS HIDDEN (the house
rule) — app.js's enterUploadState / exitUploadState
(handleSend's attachedImage branch) are the SOLE writers
of its `hidden`: shown exactly while the POST
/api/chat-images upload is in flight, hidden by
construction at BOTH resolution points (success → the
turn, failure → the banner + the retry). The thumbnail
+ filename stay visible throughout — the user watches
THEIR image uploading. -->
<span class="attach-preview-status" hidden>
<svg class="attach-preview-status-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>
<span class="attach-preview-status-text">Uploading image…</span>
</span>
<button type="button" class="attach-preview-remove" id="attach-remove" aria-label="Remove the attached image">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
+602
View File
@@ -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))
+437
View File
@@ -34,6 +34,19 @@ wiring (a recording fake LLM, a fake retriever, a fake DB session,
patched settings) so the whole validate → build contract runs without
a stack; the upload/serve tests point ``chat_image_dir`` at a tmp
directory (the raw-string ``expanduser`` convention).
Phase 132 (task 01, TODO L8) pins the PRE-turn uploading state in the
same chat-frontend surface: the preview strip ships a HIDDEN status
part (the house loading arc — decorative, aria-hidden — + the pinned
"Uploading image…" text), and ``handleSend``'s attachedImage branch
enters it exactly once per upload (after the ``attachUpload`` guard,
before the ``await uploadAttachedImage``) and exits it on BOTH
resolution paths (the helper never throws) — strip class
``is-uploading`` + status unhidden + remove ✕ disabled + the Send
button disabled with "Uploading…" on entry; the mirror image on exit
with the idle label restored byte-for-byte. ``setUiState`` / ``uiState``
/ the turn loader stay untouched (the upload is pre-turn; ``uiState``
stays ``idle``).
"""
from __future__ import annotations
@@ -41,6 +54,8 @@ import base64
import io
import json
import re
import shutil
import subprocess
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime
@@ -1168,3 +1183,425 @@ def test_the_bad_pick_and_the_reader_failure_keep_the_banner_copy() -> None:
m = re.search(r'reader\.onerror = \(\) => \{(.*?)\n \};', js, re.S)
assert m, "the FileReader's onerror branch must exist"
assert 'showErrorBanner("Couldn\'t attach the image — try again.");' in m.group(1)
# =====================================================================
# Phase 132 (task 01, TODO L8) — the PRE-turn uploading state
# =====================================================================
def _fn(js: str, name: str) -> str:
"""The source of the first `function <name>` in js (brace balanced
— the house pin pattern from tests/unit/test_sync_button.py)."""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must be defined"
open_idx = js.find("{", fn)
depth = 0
for i in range(open_idx, len(js)):
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[fn : i + 1]
raise AssertionError(f"unbalanced braces in {name}")
def test_the_strip_ships_the_hidden_uploading_status_part() -> None:
"""The preview strip's markup gains ONE status part INSIDE the
strip — after the filename span, before the remove button —
shipping HIDDEN (the house "ships hidden" rule): the house
loading arc (the svg is decorative, aria-hidden — the arc is not
the meaning) + the pinned "Uploading image…" text (it carries the
meaning). The strip's existing parts (thumbnail, name, remove ✕
+ its aria-label) are untouched, and the thumbnail + filename stay
visible throughout the upload (the user watches THEIR image
uploading — only the status part's `hidden` moves)."""
html = _index_html()
start = html.find('<div class="attach-preview" id="attach-preview" hidden>')
assert start != -1
strip = html[start : html.find("</div>", start)]
# the status part: the exact shipped markup (hidden by default).
assert '<span class="attach-preview-status" hidden>' in strip
assert (
'<svg class="attach-preview-status-icon" aria-hidden="true"'
' viewBox="0 0 24 24" fill="none" stroke="currentColor"'
' stroke-width="2" stroke-linecap="round">'
'<path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>' in strip
), "the house loading arc (decorative, aria-hidden) must ship"
assert '<span class="attach-preview-status-text">Uploading image…</span>' in strip
# exactly ONE status part, in the pinned order: img → name →
# status → remove (inside the strip, before its closing tag).
assert strip.count('class="attach-preview-status"') == 1
i_img = strip.find('class="attach-preview-img"')
i_name = strip.find('class="attach-preview-name"')
i_status = strip.find('class="attach-preview-status" hidden')
i_remove = strip.find('id="attach-remove"')
assert -1 < i_img < i_name < i_status < i_remove, (
"the status part sits after the name, before the remove button"
)
# the pre-phase parts are byte-untouched (the phase-123 contract).
assert '<img class="attach-preview-img" alt="">' in strip
assert 'aria-label="Remove the attached image"' in strip
def test_enter_exit_upload_state_are_the_exact_affordance_writers() -> None:
"""The two tiny helpers next to showAttachPreview / clearAttachedImage
(A5, owner-confirmed 2026-09-27 — the double-spam block is the
BOTH-halves affordance). enterUploadState: the strip gains the
.is-uploading class (task 02's CSS hooks it), the status part
unhides, the remove ✕ disables (the attachment is mid-flight), and
the Send button disables with the "Uploading…" label. exitUploadState
is the mirror image — and its idle restore is BYTE-IDENTICAL to
setUiState's idle label ("Send"), so the success path hands the
button to runTurn's setUiState("thinking") as usual and the
failure path leaves a fully editable composer. The attach family
is null-guarded (the house pattern); the send refs are
unconditional (the composer is required). setUiState / uiState /
the turn loader are NEVER touched — the upload is PRE-turn.
The module-scope ref is the strip's status part (the text is
static — no attachStatusText)."""
js = _app_js()
enter = _fn(js, "enterUploadState")
exit_ = _fn(js, "exitUploadState")
# entry — the full affordance, in the pinned order.
i_preview = enter.find('attachPreview.classList.add("is-uploading")')
i_status = enter.find("attachStatus.hidden = false")
i_remove = enter.find("attachRemove.disabled = true")
i_send = enter.find("sendBtn.disabled = true")
i_label = enter.find('sendLabel.textContent = "Uploading…"')
assert -1 < i_preview < i_status < i_remove < i_send < i_label
# exit — the mirror image, ending on the exact idle label.
i_preview = exit_.find('attachPreview.classList.remove("is-uploading")')
i_status = exit_.find("attachStatus.hidden = true")
i_remove = exit_.find("attachRemove.disabled = false")
i_send = exit_.find("sendBtn.disabled = false")
i_label = exit_.find('sendLabel.textContent = "Send"')
assert -1 < i_preview < i_status < i_remove < i_send < i_label
# the idle restore is byte-identical to setUiState's idle label —
# the same literal "Send", so no stale "Uploading…" can survive.
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send";' in js
# the house null-guard pattern on the attach family only (a New
# chat mid-upload hides the strip; the class on a hidden element
# is harmless and still cleared at resolution); the send refs are
# unconditional (the chat view cannot exist without the composer).
for fn_src in (enter, exit_):
for guarded in ("attachPreview", "attachStatus", "attachRemove"):
assert f"if ({guarded})" in fn_src, f"{guarded} must be null-guarded"
assert "if (sendBtn)" not in fn_src and "if (sendLabel)" not in fn_src
# the PRE-turn contract: neither helper may touch the turn's
# state owners (the turn loader spins only when the turn starts).
for fn_src in (enter, exit_):
assert "setUiState" not in fn_src
assert "uiState" not in fn_src
assert "turnLoader" not in fn_src
assert "innerHTML" not in fn_src, "the house rule: textContent only"
# the module-scope ref: the status part (no text ref — static).
assert 'const attachStatus = document.querySelector(".attach-preview-status");' in js
assert "attachStatusText" not in js
def test_handle_send_wraps_the_await_with_the_upload_state() -> None:
"""In ``handleSend``'s attachedImage branch: ``enterUploadState()``
lands EXACTLY ONCE — after the ``attachUpload = true;`` guard line
and BEFORE ``await uploadAttachedImage`` (the moment the upload
starts — never before, never twice); ``exitUploadState()`` lands
EXACTLY ONCE — after the ``attachUpload = false;`` line (the
helper never throws, so this is reached on BOTH success and
failure) and BEFORE the ``if (path === null) return;`` block
(the failure path exits the state, then returns with the banner +
the editable composer + the attachment for the retry). The
``attachUpload`` guard is byte-untouched, the upload helper is
still called from exactly one site (the def + the call), and
``handleSend`` itself never calls ``setUiState`` (the pre-turn
contract — the turn's own state takes over inside runTurn)."""
js = _app_js()
# the EXACT handleSend body (brace matched — the file-order
# neighbors of handleSend carry their own setUiState references in
# their docstrings; the pin is on this function alone).
body = _fn(js, "handleSend")
i_guard = body.index("if (attachUpload) return;")
i_arm = body.index("attachUpload = true;")
i_enter = body.index("enterUploadState();")
i_await = body.index("await uploadAttachedImage(attachedImage.file)")
i_release = body.index("attachUpload = false;")
i_exit = body.index("exitUploadState();")
i_block = body.index("if (path === null) return;")
assert -1 < i_guard < i_arm < i_enter < i_await < i_release < i_exit < i_block, (
"guard → arm → ENTER → await → release → EXIT → block, in order"
)
assert body.count("enterUploadState();") == 1, "entered exactly once per upload"
assert body.count("exitUploadState();") == 1, (
"exited exactly once — the helper never throws, one line covers both paths"
)
# the pre-phase contract is byte-untouched: the guard, the single
# call site of the upload helper (def + call = 2 occurrences),
# and the A8 block line.
assert (
"if (attachUpload) return; // a second submit mid-upload: "
"the first owns the send (never two uploads, never two turns)" in js
)
assert js.count("uploadAttachedImage") == 2
# handleSend itself never drives the turn's UI state — the upload
# is PRE-turn (uiState stays idle across the whole flight); the
# call is absent (the word survives only in the phase-48 comment).
assert "setUiState(" not in body
#: The node harness (the test-document-viewer pattern): the REAL
#: extracted enterUploadState / exitUploadState run against stub
#: element refs; every transition is snapshotted as a JSON row —
#: [tag, isUploading, statusHidden, removeDisabled, sendDisabled,
#: label]. Plain strings only (the script is concatenation-assembled).
_UPLOAD_NODE_HEAD = """
const _classes = new Set();
const attachPreview = {
classList: {
add: (c) => _classes.add(c),
remove: (c) => _classes.delete(c),
contains: (c) => _classes.has(c),
},
};
const attachStatus = { hidden: true };
const attachRemove = { disabled: false };
const sendBtn = { disabled: false };
const sendLabel = { textContent: "Send" };
const show = (tag) => {
console.log(JSON.stringify([
tag,
_classes.has("is-uploading"),
attachStatus.hidden,
attachRemove.disabled,
sendBtn.disabled,
sendLabel.textContent,
]));
};
"""
_UPLOAD_NODE_TAIL = """
show("initial");
enterUploadState();
show("enter");
exitUploadState();
show("exit-success");
enterUploadState();
show("re-enter");
exitUploadState();
show("exit-failure");
"""
#: node availability (the test-document-viewer pattern).
HAVE_NODE = shutil.which("node") is not None
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_upload_state_transitions_under_node() -> None:
"""Behavioral check (node) of the REAL enterUploadState /
exitUploadState against stub element refs (the test-document-
viewer pattern), across the pinned transition set:
* entry: the strip gains .is-uploading, the status part unhides,
the remove ✕ disables, the Send disables with "Uploading…";
* exit (success path): the mirror image — class removed, status
re-hidden, remove re-enabled, Send re-enabled with "Send";
* exit (failure path — the same call, banner the failure's
signal, not the strip's): byte-identical to the success exit —
the state is fully cleared either way (the user can retry or
remove).
"""
js = _app_js()
fns = "\n".join(_fn(js, name) for name in ("enterUploadState", "exitUploadState"))
rows = [
json.loads(line)
for line in _run_node(_UPLOAD_NODE_HEAD + fns + _UPLOAD_NODE_TAIL).splitlines()
if line
]
by_tag = {row[0]: row for row in rows}
# row = [tag, isUploading, statusHidden, removeDisabled, sendDisabled, label]
assert by_tag["initial"] == ["initial", False, True, False, False, "Send"]
assert by_tag["enter"] == ["enter", True, False, True, True, "Uploading…"], (
"entry is the full affordance — strip + both halves of the button"
)
# success and failure exits are byte-identical: fully cleared, the
# idle label restored exactly.
for tag in ("exit-success", "exit-failure"):
assert by_tag[tag] == [tag, False, True, False, False, "Send"], tag
# a re-entry (the retry after a failed upload) re-arms the full
# affordance — the helpers are idempotent in both directions.
assert by_tag["re-enter"] == ["re-enter", True, False, True, True, "Uploading…"]
# =====================================================================
# Phase 132 (task 02, TODO L8) — the strip's uploading CSS (the visual
# half of the affordance: the dimmed thumbnail, the hidden name slot,
# the spinning --ink-soft status, the dimmed remove ✕, the reduced-
# motion still, and the verified house :disabled language on the Send)
# =====================================================================
def _phase132_css() -> str:
"""The Phase 132 CSS block in styles.css — from its comment marker
to the next top-level rule (the ``.composer`` that follows the
``.attach-preview*`` family) — with the comments stripped, so the
raw-color audit sees only declarations."""
css = _css()
start = css.find("/* Phase 132 (task 02, TODO L8):")
assert start != -1, "the Phase 132 CSS block must exist in styles.css"
end = css.find("\n.composer {", start)
assert end != -1, "the Phase 132 block must sit before the .composer rule"
return re.sub(r"/\*.*?\*/", "", css[start:end], flags=re.S)
def test_the_uploading_strip_dims_the_thumbnail_and_keeps_the_name_slot() -> None:
"""The in-flight strip: the thumbnail DIMS (opacity 0.5 — it is
decorative, no contrast duty), and the filename HIDES with
visibility (NOT display/width — the flex: 1 slot keeps its measured
space, so the status takes the row's right hand without reflowing
the pinned 44px remove button; the strip height is stable)."""
css = _css()
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-img \{(.*?)\}", css, re.S)
assert m, "the uploading thumbnail must dim"
assert "opacity: 0.5;" in m.group(1)
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-name \{(.*?)\}", css, re.S)
assert m, "the uploading name must hide"
body = m.group(1)
assert "visibility: hidden;" in body
assert "display: none" not in body and "width: 0" not in body, (
"the name slot must keep its space (visibility, not removal)"
)
def test_the_uploading_status_row_is_the_ink_soft_small_text() -> None:
"""The status part's row: the house inline-flex language (the icon
+ the text as one unit), --ink-soft at the strip's small text size
(the name's 0.85rem), and min-width 0 (the row's clamp — at 320px
the text wraps rather than clipping; verified in the task's browser
pass: one line at 320px, no clip)."""
m = re.search(r"\.attach-preview-status \{(.*?)\}", _css(), re.S)
assert m, ".attach-preview-status must be styled"
body = m.group(1)
for needle in (
"display: inline-flex;",
"align-items: center;",
"gap: 0.35rem;",
"color: var(--ink-soft);",
"font-size: 0.85rem;",
"min-width: 0;",
):
assert needle in body, f".attach-preview-status must keep: {needle}"
def test_the_status_icon_is_the_14px_house_spin_at_the_sync_pace() -> None:
"""The arc: 14px (the strip's small-text scale), flex 0 0 auto (the
.sync-icon house pattern), animated with the HOUSE `spin`
keyframes at 1s linear — .sync-icon.is-spinning's in-flight pace
(the .spinner's 0.8s is the busy-turn arc's). The keyframes must
EXIST — a dangling animation-name would spin nothing and the
"in motion" state would be invisible."""
m = re.search(r"\.attach-preview-status-icon \{(.*?)\}", _css(), re.S)
assert m, ".attach-preview-status-icon must be styled"
body = m.group(1)
for needle in ("width: 14px;", "height: 14px;", "flex: 0 0 auto;"):
assert needle in body, f".attach-preview-status-icon must keep: {needle}"
assert "animation: spin 1s linear infinite;" in body
assert re.search(r"@keyframes spin \{\s*to \{ transform: rotate\(360deg\); \}\s*\}", _css()), (
"the house `spin` keyframes must exist (the arc's animation-name)"
)
def test_reduced_motion_stills_the_uploading_arc() -> None:
"""The house reduced-motion block: with prefers-reduced-motion the
arc STILLS (animation none) — the "Uploading image…" text + the
dimmed thumbnail then carry the state without motion (B5: never
motion alone; the state is text + dimming, not the spin)."""
m = re.search(
r"@media \(prefers-reduced-motion: reduce\) \{\s*"
r"\.attach-preview-status-icon \{ animation: none; \}\s*\}",
_css(),
)
assert m, "the house reduced-motion block must still the uploading arc"
def test_the_disabled_remove_x_dims_with_the_disabled_affordance() -> None:
"""The remove ✕ (task 01 disables it mid-flight via the attribute)
dims to 0.5 while uploading — the attribute is the state, the
opacity is its visual half (the same 0.5 as the dimmed thumbnail,
the house dim language)."""
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-remove \{(.*?)\}", _css(), re.S)
assert m, "the uploading remove button must dim"
assert "opacity: 0.5;" in m.group(1)
def test_the_disabled_send_rule_is_the_existing_house_rule_not_a_new_visual() -> None:
"""Task 02's verification step: the Send is disabled via the
`disabled` attribute (task 01) and the EXISTING house rule
``.send-btn:disabled`` already reads as disabled — the
--brand-busy background (the brand brightened 40% toward white,
visibly lighter than the enabled --brand fill, and a derived
variable that follows every saved theme) + the not-allowed
cursor. The pin says the rule is INTACT: the upload state must
not invent a second disabled look (no new color, no new weight).
(Verified in the task's browser pass: the rule applies to
#send-btn, which rides .send-btn.)"""
m = re.search(r"\.send-btn:disabled \{(.*?)\}", _css(), re.S)
assert m, "the house .send-btn:disabled rule must exist"
body = m.group(1)
assert "background: var(--brand-busy);" in body
assert "cursor: not-allowed;" in body
def test_the_phase132_css_ships_no_raw_color_and_holds_the_aa_pair() -> None:
"""The theme audit (the B4 byte-identical contract): every value
in the Phase 132 block is a theme variable or a neutral number —
NO raw hex/rgb/hsl (a custom theme repaints everything through the
variables). And the status text's --ink-soft on the strip's
--surface holds the AA floor: computed here against WCAG 2.1 from
the built-in :root literals (the theme phases' method) — 8.2:1,
≥ 4.5:1."""
block = _phase132_css()
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), "no raw hex in the Phase 132 block"
assert not re.search(r"\b(?:rgb|rgba|hsl|hsla)\(|\bsrgb\b", block), (
"no raw rgb/hsl in the Phase 132 block"
)
ratio = _wcag_contrast(_root_color("--ink-soft"), _root_color("--surface"))
assert ratio >= 4.5, f"--ink-soft on --surface = {ratio:.2f}:1 (< 4.5:1 AA floor)"
def _root_color(name: str) -> str:
"""A built-in :root literal color (the theme phases audit the
built-in palette this way; a saved theme repaints through the
variables and is checked by the theme E2E)."""
m = re.search(rf"(?m)^\s*{name}:\s*(#[0-9a-fA-F]{{6}})", _css())
assert m, f"{name} must ship a plain built-in color in :root"
return m.group(1)
def _hex_rgb(value: str) -> tuple[int, int, int]:
h = value.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
"""WCAG 2.1 relative luminance (the house helper's math — the
test_responsive_polish / test_dark_tech_theme contrast helper)."""
def chan(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def _wcag_contrast(fg: str, bg: str) -> float:
l1, l2 = _rel_luminance(_hex_rgb(fg)), _rel_luminance(_hex_rgb(bg))
if l1 < l2:
l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)