phase: 149_sync_progress_box
Build and Push Containers / build-and-push-app (push) Successful in 3m35s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All verification complete. The `ink-soft`/`--surface` pair the box uses is already pinned AA by the existing theme E2E suites (8.2:1 dark, ~6.9:1 light). Everything is green — no fixes were needed.

**Phase 149 final verification report**

- Verified (no re-work needed): `#sync-progress` box wiring (single `setSyncLabel` writer with `running` flag; `is-running` class travels with the spin; aria-label owned by the writer), the panel/icon-only-button CSS, and the 5-test dedicated E2E; all 6 pre-existing sync E2Es' running pins migrated to the box, settled pins untouched.
- Dedicated E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed in 198.6 s** (isolation, slow-proxy mock; under the 600 s budget).
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed, app/ coverage 99%** (>90% gate).
- Migrated sync E2Es, each in isolation: `test_sync_button` 3 passed, `test_sync_cancel` 3, `test_sync_chunk_progress` 2, `test_sync_label_mobile` 4, `test_sync_summary_visibility` 3, `test_sync_upload_progress` 4 — all green.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Criteria: in-flight spinner-only button + full untruncated wrapped box (desktop & mobile 390×844, `scrollWidth ≤ clientWidth`) ✓; settle byte-identical ("Synced HH:MM" + `3 added`, box hidden, aria restored) ✓; `#sync-result` sole live region (box `aria-hidden`), mid-run reload re-attaches ✓; WCAG (AA contrast both themes via the pinned ink-soft/surface pair, focus-visible, reduced-motion kill intact at styles.css L2201) ✓; cancel/409/error/upload/status API unchanged (green suites above) ✓.
- Documentation: README.md reviewed in full (sync-states paragraph now matches the shipped UI; env-var table matches `app/config.py` and covers all `.env.example` vars; pages/commands verified) and AGENTS.md reviewed in full — **verified consistent, no changes needed**.
- Notable: no defects found in this or prior work; no live infrastructure touched.
- Next pending phase: none — `todo/` will be empty once the harness moves 149 (only 149 was pending).
This commit is contained in:
2026-10-01 21:14:48 -04:00
parent b233c8d714
commit dd524246ec
33 changed files with 2512 additions and 393 deletions
@@ -0,0 +1,13 @@
All verification complete. The `ink-soft`/`--surface` pair the box uses is already pinned AA by the existing theme E2E suites (8.2:1 dark, ~6.9:1 light). Everything is green — no fixes were needed.
**Phase 149 final verification report**
- Verified (no re-work needed): `#sync-progress` box wiring (single `setSyncLabel` writer with `running` flag; `is-running` class travels with the spin; aria-label owned by the writer), the panel/icon-only-button CSS, and the 5-test dedicated E2E; all 6 pre-existing sync E2Es' running pins migrated to the box, settled pins untouched.
- Dedicated E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed in 198.6 s** (isolation, slow-proxy mock; under the 600 s budget).
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed, app/ coverage 99%** (>90% gate).
- Migrated sync E2Es, each in isolation: `test_sync_button` 3 passed, `test_sync_cancel` 3, `test_sync_chunk_progress` 2, `test_sync_label_mobile` 4, `test_sync_summary_visibility` 3, `test_sync_upload_progress` 4 — all green.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Criteria: in-flight spinner-only button + full untruncated wrapped box (desktop & mobile 390×844, `scrollWidth ≤ clientWidth`) ✓; settle byte-identical ("Synced HH:MM" + `3 added`, box hidden, aria restored) ✓; `#sync-result` sole live region (box `aria-hidden`), mid-run reload re-attaches ✓; WCAG (AA contrast both themes via the pinned ink-soft/surface pair, focus-visible, reduced-motion kill intact at styles.css L2201) ✓; cancel/409/error/upload/status API unchanged (green suites above) ✓.
- Documentation: README.md reviewed in full (sync-states paragraph now matches the shipped UI; env-var table matches `app/config.py` and covers all `.env.example` vars; pages/commands verified) and AGENTS.md reviewed in full — **verified consistent, no changes needed**.
- Notable: no defects found in this or prior work; no live infrastructure touched.
- Next pending phase: none — `todo/` will be empty once the harness moves 149 (only 149 was pending).
@@ -0,0 +1,120 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 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 270 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 196 1 99%
app/api/git_sources.py 259 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 183 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 265 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 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 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 77 0 100%
app/rag/importer.py 744 4 99%
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 124 0 100%
app/rag/prompts.py 155 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 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5609 17 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,15 @@
Everything is verified and green. Task 01 is complete.
**Report — Phase 149, Task 01 (`01_progress_box_wiring.md`)**
- **HTML:** `#sync-progress` box (`.sync-progress-text` span) inserted between the `.page-head` block and `#sync-result` — ship-hidden, `aria-hidden` (presentational; `#sync-result` stays the sole aria-live announcer).
- **sources.js (single-writer wiring):** `setSyncLabel` gains `running=false` — writes the SAME full untruncated label into the box, reveals it IFF a run is in flight, and owns the button's `aria-label` (full live label while running, `"Sync sources"` restored on every settle). `enterSyncRunningState` passes `running=true` + adds `is-running` (with the spin, its old aria write removed); `settleSyncButton` removes `is-running` (with the spin). Covers all 5 running entries (poll ticks, 202/409 click, both re-attaches) and every settle.
- **Unit tests:** pins moved to the new shape + new HTML markup pin + node-harness rows gain box text/hidden, `is-running`/`is-spinning`, aria-label, with a new behavioral test (box visible IFF running, exact full label, restored at settle).
- **E2E migration (six sync suites):** running-state label pins → `#sync-progress` (regexes target `#sync-progress-text` — the div's innerText carries newline artifacts); count `to_be_visible` → box visibility; `test_sync_label_mobile` tests 1–3 reworked box-based (full string + viewport-fit + `is-running`), `_settle` gains the box-hidden pin; all settled/idle + `#sync-result` pins untouched.
- **Docs:** README "States" bullet updated (button = spinner, detail box under the row); no AGENTS.md impact.
**Results:** `uv run pytest --cov=app` → **3463 passed, 99% coverage** (gate >90%); `uv run ruff check .` clean; `uv run pyright` 0 errors; all 11 sync-related E2E files green in isolation (`--no-cov`), incl. the six migrated suites.
**Notable:** (1) `test_sync_label_mobile.py` test 4 (pre-phase-128 CSS clip reproduction) stays green now, but task 02's `.sync-btn.is-running .sync-label{display:none}` (higher specificity) will make the injected rules lose — task 02 must migrate/drop it. (2) Owner appended 2 new TODO.md items mid-session (image-embed sync status) — untouched; likely a future phase.
**Next pending task:** `02_progress_box_styles.md` (box panel CSS, icon-only running button, mobile, AA/motion gates).
@@ -0,0 +1,120 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 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 270 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 196 1 99%
app/api/git_sources.py 259 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 183 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 265 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 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 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 77 0 100%
app/rag/importer.py 744 4 99%
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 124 0 100%
app/rag/prompts.py 155 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 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5609 17 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,18 @@
All green. Task complete.
**Implemented (task 02 — box styling):**
- `.sync-progress` panel in `frontend/assets/styles.css`: house bordered-panel language (`--surface` fill, 1px `--line` border, `--radius` radius, `0.75rem 1rem` padding, `min-height: 4rem` ≈ 2 lines). No `margin-top` — the `.sources-shell` flex gap (1.25rem) *is* the house spacing; a margin would double it (documented in the CSS comment)
- `.sync-progress-text`: `display: block`, `var(--mono)`, `0.8rem` (the `.sync-result` size), house `1.55` line-height, `var(--ink-soft)` (pinned identity pair on `--surface`: 8.2:1 built-in dark, theme gate keeps all themes ≥4.5:1), `overflow-wrap: anywhere` — long paths WRAP, never clip
- `.sync-btn.is-running .sync-label { display: none }` — icon-only footprint (16px icon centered in a ~47×44 pill; mobile keeps the full-width 44px bar via the pre-existing `width: 100%` rule); viewport-agnostic, defined once. Hover/`:disabled`/`:focus-visible`/`prefers-reduced-motion` untouched (they live on the button; the spinner kill at L2202 stands)
- Mobile media query: verified no changes needed (box is a block in the 72rem container — full-width at 360–390px; no dead mobile label rules — idle label still renders, ellipsis rules kept)
- **Defect fix (task 01 work):** `test_sync_label_mobile.py::test_pre_phase_css_reproduces_the_clip` went vacuous — its injected pre-phase rules (0,1,0) lost to the new (0,2,0) hide rule, leaving the parts `display:none`. The counterfactual injection set now also neutralizes the phase-149 hide rule (it didn't exist pre-phase), restoring the clip-geometry pin
**Validation — 100% green:**
- `uv run pytest --cov=app --cov-report=term` → 3463 passed, `app/` coverage 99% (>90%)
- All 11 sync/upload E2E files pass in isolation: test_sync_button (3), test_sync_label_mobile (4), test_sync_cancel (3), test_sync_chunk_progress (2), test_sync_summary_visibility (3), test_sync_upload_progress (4), test_sync_changed_only (3), test_sync_model_down (4), test_force_push_resync (2), test_archive_upload_sources (5), test_upload_no_scan (3)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- Visual verification: 8 screenshots (desktop/mobile × dark/light × idle/running) — bordered mono panel with wrapped long path, icon-only spinner button, "Sync sources" pill restored at idle, AA contrast both themes
**Docs:** no doc impact (feature-level README text stays accurate; no commands/env/API/convention changes)
**Next pending task:** `03_e2e_progress_box.md` (the dedicated in-flight/settled Playwright suite)
@@ -0,0 +1,120 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 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 270 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 196 1 99%
app/api/git_sources.py 259 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 183 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 265 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 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 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 77 0 100%
app/rag/importer.py 744 4 99%
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 124 0 100%
app/rag/prompts.py 155 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 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5609 17 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,16 @@
All gates green. Final report:
**Implemented**
- `tests/e2e/test_sync_progress_box.py` — dedicated phase-149 E2E (5 tests, module app with `BOR_GIT_SOURCES=file://` fixture repo + own `BOR_SOURCES_DIR` + slow-LLM proxy, 1.5s): spinner-only button while running (`is-running`, `is-spinning`, `#sync-label` hidden, `aria-label` == box text, Cancel visible, box `aria-hidden`); box carries the EXACT full untruncated long-path label (wrap proof `scrollWidth <= clientWidth`, announcer `#sync-result` + `aria-label` equal, contrast ≥4.5:1) with the phase-129 `· chunk n/m` part pinned in the box over the slow embed window; mobile 390×844 box full-width + icon-only full-width button + wrapped full path; settle restores the byte-identical pill (`Synced HH:MM`, `aria-label` "Sync sources", `3 added`, box hidden); mid-run reload re-attaches with the box visible.
**Test / lint / coverage**
- `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed** in isolation (×2 runs, ~205s)
- `uv run pytest --cov=app` → **3463 passed**, app coverage **99%**
- `uv run ruff check . && uv run pyright` → **clean**
- All 8 pre-existing sync E2Es green: button (3), cancel (3), chunk_progress (2), label_mobile (4), summary_visibility (3), upload_progress (4), model_down (4), changed_only (3)
**Docs** — no doc impact (README's sync section was already updated by tasks 01/02; AGENTS.md unaffected).
**Decisions** — mobile button full-width reference is the `.page-head` card content width (its containing block), not the `.container`; added a failed-terminal diagnostic print in the boundary wait (surfaced a test-cascade, not an app defect — both post-fix full runs fully green).
**Next pending task** — none for phase 149 (task 03 was the last; harness moves the phase on completion).
@@ -0,0 +1,120 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 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 270 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 196 1 99%
app/api/git_sources.py 259 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 183 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 265 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 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 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 77 0 100%
app/rag/importer.py 744 4 99%
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 124 0 100%
app/rag/prompts.py 155 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 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5609 17 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,14 @@
**What was fixed**
- No code defects found: the prior rejection was a missing final report (the harness's own a1 validation passed — coverage gate OK, ruff, pyright). Re-verified everything and tightened the README settle sentence (was "idle 'Sync sources' pill returns"; now byte-accurate: "Synced HH:MM" pill after success with the counts line, retry-ready "Sync sources" after failure/cancel — matching the E2E-pinned `settleSyncButton`/`applySyncSuccess` behavior).
**Test / lint / coverage (all re-run this pass)**
- `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed**, app coverage **99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → **clean** (0 errors)
- `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed** in isolation (198s)
- Pre-existing sync E2Es (button/cancel/chunk_progress/label_mobile/summary_visibility/upload_progress/model_down/changed_only): **26 passed** (run on this same tree; only README changed since)
**Docs** — README.md settle contract corrected (doc-only); AGENTS.md re-checked, no sync-UI content, no changes.
**Notable decisions** — README edit is documentation-only (not served by the app, no test impact); the phase-149 sync section otherwise verified accurate against the E2E pins (spinner-only button, `aria-hidden` box, A4 single live region, 409/cancel contracts).
**Next pending task** — none; task 03 is the last task of phase 149 (harness completes the phase).
@@ -0,0 +1,120 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 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 270 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 196 1 99%
app/api/git_sources.py 259 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 183 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 265 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 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 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 77 0 100%
app/rag/importer.py 744 4 99%
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 124 0 100%
app/rag/prompts.py 155 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 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5609 17 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
+15 -8
View File
@@ -326,14 +326,21 @@ whole git-source refresh in one click:
rows, not the whole table); an unchanged re-sync instead gap-fills rows, not the whole table); an unchanged re-sync instead gap-fills
any missing rows (`only_missing`). any missing rows (`only_missing`).
- States: the button shows **Syncing…** with a spinner while polling - States: while polling `GET /api/sync/status` every 2 s, the button
`GET /api/sync/status` every 2 s. During the import phase the label becomes the spinner (icon only — its label parts are hidden while a
carries the live file, its `(n/m)` file position, and — while a file's run is in flight, and its accessible name is the full live label),
embed batch is in flight — the pinned `· chunk n/m` embed position and the full live detail — "Syncing… <file> (n/m)" during the import
(batch granularity: the first chunk of the in-flight batch over the phase (the live file, its `(n/m)` file position, and — while a file's
file's total content chunks); the full untruncated label also rides embed batch is in flight — the pinned `· chunk n/m` embed position,
the button title and the aria-live result line. No client-side batch granularity: the first chunk of the in-flight batch over the
timeout — a clone + embed can legitimately take minutes. file's total content chunks) — shows in a text box under the button
row, wrapping (never clipped); the same untruncated label rides the
button title and the aria-live result line (the sole live region —
the box is presentational, `aria-hidden`). On settle the box hides
and the button leaves the running state — the "Synced HH:MM" pill
after a success (the counts line lands on the result line), the
retry-ready "Sync sources" pill after a failure or cancel. No
client-side timeout — a clone + embed can legitimately take minutes.
- One sync at a time: a second trigger while a run is in flight gets a - One sync at a time: a second trigger while a run is in flight gets a
`409`. `409`.
- **Cancel**: while a run is in flight, a **Cancel** button appears - **Cancel**: while a run is in flight, a **Cancel** button appears
+3
View File
@@ -1 +1,4 @@
# TODO # TODO
- [ ] The sync status on the RAG page gets "stuck" when it's embedding images. For example, when syncing test_sources/podman-systemd.pdf the status was stuck on `Syncing… podman-systemd.unit — Podman documentation/podman-systemd.unit — Podman documentation.pdf (2540/2540) · chunk 125/126` for several minutes for some reason
- [ ] For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?
+84 -7
View File
@@ -361,6 +361,31 @@
* "Sync cancelled", never a stuck running state). The bor:sync-status * "Sync cancelled", never a stuck running state). The bor:sync-status
* event shape is unchanged — the cancelled status object flows through * event shape is unchanged — the cancelled status object flows through
* emitSyncStatus exactly like every other state. * emitSyncStatus exactly like every other state.
*
* Phase 149 (task 01) — the sync detail box + the icon-only running
* button: the owner's report was that the full live label (a long
* source/relative/path + the counts + the chunk part) is clipped in
* the pill even on desktop — the mobile solution (stuffing the parts
* in the button) had become the permanent surface. The fix: while a
* sync/upload run is in flight, the button shows ONLY the spinning
* icon (the `is-running` class — task 02's CSS hides the label parts;
* the button IS the loading spinner, exactly the owner's ask), and a
* NEW bordered text box under the button row (#sync-progress, the
* presentational `aria-hidden` panel) shows the SAME full untruncated
* live label — wrapping, never clipped, breathing room. #sync-result
* REMAINS the sole aria-live announcer (one live region, no double
* announce — the box is a visual duplicate for sighted users). The
* wiring rides the SINGLE writer: setSyncLabel gains the `running`
* flag (true on the running entry — the poll ticks, the 202/409
* click, both re-attach entries; the settle default false) and writes
* the full label into the box + reveals/hides it, and owns the
* button's accessible name (full live label while running, the ship
* value "Sync sources" restored on every settle). The `is-running`
* class travels with the spin: added in enterSyncRunningState, removed
* in settleSyncButton (every terminal + both idles pass through it).
* The settle rendering ("Synced HH:MM" + the counts line), the Cancel
* control, the 409 double-trigger, the error banner/modal, and the
* sync status API are all UNCHANGED.
*/ */
import { fetchIsAdmin } from "./header.js"; import { fetchIsAdmin } from "./header.js";
@@ -695,7 +720,11 @@ export async function mount(root) {
* import phase after the file's first batch)), #sync-cancel-btn * import phase after the file's first batch)), #sync-cancel-btn
* (the Cancel control — phase 134 task 02, ship-hidden, revealed * (the Cancel control — phase 134 task 02, ship-hidden, revealed
* only while a sync run is in flight), #sync-icon (the * only while a sync run is in flight), #sync-icon (the
* spinner icon), #sync-result (aria-live result line), * spinner icon), #sync-result (aria-live result line — the SOLE
* live region), #sync-progress (Phase 149 task 01: the full
* untruncated live label's detail box under the button row —
* presentational, aria-hidden, visible only while a run is in
* flight; the .sync-progress-text span carries the label),
* #sync-error-banner / #sync-error-text (error banner). * #sync-error-banner / #sync-error-text (error banner).
*/ */
const syncBtn = root.querySelector("#sync-btn"); const syncBtn = root.querySelector("#sync-btn");
@@ -709,6 +738,15 @@ export async function mount(root) {
const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null; const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null;
const syncLabelChunk = syncLabel ? syncLabel.querySelector(".sync-label-chunk") : null; const syncLabelChunk = syncLabel ? syncLabel.querySelector(".sync-label-chunk") : null;
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null; const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
// Phase 149 (task 01): the sync detail box — the full UNTRUNCATED
// live label in a bordered panel under the button row, visible ONLY
// while a run is in flight (setSyncLabel reveals/hides it). The box
// is presentational (aria-hidden): #sync-result stays the SOLE
// aria-live announcer (one live region, no double announce).
const syncProgress = root.querySelector("#sync-progress");
const syncProgressText = syncProgress
? syncProgress.querySelector(".sync-progress-text")
: null;
const syncResult = root.querySelector("#sync-result"); const syncResult = root.querySelector("#sync-result");
const syncErrorBanner = root.querySelector("#sync-error-banner"); const syncErrorBanner = root.querySelector("#sync-error-banner");
const syncErrorText = root.querySelector("#sync-error-text"); const syncErrorText = root.querySelector("#sync-error-text");
@@ -755,8 +793,20 @@ export async function mount(root) {
* them are the CSS gap). textContent only — the house rule, no * them are the CSS gap). textContent only — the house rule, no
* HTML strings. Every label state goes through here (the running * HTML strings. Every label state goes through here (the running
* builders, the idle / settle labels, the 202/409 click, the * builders, the idle / settle labels, the 202/409 click, the
* re-attach). */ * re-attach).
function setSyncLabel(mainText, countText = "", chunkText = "") { * Phase 149 (task 01): the fourth parameter — `running` (the
* in-flight flag; the callsites are the ONLY decision sites: the
* running entry passes true, the settle passes the default false).
* The box (#sync-progress) gets the SAME untruncated `full` string
* (textContent only) and is visible IFF `running` — the detail box
* rides the writer, so it can never disagree with the button
* title / aria / the announcer. The button's accessible name rides
* here too: while running the icon-only button (task 02 hides the
* label parts) reads the FULL live label (the title already carries
* it for hover); every non-running call restores the ship value
* "Sync sources" (the static HTML attribute is the idle truth —
* the JS only overwrites/restores it, never removes it). */
function setSyncLabel(mainText, countText = "", chunkText = "", running = false) {
if (syncLabelMain) syncLabelMain.textContent = mainText; if (syncLabelMain) syncLabelMain.textContent = mainText;
if (syncLabelCount) { if (syncLabelCount) {
if (countText) { if (countText) {
@@ -782,6 +832,15 @@ export async function mount(root) {
(chunkText ? ` ${chunkText}` : ""); (chunkText ? ` ${chunkText}` : "");
if (syncBtn) syncBtn.title = full; // A4: the untruncated label rides the title, always if (syncBtn) syncBtn.title = full; // A4: the untruncated label rides the title, always
if (syncResult) syncResult.textContent = full; // A4: the announcer hears the whole thing if (syncResult) syncResult.textContent = full; // A4: the announcer hears the whole thing
// Phase 149 (task 01): the detail box — the SAME untruncated label
// (one source, two surfaces: sighted users read the box, the
// announcer hears it), visible only while a run is in flight.
if (syncProgressText) syncProgressText.textContent = full;
if (syncProgress) syncProgress.hidden = !running;
// The running button is the spinner (task 02 hides the label
// parts) — its accessible name is the full live label; every
// settle restores the ship value (see the function docstring).
if (syncBtn) syncBtn.setAttribute("aria-label", running ? full : "Sync sources");
} }
/* Phase 64 (task 04) + Phase 128 (task 01, the PARTS rework): the /* Phase 64 (task 04) + Phase 128 (task 01, the PARTS rework): the
@@ -894,7 +953,13 @@ export async function mount(root) {
* same way (absent from the bare builders — the upload job and the * same way (absent from the bare builders — the upload job and the
* 202/409 click — so the "" default keeps the part hidden there). * 202/409 click — so the "" default keeps the part hidden there).
* Same never-stale mechanics as before (disabled, aria-busy, * Same never-stale mechanics as before (disabled, aria-busy,
* spinning icon, no is-error). * spinning icon, no is-error). Phase 149 (task 01): the writer call
* passes `running = true` — the detail box reveals with the full
* label, and the button's accessible name becomes the full live
* label (the icon-only running button keeps a name; the writer
* owns the aria write, the entry no longer sets it) — plus the
* `is-running` class, which travels with the spin (task 02's CSS
* makes the running button icon-only).
*/ */
function enterSyncRunningState(kind, currentFile, done, total, status) { function enterSyncRunningState(kind, currentFile, done, total, status) {
if (!syncBtn) return; if (!syncBtn) return;
@@ -904,10 +969,15 @@ export async function mount(root) {
kind === "sync" && status kind === "sync" && status
? fmtSyncPhaseLabel(status) ? fmtSyncPhaseLabel(status)
: fmtSyncLabel(kind, currentFile, done, total); : fmtSyncLabel(kind, currentFile, done, total);
setSyncLabel(main, count, chunk); // the parts + the A4 untruncated title / announcer // the parts + the A4 untruncated title / announcer + the phase-149
syncBtn.setAttribute("aria-label", "Sync sources"); // detail box (revealed, full label) + the running aria-label
setSyncLabel(main, count, chunk, true);
syncBtn.classList.remove("is-error"); syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning"); if (syncIcon) syncIcon.classList.add("is-spinning");
// Phase 149 (task 01): the icon-only running button — the class
// and the spin travel together (one in-flight state; the settle
// removes both).
syncBtn.classList.add("is-running");
// Phase 134 (task 02): the Cancel control is visible + enabled // Phase 134 (task 02): the Cancel control is visible + enabled
// ONLY while a SYNC run is in flight — the upload job is NOT // ONLY while a SYNC run is in flight — the upload job is NOT
// cancellable in this phase (its run keeps the control hidden). // cancellable in this phase (its run keeps the control hidden).
@@ -927,10 +997,17 @@ export async function mount(root) {
syncBtn.setAttribute("aria-label", "Sync sources"); syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error"); syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.remove("is-spinning"); if (syncIcon) syncIcon.classList.remove("is-spinning");
// Phase 149 (task 01): the class travels with the spin — EVERY
// settle (every terminal + both idles pass through here) leaves
// the running state.
syncBtn.classList.remove("is-running");
// Phase 128 (task 01): the idle / settle labels ("Sync sources", // Phase 128 (task 01): the idle / settle labels ("Sync sources",
// "Synced HH:MM") go through the parts writer — count hidden // "Synced HH:MM") go through the parts writer — count hidden
// (absent), the label's full string rides the title + #sync-result // (absent), the label's full string rides the title + #sync-result
// (A4, as with every label state). // (A4, as with every label state). Phase 149 (task 01): the
// default `running = false` keeps the detail box hidden in every
// non-running state (idle, settled, error) and re-restores the
// button's idle accessible name.
setSyncLabel(label); setSyncLabel(label);
} }
+44
View File
@@ -2236,6 +2236,50 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
white-space: nowrap; white-space: nowrap;
font-family: var(--mono); /* the same pinned-part language as the count (position reads as data) */ font-family: var(--mono); /* the same pinned-part language as the count (position reads as data) */
} }
/* Phase 149 (task 02): the sync detail box — the house's bordered panel
language (the .page-head card: --surface fill, 1px --line border,
the house radius; --line is decorative, no contrast duty, like every
other house border). It sits in the .sources-shell flex column whose
1.25rem gap IS the house spacing above and below — no margin-top
(it would double the gap). min-height ≈ 2 lines (0.8rem mono at the
body 1.55 line-height, + the 0.75rem block padding + the 2px border,
border-box) so even a one-line label reads as a calm panel with
breathing room. The box is hidden-attr-driven (the global [hidden]
!important rule keeps it display:none at idle — it exists only while
a run is in flight). Mobile: a block in the same .container rides the
100%-width phone shell — full width with no override (task 03's E2E
asserts it at 360px). */
.sync-progress {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 1rem;
min-height: 4rem;
}
.sync-progress-text {
display: block;
font-family: var(--mono); /* the .sync-result mono language (position reads as data) */
font-size: 0.8rem; /* the .sync-result size */
line-height: 1.55; /* the house body line-height */
color: var(--ink-soft); /* the .sync-result ink — a PINNED identity pair on --surface
(8.2:1 on the built-in dark, AA in both built-ins; the
theme gate keeps any custom theme at >= 4.5:1) */
overflow-wrap: anywhere; /* THE POINT of the box — a long source/relative/path WRAPS
inside the panel; the pre-phase pill ellipsis is retired
for the live detail (never clipped) */
}
/* Phase 149 (task 02): the running button IS the loading spinner (the
owner's ask) — the label parts hide via the .is-running class
(sources.js adds/removes it exactly where the icon's .is-spinning
travels). The pill's padding/border stay, so the control never
collapses: the 16px icon centers in a ~47x44px pill (desktop) or the
full-width 44px bar (mobile — the pre-phase .sync-btn { width: 100% }
rule keeps the big in-flight target, viewport-agnostic rule defined
ONCE here). :hover, :disabled, and :focus-visible are untouched —
they live on the .sync-btn element, and hiding the label span touches
neither. The idle "Sync sources" label (and the mobile ellipsis rules
for it) render exactly as pre-phase when the class is absent. */
.sync-btn.is-running .sync-label { display: none; }
/* Last-result announcer — soft ink, small mono */ /* Last-result announcer — soft ink, small mono */
.sync-result { .sync-result {
display: block; display: block;
+11
View File
@@ -482,6 +482,17 @@
to pull the latest and re-import. to pull the latest and re-import.
</p> </p>
</div> </div>
<!-- Phase 149 (task 01): the sync detail box — the full UNTRUNCATED live
label ("Syncing… <source/rel/path> (n/m) · chunk n/m") in a bordered
panel under the button row, visible ONLY while a sync/upload run is
in flight (sources.js reveals/hides it through the single setSyncLabel
writer). Presentational (aria-hidden) — #sync-result stays the SOLE
aria-live announcer (one live region, no double announce). The button
itself is the spinner while running (task 02's .is-running CSS hides
the label parts). -->
<div class="sync-progress" id="sync-progress" hidden aria-hidden="true">
<span class="sync-progress-text" id="sync-progress-text"></span>
</div>
<!-- #sync-result is the aria-live announcer: the last sync <!-- #sync-result is the aria-live announcer: the last sync
result ("N added · …") when a sync settles, and — phase 64 — result ("N added · …") when a sync settles, and — phase 64 —
the LIVE file label while a sync runs ("Syncing… <file> the LIVE file label while a sync runs ("Syncing… <file>
+15 -8
View File
@@ -11,7 +11,11 @@ The story gate runs the **real** sync path end to end — a real
network), a real import against the mock LLM, a real KB-overview network), a real import against the mock LLM, a real KB-overview
regeneration — plus the admin-only visibility and the full button regeneration — plus the admin-only visibility and the full button
lifecycle (§7.4: "Syncing…" → "Synced HH:MM" + counts, the idempotent lifecycle (§7.4: "Syncing…" → "Synced HH:MM" + counts, the idempotent
second run, the 409 double trigger). second run, the 409 double trigger). Phase 149 (task 01): the
RUNNING-state pins (the 202's "Syncing…") ride the detail box
(``#sync-progress`` — the running label left the pill; the button is
the spinner), while every SETTLED pin (the idle "Sync sources", the
"Synced HH:MM" label + counts line) is unchanged.
Per-module app env (the E2E conftest pattern, module-scoped): this Per-module app env (the E2E conftest pattern, module-scoped): this
story's app boots with ``BOR_GIT_SOURCES=file://<fixture repo>`` and story's app boots with ``BOR_GIT_SOURCES=file://<fixture repo>`` and
@@ -246,11 +250,12 @@ def test_anonymous_sees_no_button(page: Page, app_url: str, db_ready: None) -> N
def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None: def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
"""AC 2 + 4 — click → "Syncing…" (disabled) → "Synced HH:MM" + the """AC 2 + 4 — click → "Syncing…" (disabled; phase 149: the
counts, against the REAL pipeline (git clone of the ``file://`` running label is in the detail box — the button is the spinner)
fixture → import with prune → KB overview regeneration, mock LLM). → "Synced HH:MM" + the counts, against the REAL pipeline (git
Then the idempotent second run: fast-forward pull + sha256 hash clone of the ``file://`` fixture → import with prune → KB overview
skip → "0 added · 1 unchanged".""" regeneration, mock LLM). Then the idempotent second run:
fast-forward pull + sha256 hash skip → "0 added · 1 unchanged"."""
login(page, app_url) # lands on /sources.html (the button's home) login(page, app_url) # lands on /sources.html (the button's home)
btn = page.locator("#sync-btn") btn = page.locator("#sync-btn")
@@ -260,7 +265,9 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
# --- run 1: clone + import + overview -------------------------------- # --- run 1: clone + import + overview --------------------------------
btn.click() btn.click()
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…") # Phase 149 (task 01): the running detail rides the box under the
# button row (the button is the spinner) — the 202's bare label.
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
# The fixture doc (one A9-format file) is the only change. # The fixture doc (one A9-format file) is the only change.
@@ -282,7 +289,7 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
# --- run 2: idempotent pull + hash skip ------------------------------- # --- run 2: idempotent pull + hash skip -------------------------------
btn.click() btn.click()
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS) expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() expect(btn).to_be_enabled()
# Nothing re-embedded (sha256 delta) — the no-op run announces the # Nothing re-embedded (sha256 delta) — the no-op run announces the
+6 -4
View File
@@ -497,10 +497,12 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
btn.click() btn.click()
# The 202 enters the running state SYNCHRONOUSLY (the click handler # The 202 enters the running state SYNCHRONOUSLY (the click handler
#'s 202 branch, before the first 2 s poll tick): the main button #'s 202 branch, before the first 2 s poll tick): the main button
# disables + "Syncing…", and the Cancel control reveals + enables # disables, the detail box reveals with "Syncing…" (phase 149 —
# (enterSyncRunningState, kind "sync"). # the running label rides the box; the button is the spinner),
# and the Cancel control reveals + enables (enterSyncRunningState,
# kind "sync").
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(cancel).to_be_visible() expect(cancel).to_be_visible()
expect(cancel).to_be_enabled() expect(cancel).to_be_enabled()
@@ -553,7 +555,7 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
# --- run 2: the re-sync is clean -------------------------------------- # --- run 2: the re-sync is clean --------------------------------------
btn.click() btn.click()
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(cancel).to_be_visible() # the fresh run is cancellable again expect(cancel).to_be_visible() # the fresh run is cancellable again
expect(page.locator("#sync-label")).to_have_text( expect(page.locator("#sync-label")).to_have_text(
SYNCED_LABEL, timeout=RESYNC_TIMEOUT_MS SYNCED_LABEL, timeout=RESYNC_TIMEOUT_MS
+3 -1
View File
@@ -688,10 +688,12 @@ def _start_sync(page: Page, app_url: str, sync_dir: Path) -> None:
# the phase-64 contract): disabled, aria-busy, spinning icon, and # the phase-64 contract): disabled, aria-busy, spinning icon, and
# the label's MAIN part alone ("Syncing…" — the 202 click carries # the label's MAIN part alone ("Syncing…" — the 202 click carries
# no status, so both pinned parts hide, phase 128/129 writers). # no status, so both pinned parts hide, phase 128/129 writers).
# Phase 149 (task 01): the running detail rides the box — the 202's
# bare label is in #sync-progress (the button is the spinner).
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true") expect(btn).to_have_attribute("aria-busy", "true")
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b")) expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator(".sync-label-count")).to_be_hidden() expect(page.locator(".sync-label-count")).to_be_hidden()
expect(page.locator(".sync-label-chunk")).to_be_hidden() expect(page.locator(".sync-label-chunk")).to_be_hidden()
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
+295 -234
View File
@@ -1,8 +1,7 @@
"""Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync label """Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync
is fully visible at a phone viewport — the file path ellipsizes to fit detail is fully visible at a phone viewport — in BOTH long spans
and the ``(n/m)`` count span sits in its own non-shrinking part that is (import + summaries) — while desktop keeps the pre-phase full
NEVER clipped — in BOTH long spans (import + summaries), while desktop strings.
keeps the pre-phase strings and the ``min(16rem, 40vw)`` cap.
The dedicated story suite for ``128_sync_label_mobile`` (A16 — one The dedicated story suite for ``128_sync_label_mobile`` (A16 — one
Playwright file per phase, run in isolation): the owner's report was Playwright file per phase, run in isolation): the owner's report was
@@ -10,11 +9,40 @@ that ``Syncing… name/of/file/here (2/3)`` "doesn't show up on mobile" —
pre-phase, ``#sync-label`` was ONE inline-block span (whole label pre-phase, ``#sync-label`` was ONE inline-block span (whole label
string, one ellipsis) whose mobile media query LIFTED the string, one ellipsis) whose mobile media query LIFTED the
``min(16rem, 40vw)`` cap (``max-width: none``), so the ellipsized tail ``min(16rem, 40vw)`` cap (``max-width: none``), so the ellipsized tail
— where ``(n/m)`` lives — was clipped away on a phone. Phase 128 splits — where ``(n/m)`` lives — was clipped away on a phone. Phase 128 split
the label into two parts (``.sync-label-main`, the shrinkable the label into two parts (``.sync-label-main`, the shrinkable
prefix + file/folder, + ``.sync-label-count`, the pinned position) so prefix + file/folder, + ``.sync-label-count`, the pinned position) so
the count is never the ellipsis's sacrifice. the count is never the ellipsis's sacrifice.
Phase 149 (task 01) RE-POINTED the running-state pins: the owner's
follow-up was that the full live label (a long
``source/relative/path`` + the counts + the chunk part) is clipped
in the pill even on desktop, and the fix moves the running detail
out of the pill entirely — while a run is in flight the button is
the spinner (the ``is-running`` class; task 02's CSS hides the label
parts), and a detail box under the button row (``#sync-progress``)
carries the SAME full untruncated label — wrapping, never clipped,
``#sync-result`` staying the sole aria-live announcer. Tests 1–3
therefore pin the BOX: visible while the run is in flight, its text
the pre-phase byte-identical full string ("Syncing… <full
source/relative/path> (n/4)" / "Summarizing folders… <folder>
(n/3)" + the phase-129 "· chunk n/m" while a batch is in flight),
inside the viewport at 390 px and 1280 px, the button in its
running state, and the ``title`` carrying the same full string
(A4 — one source, two surfaces). The SETTLED pins (the phase-32
"Synced HH:MM" label, the count span hidden + empty, the box hidden)
are UNCHANGED. Test 4 (the pre-phase-128 rules' clip reproduction)
pins the old markup/CSS counterfactually — the label parts still
receive their text under any CSS (the writer is unchanged), so it
holds on the DOM text + injected rules regardless of the phase-149
box. The injected set ALSO neutralizes the phase-149 hide rule
(``.sync-btn.is-running .sync-label { display: none }`` — task 02's
specificity (0,2,0) would beat the injected (0,1,0) ``.sync-label``
rule and leave the parts display:none, making the geometry vacuous);
pre-phase, that rule simply did not exist, so the faithful
pre-phase counterfactual restores the parts' inline-block display
at matching specificity, last in the cascade.
Run in isolation (DB must be up: ``podman compose up -d db``): Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov
@@ -56,38 +84,34 @@ Test → observable mapping (Playwright Mapping Rule):
1. ``test_mobile_label_fits_viewport_with_count_in_import`` — admin 1. ``test_mobile_label_fits_viewport_with_count_in_import`` — admin
page on the sources view at viewport **390×844** (mobile); click page on the sources view at viewport **390×844** (mobile); click
**Sync sources**; while the import phase is running (the page's **Sync sources**; while the import phase is running (the page's
own 2 s poll): the rendered ``#sync-label``'s bounding box is own 2 s poll): the detail box (``#sync-progress``) is VISIBLE and
INSIDE the viewport (left ≥ 0, right ≤ 390 — the bug's direct an atomic snapshot of: its text the FULL untruncated label
negation), and the ``.sync-label-count`` part is VISIBLE (non-null ``Syncing… <full source/relative/path> (n/4)`` (+ the phase-129
box) and NOT clipped — its right edge sits at or inside the label's "· chunk n/m" part while a batch is in flight — optional, the
right edge and the button's right edge, with text matching pre-first-batch frame has none) with the ``(n/4)`` count IN the
``\\(\\d+/\\d+\\)`` once a file is in flight; ``.sync-label-main`` text (never clipped — nothing is ellipsized away), the box's
carries ``Syncing… <file>`` — the FULL source/relative/path bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
(visually ellipsized, but the DOM text and the button ``title`` bug's direct negation), the button in its running state
are untruncated, A4). Phase 129: the label's THIRD pinned part — (``is-running`` — the spinner), and the button ``title``
``.sync-label-chunk``, "· chunk n/m" (the in-flight file's carrying the SAME full string (A4 — one source, two surfaces).
embed-batch position, batch granularity) — renders while a file's The run then settles (the phase-32 terminal shape; the box hides).
embed batch is in flight (``chunks_total > 0``) and rides the
untruncated ``title`` too; the snapshot accepts it (optional —
the pre-first-batch frame has no chunk part yet; the phase-129
dedicated suite pins it strictly).
2. ``test_mobile_label_shows_summaries_count`` — same mobile 2. ``test_mobile_label_shows_summaries_count`` — same mobile
viewport; through to the summaries phase: ``.sync-label-main`` viewport; through to the summaries phase: the box text matches
matches ``Summarizing folders…`` (the folder part optional — the ``Summarizing folders… [source/folder] (n/3)`` (the folder part
phase's first poll may precede the first hook), the optional — the phase's first poll may precede the first hook — the
``.sync-label-count`` part matches ``\\(\\d+/3\\)`` and stays ``(n/3)`` count always present, IN the text) with the same
visible (same clip pins as test 1); on settle the button leaves box-geometry pins as test 1 (inside the viewport), the ``title``
the running label (the phase-32 terminal ``Synced HH:MM``) and the carrying the same full string (A4); on settle the button leaves
count span is hidden + empty. the running label (the phase-32 terminal ``Synced HH:MM``), the
count span is hidden + empty, and the box hides.
3. ``test_desktop_label_unchanged`` — viewport 1280×800; the same 3. ``test_desktop_label_unchanged`` — viewport 1280×800; the same
sync: during import the full rendered label text (main + count + sync: during import the box text matches ``Syncing… <full
optional phase-129 chunk concatenation) matches ``Syncing… <full source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
source/relative/path> (n/m)`` (the pre-phase byte-identical batch is in flight) — the pre-phase byte-identical FULL string
string, the chunk part appended while a batch is in flight) and (phase 149 moved it from the pill's parts to the box — the text
the label's width respects the ``min(16rem, 40vw)`` cap (bounding is unchanged), the box inside the viewport, the button in its
box width ≤ 256 px + slack — the shrinkable main part absorbs running state (``is-running``), and the ``title`` carrying the
the extra pinned part); the ``title`` carries the same same untruncated string (A4).
untruncated string (A4).
4. ``test_pre_phase_css_reproduces_the_clip`` — the pin check: the 4. ``test_pre_phase_css_reproduces_the_clip`` — the pin check: the
SAME mobile run with the pre-phase-128 ``.sync-label`` rules SAME mobile run with the pre-phase-128 ``.sync-label`` rules
re-injected (CSSOM ``insertRule`` into the origin's own linked re-injected (CSSOM ``insertRule`` into the origin's own linked
@@ -183,21 +207,26 @@ FILE_RE = r"synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\.md"
#: strings, unit-pinned in tests/unit/test_frontend_sync_upload.py): #: strings, unit-pinned in tests/unit/test_frontend_sync_upload.py):
#: main "Syncing… <full source/relative/path>" + count "(n/4)" — #: main "Syncing… <full source/relative/path>" + count "(n/4)" —
#: ``done`` starts at 1 (the importer counts the file as current #: ``done`` starts at 1 (the importer counts the file as current
#: BEFORE indexing it), so ``n`` ∈ 1..4. #: BEFORE indexing it), so ``n`` ∈ 1..4. (Test 4 — the pre-phase
#: rules' clip reproduction — pins the PARTS' DOM text directly.)
IMPORT_MAIN_RE = re.compile(rf"^Syncing… {FILE_RE}$") IMPORT_MAIN_RE = re.compile(rf"^Syncing… {FILE_RE}$")
IMPORT_COUNT_RE = re.compile(r"^\([1-4]/4\)$") IMPORT_COUNT_RE = re.compile(r"^\([1-4]/4\)$")
#: The pre-phase byte-identical FULL string (main + " " + count — the #: The pre-phase byte-identical FULL string (main + " " + count — the
#: single span's text before phase 128; the A4 title keeps it), plus #: single span's text before phase 128; the A4 title keeps it), plus
#: the OPTIONAL phase-129 chunk part (" · chunk n/m" — the in-flight #: the OPTIONAL phase-129 chunk part (" · chunk n/m" — the in-flight
#: file's embed-batch position, batch granularity) the title carries #: file's embed-batch position, batch granularity). Phase 149 (task
#: while a file's embed batch is in flight. #: 01): the box (#sync-progress) carries this EXACT string while the
#: import is in flight (tests 1 + 3) — the title carries it too
#: (A4 — one source, two surfaces).
IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)(?: · chunk \d+/\d+)?$") IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)(?: · chunk \d+/\d+)?$")
#: The summaries label's PARTS (phase 98 task 02, unit-pinned): main #: The summaries label's FULL string (phase 98 task 02, unit-pinned):
#: "Summarizing folders… [source/folder]" (the folder part omitted #: "Summarizing folders… [source/folder] (n/3)" (the folder part
#: while ``current_summary`` is null) + count "(n/3)". #: omitted while ``current_summary`` is null — the phase's first poll
SUMMARY_MAIN_RE = re.compile(r"^Summarizing folders…(?: \S+)?$") #: may precede the first hook — the (n/3) count always present). Phase
SUMMARY_COUNT_RE = re.compile(r"^\(\d+/3\)$") #: 149 (task 01): the box carries this EXACT string while the
#: summaries phase is in flight (test 2) — the title carries it too.
SUMMARY_FULL_RE = re.compile(r"^Summarizing folders…(?: \S+)? \(\d+/3\)$")
#: "Synced HH:MM" — the local-time last-result label (sources.js's #: "Synced HH:MM" — the local-time last-result label (sources.js's
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern). #: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
@@ -209,17 +238,15 @@ SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$") IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
#: The viewports (the task's mapping): the phone (the reported bug) #: The viewports (the task's mapping): the phone (the reported bug)
#: and the desktop (the unchanged-contract leg). #: and the desktop (the unchanged-contract leg). Phase 149 (task 01):
#: the geometry pins now apply to the DETAIL BOX (a block in the
#: 72rem container — it can only shrink toward the container's
#: content width, never overflow the viewport); the old pill-label
#: cap (min(16rem, 40vw)) applied to the ellipsized pill label, which
#: is no longer the running surface (the button is the spinner).
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844} MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
DESKTOP_VIEWPORT: ViewportSize = {"width": 1280, "height": 800} DESKTOP_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
#: The desktop cap: ``min(16rem, 40vw)`` = 256 px at the 16 px :root
#: (16rem) and the 1280 px viewport (40vw = 512 px) — the bounding
#: box width may not exceed the cap (a small slack covers float +
#: border rounding).
DESKTOP_CAP_PX = 256
CAP_SLACK_PX = 8
#: Generous settle budgets: a changed sync against the slowed LLM is #: Generous settle budgets: a changed sync against the slowed LLM is
#: ≈ 15–18 s; the UI's 2 s poll settles at most one tick after the #: ≈ 15–18 s; the UI's 2 s poll settles at most one tick after the
#: terminal state lands. #: terminal state lands.
@@ -234,27 +261,127 @@ def _md(title: str, body: str) -> str:
# The in-browser snapshot functions (Playwright Mapping Rule — the # The in-browser snapshot functions (Playwright Mapping Rule — the
# rendered DOM is the observable). # rendered DOM is the observable).
# #
# Each returns null while the awaited label state is absent, and an # Phase 149 (task 01): the RUNNING detail rides the detail box —
# ATOMIC geometry snapshot once it is present: the part texts, the # #sync-progress (visible IFF a run is in flight) with the FULL
# button title, and the getBoundingClientRect boxes of the label, the # untruncated label in its .sync-progress-text span. Each snapshot
# count part, and the button — read in the SAME in-browser evaluation # returns null while the awaited box state is absent, and an ATOMIC
# that verified the label state, so the numbers can never straddle a # snapshot once it is present: the box's text + the button's title +
# 2 s poll tick (the parts + the title are written together in one # is-running flag + the getBoundingClientRect boxes of the box and
# JS task by setSyncLabel). # the button — read in the SAME in-browser evaluation that verified
# the box state, so the numbers can never straddle a 2 s poll tick
# (the box text + the title are written together in one JS task by
# the single setSyncLabel writer — A4: one source, two surfaces).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
#: Test 1/3 — the in-flight IMPORT label: main carries "Syncing… #: Test 1/3 — the in-flight IMPORT detail: the box is VISIBLE and its
#: <full source/relative/path>" (the untruncated A4 text — the visual #: text is the FULL untruncated label — "Syncing… <full
#: ellipsis never touches textContent), the count part is VISIBLE #: source/relative/path> (n/4)" (+ the optional phase-129 " · chunk
#: (not ``hidden``) with the "(n/4)" position, and the button title #: n/m" part while a file's embed batch is in flight — the
#: carries the full untruncated label (main + " " + count, A4). Phase #: pre-first-batch frame has none; the phase-129 dedicated suite pins
#: 129: the THIRD pinned part — .sync-label-chunk ("· chunk n/m", the #: it strictly) — the count is IN the text (never clipped — nothing
#: in-flight file's embed-batch position) — rides the title too while #: is ellipsized away), the button is in its running state
#: a file's embed batch is in flight (chunks_total > 0): the title #: (is-running — the spinner), and the title carries the SAME full
#: check accepts main + " " + count (+ " " + chunk when the part is #: string (A4).
#: rendered) — optional, since the pre-first-batch frame has no chunk _JS_IMPORT_BOX_SNAPSHOT = """
#: part (the phase-129 dedicated suite pins the part strictly). () => {
_JS_IMPORT_SNAPSHOT = """ const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
if (!box || !textEl || !btn || box.hidden) return null;
const t = textEl.textContent;
const reFull = new RegExp(
"^Syncing… synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\\\\.md " +
"\\\\([1-4]/4\\\\)(?: · chunk \\\\d+/\\\\d+)?$"
);
if (!reFull.test(t)) return null;
const title = btn.getAttribute('title');
if (title !== t) return null;
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
return {
boxText: t,
title,
isRunning: btn.classList.contains('is-running'),
boxRect: rect(box),
btnRect: rect(btn),
};
}
"""
#: Test 2 — the SUMMARIES detail: the box is VISIBLE and its text is
#: the full "Summarizing folders… [source/folder] (n/3)" (the folder
#: part optional — the phase's first poll may precede the first hook —
#: the (n/3) count always present, IN the text), the button is in its
#: running state, and the title carries the SAME full string (A4).
_JS_SUMMARY_BOX_SNAPSHOT = """
() => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
if (!box || !textEl || !btn || box.hidden) return null;
const t = textEl.textContent;
const reFull = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
if (!reFull.test(t)) return null;
const title = btn.getAttribute('title');
if (title !== t) return null;
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
return {
boxText: t,
title,
isRunning: btn.classList.contains('is-running'),
boxRect: rect(box),
btnRect: rect(btn),
};
}
"""
#: The pre-phase-128 ``.sync-label`` geometry, re-injected for test 4
#: (the bug reproduction): the base rule's inline-block + single
#: ellipsis with the mobile media query's lifted cap (``max-width:
#: none``) — the label sizes to its FULL content and its ONE ellipsis
#: owns the whole tail, so the count (now riding a child span inside
#: that ellipsized box) is clipped away with it. The parts' own rules
#: are neutralized (no flex row, no per-part ellipsis, the pre-phase
#: non-mono count font). Phase 149 (task 02): the set ALSO
#: neutralizes the phase-149 hide rule — ``.sync-btn.is-running
#: .sync-label { display: none }`` (0,2,0) would beat the injected
#: (0,1,0) ``.sync-label`` rule and leave the parts display:none, so
#: the counterfactual restores the parts' pre-phase inline-block
#: display at matching specificity (last in the cascade it wins);
#: pre-phase, the hide rule simply did not exist. The rules are
#: complete rule strings — they go in via CSSOM ``insertRule``
#: (appended to the origin's own linked stylesheet, so the cascade
#: position is "last wins" like an injected ``<style>`` tag — which
#: the A20 CSP would block). The probe measured this override
#: reproduces the owner's report at 390 px with the repo's fonts (the
#: count's box fully past the label's clip edge and past the pill's
#: right edge).
_PRE_PHASE_SYNC_RULES = (
".sync-label { display: inline-block; align-items: initial; gap: 0; "
"max-width: none; min-width: 0; overflow: hidden; "
"text-overflow: ellipsis; white-space: nowrap; }",
".sync-label-main { min-width: 0; overflow: visible; text-overflow: clip; }",
".sync-label-count { flex: 0 1 auto; font-family: inherit; }",
".sync-btn.is-running .sync-label { display: inline-block; }",
)
#: Test 4 ONLY — the PARTS-level in-flight IMPORT snapshot (the
#: counterfactual clip reproduction): the writer still lands the label
#: PARTS (main + count + optional chunk) in the DOM for every running
#: label (phase 149's box reads the same writer output), so under the
#: re-injected pre-phase-128 CSS the parts' texts + geometry are the
#: observable — the count part's box runs past the label's clip edge
#: (painted away) + off the pill's right edge (the bug). Reads the
#: parts' textContent + hidden flags, the button title (A4), and the
#: getBoundingClientRect boxes of the label, the count part, and the
#: button — atomically (one in-browser evaluation, so the numbers
#: never straddle a 2 s poll tick).
_JS_PARTS_IMPORT_SNAPSHOT = """
() => { () => {
const label = document.querySelector('#sync-label'); const label = document.querySelector('#sync-label');
const btn = document.querySelector('#sync-btn'); const btn = document.querySelector('#sync-btn');
@@ -287,61 +414,6 @@ _JS_IMPORT_SNAPSHOT = """
} }
""" """
#: Test 2 — the SUMMARIES label: main carries "Summarizing folders…
#: [source/folder]" (the folder part optional — the phase's first
#: poll may precede the first hook), the count part is visible with
#: the "(n/3)" position, and the title carries the full untruncated
#: label (A4).
_JS_SUMMARY_SNAPSHOT = """
() => {
const label = document.querySelector('#sync-label');
const btn = document.querySelector('#sync-btn');
if (!label || !btn) return null;
const main = label.querySelector('.sync-label-main');
const count = label.querySelector('.sync-label-count');
if (!main || !count || count.hidden) return null;
const reMain = /^Summarizing folders…(?: \\S+)?$/;
const reCount = /^\\(\\d+\\/3\\)$/;
if (!reMain.test(main.textContent) || !reCount.test(count.textContent)) return null;
const title = btn.getAttribute('title');
if (title !== main.textContent + ' ' + count.textContent) return null;
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
return {
mainText: main.textContent,
countText: count.textContent,
title,
labelRect: rect(label),
countRect: rect(count),
btnRect: rect(btn),
};
}
"""
#: The pre-phase-128 ``.sync-label`` geometry, re-injected for test 4
#: (the bug reproduction): the base rule's inline-block + single
#: ellipsis with the mobile media query's lifted cap (``max-width:
#: none``) — the label sizes to its FULL content and its ONE ellipsis
#: owns the whole tail, so the count (now riding a child span inside
#: that ellipsized box) is clipped away with it. The parts' own rules
#: are neutralized (no flex row, no per-part ellipsis, the pre-phase
#: non-mono count font). The rules are complete rule strings — they
#: go in via CSSOM ``insertRule`` (appended to the origin's own
#: linked stylesheet, so the cascade position is "last wins" like an
#: injected ``<style>`` tag — which the A20 CSP would block). The
#: probe measured this override reproduces the owner's report at
#: 390 px with the repo's fonts (the count's box fully past the
#: label's clip edge and past the pill's right edge).
_PRE_PHASE_SYNC_RULES = (
".sync-label { display: inline-block; align-items: initial; gap: 0; "
"max-width: none; min-width: 0; overflow: hidden; "
"text-overflow: ellipsis; white-space: nowrap; }",
".sync-label-main { min-width: 0; overflow: visible; text-overflow: clip; }",
".sync-label-count { flex: 0 1 auto; font-family: inherit; }",
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fixtures # Fixtures
@@ -563,12 +635,12 @@ def _start_sync(page: Page, app_url: str, sync_dir: Path) -> None:
# The click's immediate state (the 202 moment — the bare prefix, # The click's immediate state (the 202 moment — the bare prefix,
# the phase-64 contract): disabled, aria-busy, spinning icon, and # the phase-64 contract): disabled, aria-busy, spinning icon, and
# the label's MAIN part alone (the count part is hidden — its text # the detail box revealed with the bare label (phase 149 — the
# is empty, so the parts' concatenation is exactly "Syncing…"). # running detail rides the box; the button is the spinner).
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true") expect(btn).to_have_attribute("aria-busy", "true")
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b")) expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
@@ -607,34 +679,32 @@ def _settle(page: Page, app_url: str) -> None:
count = page.locator(".sync-label-count") count = page.locator(".sync-label-count")
expect(count).to_be_hidden() expect(count).to_be_hidden()
expect(count).to_have_text("") expect(count).to_have_text("")
# Phase 149 (task 01): the detail box is run-scoped — the settle
# (the writer's default running = false) hides it again.
expect(page.locator("#sync-progress")).to_be_hidden()
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
def _assert_count_not_clipped(snap: dict[str, Any], viewport_w: int) -> None: def _assert_box_fits_viewport(snap: dict[str, Any], viewport_w: int) -> None:
"""The phase 128 geometry contract for a rendered running label at """The phase 149 geometry contract for the rendered running detail
the given viewport: the label box is INSIDE the viewport (the bug's at the given viewport: the detail box is VISIBLE (a non-degenerate
direct negation), the count part is VISIBLE (a non-degenerate box), box) and INSIDE the viewport (left ≥ 0, right ≤ the viewport
and the count is NOT clipped — its right edge sits at or inside width — the owner's clipped-detail report, directly negated). The
the label's right edge (the label's own clip boundary) and the box is a block in the 72rem centered container, so it can only
button's right edge (the pill). The 1 px slack covers float shrink toward the container's content width — it never overflows
rounding only — the layout nests the boxes exactly.""" the viewport — and its text is the FULL untruncated label (the
label, count, btn = snap["labelRect"], snap["countRect"], snap["btnRect"] "(n/m)" count is IN the text; the wrap that keeps long paths
assert label["left"] >= 0, f"label runs off the left edge: {label}" on-screen is task 02's overflow-wrap: anywhere, pinned by the
assert label["right"] <= viewport_w, ( phase-149 dedicated suite). The 1 px slack covers float rounding
f"label runs off the right edge of the {viewport_w}px viewport: {label}" only."""
box = snap["boxRect"]
assert box["width"] > 0 and box["height"] > 0, (
f"the detail box has no visible box: {box}"
) )
assert count["width"] > 0 and count["height"] > 0, ( assert box["left"] >= 0, f"the detail box runs off the left edge: {box}"
f"the count part has no visible box: {count}" assert box["right"] <= viewport_w + 1, (
) f"the detail box runs off the right edge of the {viewport_w}px "
assert count["left"] >= label["left"] - 1, ( f"viewport: {box}"
f"the count part sits outside the label: {label} {count}"
)
assert count["right"] <= label["right"] + 1, (
f"the count part is clipped by the label (its box runs past the "
f"label's clip edge): label={label} count={count}"
)
assert count["right"] <= btn["right"] + 1, (
f"the count part runs off the pill: btn={btn} count={count}"
) )
@@ -650,47 +720,43 @@ def test_mobile_label_fits_viewport_with_count_in_import(
"""A fresh admin page on the sources view at a PHONE viewport """A fresh admin page on the sources view at a PHONE viewport
(390×844) clicks **Sync sources**; while the import phase is (390×844) clicks **Sync sources**; while the import phase is
running (the page's own 2 s poll renders it — the span outlives running (the page's own 2 s poll renders it — the span outlives
the poll by ≥ 2× under the slow proxy), the rendered ``#sync-label`` the poll by ≥ 2× under the slow proxy), the DETAIL BOX
is an atomic snapshot of: the label box INSIDE the viewport (left ≥ (``#sync-progress`` — phase 149: the running detail left the
0, right ≤ 390 — the bug's direct negation), the ``.sync-label-count`` pill; the button is the spinner) is an atomic snapshot of:
part VISIBLE with its "(n/4)" position and its right edge at or VISIBLE with the FULL untruncated label ``Syncing… <full
inside the label's and the button's right edges (NEVER clipped — source/relative/path> (n/4)`` (+ the phase-129 "· chunk n/m" part
the pre-phase geometry fails exactly these pins, test 4 while a batch is in flight — optional) with the ``(n/4)`` count IN
reproduces it), and the ``.sync-label-main`` part carrying the text (NEVER clipped — nothing is ellipsized away), the box's
``Syncing… <full source/relative/path>`` — the file part may be bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
VISUALLY ellipsized, but the DOM text is untruncated and the button bug's direct negation), the button in its running state
``title`` carries the FULL label (main + " " + count, A4). The run (``is-running``), and the button ``title`` carrying the SAME full
then settles (the phase-32 terminal shape).""" string (A4 — one source, two surfaces). The run then settles (the
phase-32 terminal shape; the box hides)."""
page.set_viewport_size(MOBILE_VIEWPORT) page.set_viewport_size(MOBILE_VIEWPORT)
_start_sync(page, app_url, synclabel_dir) _start_sync(page, app_url, synclabel_dir)
# The page's own 2 s poll renders the in-flight import label; the # The page's own 2 s poll renders the in-flight import detail in
# atomic in-browser snapshot pins its text + geometry together. # the box; the atomic in-browser snapshot pins its text + geometry
# (The Playwright sync API wraps a non-primitive result in a # together. (The Playwright sync API wraps a non-primitive result
# JSHandle — .json_value() unwraps the snapshot dict.) # in a JSHandle — .json_value() unwraps the snapshot dict.)
snap = page.wait_for_function( snap = page.wait_for_function(
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS _JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value() ).json_value()
# The parts (the phase-64 byte-identical strings, unit-pinned): # The FULL untruncated label — the complete
# main = "Syncing… <full source/relative/path>" (untruncated — the # source/relative/path, the (n/4) count IN the text (never
# visual ellipsis never touches textContent), count = "(n/4)". # clipped), the optional phase-129 chunk part.
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"] assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
assert IMPORT_COUNT_RE.match(snap["countText"]), snap["countText"] # A4: the button title carries the SAME untruncated label (the
# A4: the button title carries the UNTRUNCATED label — the full # in-browser check already required title == box text; pin it here
# path, not the ellipsized visual tail (the in-browser check # too — one source, two surfaces).
# already required title == main + " " + count; pin it here too). assert snap["title"] == snap["boxText"], snap
# Phase 129: the pinned chunk part ("· chunk n/m") rides the title # Phase 149: the running button is the spinner (is-running rides
# while a file's embed batch is in flight (optional — the # the spin — the detail is the box's, not the pill's).
# pre-first-batch frame has none; the phase-129 dedicated suite assert snap["isRunning"] is True, snap
# pins it strictly). # The geometry contract (the fix): the box is visible and inside
full = snap["mainText"] + " " + snap["countText"] # the viewport.
if snap["chunkText"]: _assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
full += " " + snap["chunkText"]
assert snap["title"] == full, snap
# The geometry contract (the fix): label inside the viewport, the
# count visible and never clipped.
_assert_count_not_clipped(snap, MOBILE_VIEWPORT["width"])
_settle(page, app_url) _settle(page, app_url)
@@ -707,24 +773,26 @@ def test_mobile_label_shows_summaries_count(
"""Same phone viewport; the sync runs through to the SUMMARIES """Same phone viewport; the sync runs through to the SUMMARIES
phase (the post-import span where the file count sits still — the phase (the post-import span where the file count sits still — the
owner's "the number pauses" leg): the page's own 2 s poll renders owner's "the number pauses" leg): the page's own 2 s poll renders
``.sync-label-main`` as ``Summarizing folders… [source/folder]`` the DETAIL BOX (``#sync-progress`` — phase 149) with
(the folder part optional — the phase's first poll may precede the ``Summarizing folders… [source/folder] (n/3)`` (the folder part
first hook) with the VISIBLE ``.sync-label-count`` part matching optional — the phase's first poll may precede the first hook — the
``(n/3)`` (the 3 candidate folders) — never clipped (the same pins ``(n/3)`` count of the 3 candidate folders always present, IN the
as test 1) — and the title carrying the full untruncated label text — never clipped) with the same box-geometry pins as test 1
(A4). On settle the button leaves the running label (the phase-32 (inside the viewport), the button in its running state, and the
terminal ``Synced HH:MM``) and the count span is hidden + empty.""" title carrying the SAME full string (A4). On settle the button
leaves the running label (the phase-32 terminal ``Synced HH:MM``),
the count span is hidden + empty, and the box hides."""
page.set_viewport_size(MOBILE_VIEWPORT) page.set_viewport_size(MOBILE_VIEWPORT)
_start_sync(page, app_url, synclabel_dir) _start_sync(page, app_url, synclabel_dir)
snap = page.wait_for_function( snap = page.wait_for_function(
_JS_SUMMARY_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS _JS_SUMMARY_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value() ).json_value()
assert SUMMARY_MAIN_RE.match(snap["mainText"]), snap["mainText"] assert SUMMARY_FULL_RE.match(snap["boxText"]), snap["boxText"]
assert SUMMARY_COUNT_RE.match(snap["countText"]), snap["countText"] assert snap["title"] == snap["boxText"], snap
assert snap["title"] == snap["mainText"] + " " + snap["countText"], snap assert snap["isRunning"] is True, snap
_assert_count_not_clipped(snap, MOBILE_VIEWPORT["width"]) _assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
_settle(page, app_url) _settle(page, app_url)
@@ -738,43 +806,34 @@ def test_mobile_label_shows_summaries_count(
def test_desktop_label_unchanged( def test_desktop_label_unchanged(
page: Page, app_url: str, synclabel_dir: Path, db_ready: None page: Page, app_url: str, synclabel_dir: Path, db_ready: None
) -> None: ) -> None:
"""A DESKTOP viewport (1280×800) keeps the pre-phase contract: """A DESKTOP viewport (1280×800) keeps the pre-phase contract —
during import the FULL rendered label text — the main + count the TEXT: during import the detail box (``#sync-progress`` —
parts' concatenation (main + " " + count, the CSS gap standing in phase 149 moved the running detail out of the pill to the box;
for the old single span's space) — matches ``Syncing… <full the owner's report was that the full label was clipped in the
source/relative/path> (n/m)``: the pre-phase byte-identical string pill even on desktop) carries ``Syncing… <full
(the parts rework changed the output SHAPE, not the text). The source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
label's width respects the ``min(16rem, 40vw)`` cap — 256 px at batch is in flight): the pre-phase byte-identical FULL string
this viewport (16 px :root) — with the count part pinned at the (phase 149 changed the SURFACE, not the text). The box is inside
right edge and the main part ellipsized (the same cap/ellipsis the the viewport, the button is in its running state (``is-running``
pre-phase label had), and the button ``title`` carries the same — the spinner), and the button ``title`` carries the same
untruncated string (A4).""" untruncated string (A4)."""
page.set_viewport_size(DESKTOP_VIEWPORT) page.set_viewport_size(DESKTOP_VIEWPORT)
_start_sync(page, app_url, synclabel_dir) _start_sync(page, app_url, synclabel_dir)
snap = page.wait_for_function( snap = page.wait_for_function(
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS _JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value() ).json_value()
# The pre-phase byte-identical FULL string (main + " " + count + # The pre-phase byte-identical FULL string ("Syncing… <full
# the phase-129 chunk part while a batch is in flight): # source/relative/path> (n/m)" + " · chunk n/m" while a batch is
# "Syncing… <full source/relative/path> (n/m)" (+ " · chunk n/m"). # in flight) — now in the box, untruncated and wrapping.
full = snap["mainText"] + " " + snap["countText"] assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
if snap["chunkText"]: assert snap["title"] == snap["boxText"], snap
full += " " + snap["chunkText"] # Phase 149: the running button is the spinner (is-running);
assert IMPORT_FULL_RE.match(full), full # the count is IN the box's full text (the never-clipped pin —
assert snap["title"] == full, snap # nothing is ellipsized away on desktop either).
# The count part is visible and pinned inside the label (no assert snap["isRunning"] is True, snap
# clipping on desktop either — the cap ellipsizes the MAIN part _assert_box_fits_viewport(snap, DESKTOP_VIEWPORT["width"])
# only).
count, label = snap["countRect"], snap["labelRect"]
assert count["width"] > 0 and count["height"] > 0, count
assert count["right"] <= label["right"] + 1, f"label={label} count={count}"
# The cap: min(16rem, 40vw) = 256 px (16 px :root, 1280 px
# viewport) — the bounding box width may not exceed it (+ slack).
assert label["width"] <= DESKTOP_CAP_PX + CAP_SLACK_PX, (
f"the label box exceeds the min(16rem, 40vw) cap: {label}"
)
_settle(page, app_url) _settle(page, app_url)
@@ -791,9 +850,11 @@ def test_pre_phase_css_reproduces_the_clip(
"""The same mobile run with the PRE-PHASE-128 ``.sync-label`` rules """The same mobile run with the PRE-PHASE-128 ``.sync-label`` rules
re-injected (CSSOM ``insertRule`` into the origin's own linked re-injected (CSSOM ``insertRule`` into the origin's own linked
stylesheet — inline-block, the cap lifted, one ellipsis over the stylesheet — inline-block, the cap lifted, one ellipsis over the
whole label, the count riding the ellipsized tail): the reported whole label, the count riding the ellipsized tail; the injected
bug comes back geometrically — the count part's box extends past set also neutralizes the phase-149 ``is-running`` hide rule, which
the label's clip edge (its right edge outside the label's right pre-phase did not exist): the reported bug comes back geometrically —
the count part's box extends past the label's clip edge (its right
edge outside the label's right
edge, so the label's ``overflow: hidden`` paints it away) and past edge, so the label's ``overflow: hidden`` paints it away) and past
the pill's right edge (it runs off the button). This is the the pill's right edge (it runs off the button). This is the
geometry tests 1–2's "count not clipped" pins reject — the suite's geometry tests 1–2's "count not clipped" pins reject — the suite's
@@ -822,7 +883,7 @@ def test_pre_phase_css_reproduces_the_clip(
) )
snap = page.wait_for_function( snap = page.wait_for_function(
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS _JS_PARTS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value() ).json_value()
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"] assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
+996
View File
@@ -0,0 +1,996 @@
"""Phase 149 task 03 E2E (Playwright, mock-only): the owner's ask, pinned
end to end — while a sync run is in flight, the **button is the loading
spinner** (``is-running`` hides the label parts; the spinning icon is the
whole control) and a NEW bordered text box directly under the button row
(``#sync-progress``) carries the **EXACT full untruncated live label** —
``Syncing… <source/relative/path> (n/m) · chunk n/m`` — wrapping, never
clipping (the long ``source/relative/path`` that overflows the pill is the
fixture's whole reason to exist). At settle the box hides, the pill is
restored byte-identical to pre-phase (``Synced HH:MM`` + the ``fmtSyncResult``
counts line on ``#sync-result``), and ``#sync-result`` stayed the SOLE
aria-live announcer the whole time (the box is presentational,
``aria-hidden``).
The dedicated story suite for ``149_sync_progress_box`` (A16 — one
Playwright file per phase, run in isolation). Tasks 01/02 built the box +
its wiring + its CSS; this suite is the behavioral proof through the REAL
sync path: a real ``git clone`` of a local ``file://`` fixture repo, a real
import, the slowed mock LLM so the in-flight state is observable.
Run in isolation (DB must be up: ``podman compose up -d db``; git on PATH —
a documented environment prerequisite, phase 28):
uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
deterministic mock plus the phase-64 ``tests/e2e/slow_llm.py`` delay proxy
(``SLOW_DELAY_S = "1.5"`` — the proxy delays EVERY LLM call, embeds
included) in front of the mock: each in-flight state outlives the UI's
2 s status poll by ≫ 1×, so every rendered label is observed
deterministically, not as a race.
The suite runs ONE module app (the phase-79 full-inventory pattern — it
binds its own port) over the shared E2E Postgres: the SLOW app with
``BOR_GIT_SOURCES=file://<fixture repo>`` (the env fallback — the autouse
truncate keeps the ``git_sources`` table empty, so the env list IS the
effective source) + its own ``BOR_SOURCES_DIR`` for the checkout.
Timing fixture: a changed sync of the 3-file fixture is ≈ 2 model probes
+ 3 files × (1 content-embed batch + 1 summary chat + 1 summary-chunk
embed) + 1 overview + 7 folder summaries ≈ 19 LLM calls ≈ 30–35 s under
the proxy. The LONG-path file is the FIRST of the three (the sorted walk —
``notes/a-…`` < ``notes/short-…``), so its import span (7–8 embed batches
× 1.5 s + the two summary calls ≈ 15 s) outlives the 2 s poll ~7× — the
full-path + chunk-part windows the assertions observe are ≫ the poll
period (deterministic, not races).
KB fixture — a real git repo (host temp dir via ``tmp_path_factory``; the
app runs on the same host) with EXACTLY THREE markdown files:
* ``notes/a-rather-deeply-nested-folder/structure/for/the/sync/
fixture-document.md`` — the LONG relative path (the clipping trigger the
owner hit: a ``source/relative/path`` that overflows the pre-phase pill
at desktop width), and a multi-chunk body (7 single-paragraph sections
— one chunk apiece at the code-default 2000-char target, so the embed
span is many 1.5 s token-budgeted batches: the wide in-flight window);
* ``notes/short-one.md`` + ``notes/short-two.md`` — short paths, one chunk
each (the sync has several files → the ``(n/3)`` count part and the
per-file chunk restart are exercised too).
The source name is the checkout's directory basename (the ``repo_name``
rule, phase 28): ``progressbox`` — so the in-flight label's file part is
``progressbox/notes/a-rather-deeply-nested-folder/structure/for/the/sync/
fixture-document.md``.
Autouse cleanup (the phase-96 pattern): before each test wait for no
running sync on the app, then truncate the shared registry + KB tables
(chunks/documents/query_log/steering_notes/kb_overview/git_sources/
folder_summaries) — each test runs its own sync of the env-fallback source;
after each test the same truncate. The checkout in ``BOR_SOURCES_DIR``
survives across tests on purpose (the sync's pull is a fast no-op after
the first clone).
Test → observable mapping (Playwright Mapping Rule — the rendered DOM is
the observable; every in-flight snapshot is ONE in-browser evaluation that
verifies the awaited label state and reads the numbers together, so they
can never straddle a 2 s poll tick — the single ``setSyncLabel`` writer
updates the box + the button's aria-label/title + ``#sync-result`` in one
JS task):
1. ``test_running_button_is_spinner_only`` — click **Sync sources**; poll
until ``#sync-progress`` is visible (a run is in flight): ``#sync-btn``
carries ``is-running`` AND the icon ``is-spinning`` (the spinner IS the
button), ``#sync-label`` is NOT visible (the label parts are hidden —
the button is the icon), the button's ``aria-label`` EQUALS the box's
current text (written by the same call — never stale), the box is
``aria-hidden`` (presentational — ``#sync-result`` is the sole live
region), the button is disabled + ``aria-busy`` (the pre-phase running
mechanics), and the Cancel button is visible (the phase-134 contract
unchanged).
2. ``test_box_carries_the_full_untruncated_label`` — during the
in-flight window of the LONG-path file (poll until the box text is
``Syncing… <full long path> (1/3)`` — the path string IN FULL, the
pre-phase ellipsis would have truncated it): the box's
``scrollWidth <= clientWidth`` AND the text's ``scrollWidth <=
clientWidth`` (the wrap engaged — nothing is clipped), ``#sync-result``
(the announcer) carries the SAME full string (A4 — one source, two
surfaces), the button's ``aria-label`` equals it too, and the box ink
on the panel background clears WCAG 2.1 AA (≥ 4.5:1). Then a retry loop
over the slow embed window pins the phase-129 chunk part IN THE BOX
(``Syncing… <full long path> (1/3) · chunk n/m`` — the part is only
present while an embed batch is in flight; the proxy's 1.5 s delay
makes the ~15 s window ≫ the 2 s poll).
3. ``test_mobile_box_full_width`` — a fresh page at the house mobile
viewport (390×844 — the ``test_sync_label_mobile.py`` constant): the
same in-flight assertions — the box is FULL WIDTH (its width ≈ the
container's content width minus the container's padding — no override
needed, the block rides the 72rem container), no horizontal clipping
(``scrollWidth <= clientWidth``), the icon-only button keeps the
mobile full-width rule, the label wraps (the full long path is a
substring — the old mobile-stuffing ellipsis is gone).
4. ``test_settled_state_restores_the_pill`` — in flight first (the box is
visible), then let the run settle (the house settle idiom): the box is
HIDDEN (run-scoped only), the button shows the settle label
``Synced HH:MM`` (the pre-phase byte-identical settle copy) with the
count + chunk parts hidden and empty, ``is-running`` / ``is-spinning``
are gone, the ``aria-label`` is back to ``"Sync sources"``, and the
counts line on ``#sync-result`` matches the pre-phase ``fmtSyncResult``
shape (``N added``).
5. ``test_reattach_shows_the_box`` — in flight (the box visible), RELOAD
the page (the admin session cookie persists): the load-time re-attach
(``initSyncButton``) finds the RUNNING sync → the box is VISIBLE with
the current label and the button is icon-only again (the never-stale
contract — a page load mid-run shows the truth, not an idle pill).
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, ViewportSize, expect
from sqlalchemy import text
from app.config import Settings as _Settings
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module's app binds its own port
# instead (a same-port second uvicorn dies on bind and would drive the
# wrong server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCPROGRESS", "8166"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The slow-LLM proxy's port (the conftest's mock LLM stays on
#: MOCK_PORT — the proxy fronts it).
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT_SYNCPROGRESS", "8908"))
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
#: Per-LLM-request delay on the proxy (the phase-64/98 rationale,
#: module docstring): the proxy delays EVERY call — embeds included —
#: so the in-flight windows outlive the UI's 2 s poll by ≫ 1×.
SLOW_DELAY_S = "1.5"
# --------------------------------------------------------------------------
# Fixture constants (deterministic, token-controlled)
# --------------------------------------------------------------------------
#: The fixture repo's directory name — the source name the import
#: records for it (the ``repo_name`` basename rule, phase 28).
SOURCE = "progressbox"
#: The LONG relative path — the clipping trigger the owner hit (a
#: ``source/relative/path`` that overflows the pre-phase pill even on
#: desktop) — and the two short-path siblings (the sync has several
#: files: the ``(n/3)`` count part + the per-file chunk restart).
LONG_REL = (
"notes/a-rather-deeply-nested-folder/structure/for/the/sync/"
"fixture-document.md"
)
SHORT_RELS = ("notes/short-one.md", "notes/short-two.md")
N_FILES = 3
#: The in-flight label's file part — the status endpoint's
#: ``current_file`` = ``<source>/<rel>`` (the sync's progress hook),
#: verbatim. The long file is the FIRST of the three (the sorted walk —
#: ``notes/a-…`` < ``notes/short-…``), so its count part is ``(1/3)``.
LONG_PATH = f"{SOURCE}/{LONG_REL}"
#: The long file's shape (module docstring): an H1 preamble (one chunk)
#: + 7 single-paragraph sections (~1500 chars each — one chunk apiece at
#: the code-default 2000-char target) → ~8 chunks → ~8 token-budgeted
#: embed batches × 1.5 s (the wide in-flight window the assertions
#: observe — deterministic, not a race).
N_LONG_SECTIONS = 7
SHORT_MD = (
"# Short fixture {n}\n\nA tiny sync fixture note with a short path "
"({n}).\n"
)
#: The house mobile viewport (the ``test_sync_label_mobile.py``
#: constant — the phone-width leg of the owner's ask).
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
#: "Synced HH:MM" — the local-time last-result label (sources.js's
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
#: The settled boot label — idle ("Sync sources") or the RE-ATTACHED
#: terminal from a previous test's sync (the module app is shared, its
#: in-memory sync state survives the test boundary, and the boot
#: re-attach renders the last result).
IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
#: Generous settle budgets: a changed sync against the slowed LLM is
#: ≈ 30–35 s; the UI's 2 s poll settles at most one tick after the
#: terminal state lands.
SETTLE_TIMEOUT_MS = 120_000
#: The module's wall budget: five full 3-file syncs against the slowed
#: mock (≈ 30–35 s each) must stay well under this — a breach is a
#: regression canary for the sync pipeline's speed, not a flake
#: tolerance.
WALL_BUDGET_S = 600.0
# ---------------------------------------------------------------------------
# The in-browser snapshot functions (Playwright Mapping Rule — the
# rendered DOM is the observable).
#
# Each returns null while the awaited label state is absent, and an
# ATOMIC snapshot once it is present: the box's text + the button's
# aria-label / classes + the geometry — read in the SAME in-browser
# evaluation that verified the state, so the numbers can never straddle
# a 2 s poll tick (the single setSyncLabel writer updates the box, the
# button, and the announcer together in one JS task — A4: one source,
# two surfaces).
# ---------------------------------------------------------------------------
#: Tests 1 + 5 — ANY in-flight state (a run is in flight): the box is
#: VISIBLE with non-empty text, and the button is in its running state
#: (is-running + the spinning icon + the hidden label parts + the full
#: live label as aria-label + disabled + aria-busy) with the Cancel
#: control visible (the phase-134 contract). The box's aria-hidden
#: attribute is read too (presentational — the one-live-region pin).
_JS_RUNNING_SNAPSHOT = """
() => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
const label = document.querySelector('#sync-label');
const icon = btn ? btn.querySelector('.sync-icon') : null;
const cancel = document.querySelector('#sync-cancel-btn');
if (!box || !textEl || !btn || !label || !icon || !cancel) return null;
if (box.hidden || !textEl.textContent) return null;
const cs = (el) => getComputedStyle(el);
return {
boxText: textEl.textContent,
ariaHidden: box.getAttribute('aria-hidden'),
ariaLabel: btn.getAttribute('aria-label'),
isRunning: btn.classList.contains('is-running'),
isSpinning: icon.classList.contains('is-spinning'),
labelHidden: cs(label).display === 'none',
cancelVisible: !cancel.hidden && cs(cancel).display !== 'none',
btnDisabled: btn.disabled,
ariaBusy: btn.getAttribute('aria-busy'),
};
}
"""
#: Test 2, wait A — the in-flight window of the LONG-path file: the box
#: is VISIBLE and its text is the FULL label for the long file —
#: "Syncing… <full long path> (1/3)" (+ the optional phase-129
#: " · chunk n/m" part while an embed batch is in flight — the
#: pre-first-batch frame has none) — the complete
# source/relative/path IN the text (the pre-phase ellipsis would have
#: truncated it). Returns the text + the wrap proof (scrollWidth vs
#: clientWidth of box and text) + the announcer + the aria-label + the
#: computed ink/panel colors (the AA pin).
_JS_LONG_FILE_SNAPSHOT = """
(longPath) => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
const result = document.querySelector('#sync-result');
if (!box || !textEl || !btn || !result || box.hidden) return null;
const t = textEl.textContent;
const prefix = 'Syncing… ' + longPath + ' (';
if (!t.startsWith(prefix)) return null;
const tail = t.slice(prefix.length);
if (!/^\\d+\\/3\\)( · chunk \\d+\\/\\d+)?$/.test(tail)) return null;
const cs = (el) => getComputedStyle(el);
return {
boxText: t,
resultText: result.textContent,
ariaLabel: btn.getAttribute('aria-label'),
boxScrollWidth: box.scrollWidth,
boxClientWidth: box.clientWidth,
textScrollWidth: textEl.scrollWidth,
textClientWidth: textEl.clientWidth,
ink: cs(textEl).color,
bg: cs(box).backgroundColor,
};
}
"""
#: Test 2, wait B — the phase-129 chunk part IN THE BOX during the slow
#: embed window: the box text is "Syncing… <full long path> (1/3) ·
#: chunk n/m" (the part is only present while an in-flight file's embed
#: batch is in flight — the proxy's 1.5 s delay makes the ~15 s
#: long-file window ≫ the 2 s poll, so the retry loop resolves
#: deterministically).
_JS_LONG_FILE_CHUNK_SNAPSHOT = """
(longPath) => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
if (!box || !textEl || box.hidden) return null;
const t = textEl.textContent;
if (!t.startsWith('Syncing… ' + longPath + ' (1/3) · chunk ')) return null;
const m = t.match(/· chunk (\\d+)\\/(\\d+)$/);
if (!m) return null;
return {
boxText: t,
chunkN: Number(m[1]),
chunkM: Number(m[2]),
boxScrollWidth: box.scrollWidth,
boxClientWidth: box.clientWidth,
textScrollWidth: textEl.scrollWidth,
textClientWidth: textEl.clientWidth,
};
}
"""
#: Test 3 — the mobile (390×844) in-flight window of the long file: the
#: box text is the full label (the long path IN the text), the box is
#: FULL WIDTH (its bounding-box width ≈ the container's content width —
#: the block rides the 72rem container, no override), no horizontal
#: clipping (scrollWidth <= clientWidth), the icon-only button keeps the
#: mobile full-width rule (its width:100% resolves against its
#: containing block — the .page-head card's content width, NOT the
#: container's), and the label parts are hidden.
_JS_MOBILE_SNAPSHOT = """
(longPath) => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
const label = document.querySelector('#sync-label');
const icon = btn ? btn.querySelector('.sync-icon') : null;
const container = document.querySelector('#view-rag .container');
const head = document.querySelector('#view-rag .page-head');
if (!box || !textEl || !btn || !label || !icon || !container || !head) return null;
if (box.hidden) return null;
const t = textEl.textContent;
if (!t.startsWith('Syncing… ' + longPath + ' (')) return null;
const cs = (el) => getComputedStyle(el);
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
// clientWidth already excludes the border — content = clientWidth
// minus the horizontal padding only.
const contentWidth = (el) => {
const s = cs(el);
return el.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight);
};
return {
boxText: t,
isRunning: btn.classList.contains('is-running'),
isSpinning: icon.classList.contains('is-spinning'),
labelHidden: cs(label).display === 'none',
boxRect: rect(box),
btnRect: rect(btn),
containerContentWidth: contentWidth(container),
headContentWidth: contentWidth(head),
boxScrollWidth: box.scrollWidth,
boxClientWidth: box.clientWidth,
};
}
"""
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _git(cwd: Path, *args: str) -> None:
"""Run git in *cwd*; a non-zero exit fails the fixture loudly."""
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
if proc.returncode != 0:
raise AssertionError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
def _commit(repo: Path, message: str) -> None:
"""A fixture commit (never signed — the house ``commit.gpgsign``
flag, the test_sync_button.py idiom)."""
_git(
repo,
"-c", "user.email=e@x", "-c", "user.name=t",
"-c", "commit.gpgsign=false",
"commit", "-qm", message,
)
def _long_md() -> str:
"""The long-path fixture doc (see the module docstring): an H1
preamble (one chunk) + 7 single-paragraph sections (~1500 chars
each — one chunk apiece at the code-default 2000-char target — no
randomness, so the chunk count is stable run to run)."""
parts = ["# Long-path fixture document\n"]
parts.append(
"Preamble for the deeply nested sync fixture document. This "
"paragraph sits under the H1 and chunks on its own.\n"
)
for k in range(1, N_LONG_SECTIONS + 1):
parts.append(f"\n## Nesting {k}\n")
base = (
f"Nesting paragraph {k} of the long-path sync fixture carries "
"deterministic filler for the chunker: alpha bravo charlie "
"delta echo foxtrot golf hotel india juliet kilo lima "
)
parts.append((base * 30)[:1500] + "\n\n")
return "".join(parts)
@pytest.fixture(scope="module")
def progressbox_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""A real git repo the sync must clone over ``file://`` (see the
module docstring): the long-path doc (the clipping trigger,
multi-chunk body) + the two short-path docs. The directory NAME is
the source name (the ``repo_name`` basename rule, phase 28).
``tmp_path`` is function-scoped while the module-scoped app fixture
needs the repo for the module's lifetime, so it is built under
``tmp_path_factory`` via real ``git`` subprocess calls."""
root = tmp_path_factory.mktemp("bor_syncprogress")
repo = root / SOURCE
long_file = repo / LONG_REL
long_file.parent.mkdir(parents=True)
long_file.write_text(_long_md(), encoding="utf-8")
for i, rel in enumerate(SHORT_RELS, start=1):
(repo / rel).write_text(SHORT_MD.format(n=i), encoding="utf-8")
_git(repo, "init", "-q")
_git(repo, "add", "-A")
_commit(repo, "one")
assert (repo / ".git").is_dir()
assert len(list(repo.rglob("*.md"))) == N_FILES
return repo
def _app_env(
base_url: str, repo: Path, tmp_path_factory: pytest.TempPathFactory
) -> dict[str, str]:
"""The per-module app env (the conftest pattern): the ``base_url``
picks the LLM (the slow proxy in front of the mock), and
``BOR_GIT_SOURCES`` is this suite's own ``file://`` fixture repo —
the sync's SUBJECT (the env fallback: the autouse truncate keeps the
``git_sources`` table empty, so the env list is the effective
source). The env var also overrides the owner's local (gitignored)
``.env`` ``BOR_GIT_SOURCES`` (process env ranks above the file)."""
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 base_url
)
# Mock-calibrated gate values (conftest pattern): the floors ride
# WITH the 0.30 threshold — a floor above it fails the startup
# validator (phase 112/113).
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 (the
# conftest leak-guard pattern).
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 sync's subject (see the docstring) + its own checkout dir.
env["BOR_GIT_SOURCES"] = f"file://{repo}"
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
# Leak guards (conftest pattern): an operator's local (gitignored)
# .env cannot leak corpus-specific settings into the app under test.
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
return env
@pytest.fixture(scope="module")
def slow_llm(mock_llm: int) -> Iterator[int]:
"""The delay-injecting reverse proxy in front of the mock LLM
(``tests/e2e/slow_llm.py``) — the timing fixture for the whole
suite: it delays EVERY LLM call, embeds included, so the in-flight
windows outlive the UI's 2 s poll by ≫ 1× (see ``SLOW_DELAY_S``)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{SLOW_URL}/v1/models")
yield SLOW_PORT
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_server(
mock_llm: int,
slow_llm: int,
progressbox_repo: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""The SLOW app under test (the whole suite):
``BOR_LLM_BASE_URL`` is the slow proxy in front of the mock (the
timing fixture), ``BOR_GIT_SOURCES`` the ``file://`` fixture repo
(the sync's subject — see the module docstring), and
``BOR_SOURCES_DIR`` its own checkout dir. ``progressbox_repo`` is a
dependency only for the fixture ordering (the repo exists before
the first test)."""
env = _app_env(f"{SLOW_URL}/v1", progressbox_repo, tmp_path_factory)
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=env,
)
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
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern): the E2E suites
share one Postgres, so a leftover git_sources row would outrank
this app's ``BOR_GIT_SOURCES`` env fallback (effective_sources
prefers the DB list) and a leftover document would pollute the file
counts the sync pins exactly."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, folder_summaries"
)
)
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
def _wait_sync_settled(base_url: str, timeout_s: float = 180.0) -> None:
"""No background sync may leak across tests (the run state lives in
the app's memory, and a still-running run would keep importing into
the NEXT test's truncated KB): wait for the app's sync status to be
non-running BEFORE the truncate. Own admin session (the endpoint is
admin-only) — usually a no-op: the tests settle only after their
run's terminal state."""
with httpx.Client(base_url=base_url, timeout=5.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
if body["state"] != "running":
# Diagnostic: a FAILED terminal between tests is a real
# anomaly for this suite (every sync here must succeed)
# — surface the error text so a cascade is diagnosable
# instead of showing up as a later test's blocked click.
if body["state"] == "failed":
print(f"sync progress box: terminal FAILED at a test "
f"boundary: {body.get('error')}")
return
time.sleep(0.2)
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
@pytest.fixture(autouse=True)
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
"""Per-test KB isolation (the phase-96 cleanup pattern): the
shared E2E Postgres is truncated before AND after every test —
each test runs its own sync of the env-fallback source, so the KB
rows of the fixture source are exactly this test's own. The
pre-wait keeps a leaked run from the previous test (the module app
is shared) from racing the truncate."""
_wait_sync_settled(app_url)
_truncate_all()
yield
_truncate_all()
@pytest.fixture(scope="module", autouse=True)
def _wall_time_guard() -> Iterator[None]:
"""The module's wall-time budget: the whole module — app boot +
FIVE full 3-file syncs against the slowed mock LLM — stays under
``WALL_BUDGET_S``. A breach is a regression canary (the sync
pipeline slowed), not a flake tolerance."""
start = time.monotonic()
yield
elapsed = time.monotonic() - start
assert elapsed < WALL_BUDGET_S, (
f"the module took {elapsed:.1f}s — over the {WALL_BUDGET_S:.0f}s wall "
"budget (the five small-fixture syncs against the slowed mock LLM "
"are a speed canary)"
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_rgb(css_color: str) -> tuple[int, int, int]:
"""A computed ``rgb(r, g, b)`` / ``rgba(…)`` color → the triple."""
m = re.match(r"rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)", css_color)
assert m, f"not a computable rgb color: {css_color!r}"
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
def _wcag_luminance(rgb: tuple[float, float, float]) -> float:
"""WCAG 2.1 relative luminance (the same math the theme suites run)."""
def lin(c: float) -> float:
s = c / 255
return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = rgb
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
def _wcag_ratio(
fg: tuple[float, float, float], bg: tuple[float, float, float]
) -> float:
l1, l2 = _wcag_luminance(fg), _wcag_luminance(bg)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
def _start_sync(page: Page, app_url: str) -> None:
"""Land the admin on the sources view, wait for the settled boot
label (the phase-64 pattern — also proves the boot re-attach
completed, so no later re-render can clobber the running state),
then click **Sync sources** and pin the click's immediate 202
state: the button IS the spinner (disabled, aria-busy,
``is-running``, the spinning icon) and the box is revealed with
the bare label (phase 149 — the running detail rides the box)."""
login(page, app_url) # lands on /sources.html (the button's home)
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
expect(page.locator("#sync-label")).to_have_text(IDLE_OR_SETTLED)
expect(btn).to_be_enabled()
btn.click()
# The click's immediate state (the 202 moment — the bare prefix,
# the phase-64 contract): the button is the loading spinner and
# the box carries the bare label (phase 149 — one source, two
# surfaces).
expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true")
expect(btn).to_have_class(re.compile(r"\bis-running\b"))
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-error-banner")).to_be_hidden()
def _wait_terminal(app_url: str, timeout_s: float = 120.0) -> dict[str, Any]:
"""Poll the status endpoint (own admin session) until the UI-
triggered run reaches a terminal state — the source of truth for
the settle (the UI's 2 s poll renders it at most one tick later).
A FAILED terminal fails the test with the terminal body (the
``detail`` / error text) — never an opaque label timeout."""
with httpx.Client(base_url=app_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
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"):
assert body["state"] == "success", body
return body
time.sleep(0.2)
raise AssertionError(f"the run never reached a terminal state: {body}")
def _settle(page: Page, app_url: str) -> None:
"""The phase-32 success settle (the pre-phase byte-identical
terminal shape, preserved by phase 149): the run reaches a SUCCESS
terminal at the endpoint, then the button shows "Synced HH:MM"
(re-enabled, the running state fully cleared), the box is HIDDEN
(run-scoped), and no error."""
_wait_terminal(app_url)
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
btn = page.locator("#sync-btn")
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-progress")).to_be_hidden()
expect(page.locator("#sync-error-banner")).to_be_hidden()
# ---------------------------------------------------------------------------
# 1. In flight: the button is the spinner only (no visible label text),
# the aria-label carries the full live label, and Cancel rides along
# ---------------------------------------------------------------------------
def test_running_button_is_spinner_only(
page: Page, app_url: str, db_ready: None
) -> None:
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
the page's OWN 2 s poll keeps the run in flight long enough (the
slow proxy guarantees a ≫ 30 s window) that the in-flight state is
observed deterministically: ``#sync-btn`` carries ``is-running``
AND the icon ``is-spinning`` (the spinner IS the loading state),
``#sync-label`` is NOT visible (the label parts are hidden — the
button is the icon, at any viewport), the button's ``aria-label``
EQUALS the box's current text (written by the same single-writer
call — never stale), the box is ``aria-hidden`` (presentational —
``#sync-result`` remains the sole aria-live region), the running
mechanics (disabled + ``aria-busy``) are preserved, and the Cancel
button is visible (the phase-134 contract unchanged)."""
_start_sync(page, app_url)
# Poll (the generous timeout — the slow proxy's in-flight window is
# ~30 s) until a run is in flight: the box is visible + non-empty.
snap = page.wait_for_function(
_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
# The button is the loading spinner (the owner's ask).
assert snap["isRunning"] is True, snap
assert snap["isSpinning"] is True, snap
# The label parts are hidden — the button is the icon (no visible
# label text).
assert snap["labelHidden"] is True, snap
assert page.locator("#sync-label").is_visible() is False
assert page.locator("#sync-progress").is_visible()
# The icon-only button keeps an accessible name — the FULL live
# label, equal to the box's text (the same writer call wrote both).
assert snap["boxText"], snap
assert snap["ariaLabel"] == snap["boxText"], snap
# The box is presentational — #sync-result is the SOLE live region.
assert snap["ariaHidden"] == "true", snap
# The pre-phase running mechanics are preserved.
assert snap["btnDisabled"] is True, snap
assert snap["ariaBusy"] == "true", snap
# The Cancel control rides the running state (phase 134 — unchanged).
assert snap["cancelVisible"] is True, snap
_settle(page, app_url)
# ---------------------------------------------------------------------------
# 2. The box carries the EXACT full untruncated label (long path
# included — wrapped, never clipped), the announcer carries the same
# string, and the chunk part appears in the box during the embed
# window
# ---------------------------------------------------------------------------
def test_box_carries_the_full_untruncated_label(
page: Page, app_url: str, db_ready: None
) -> None:
"""During the in-flight window of the LONG-path file (poll until the
box text is ``Syncing… <full long path> (1/3)`` — the slow proxy
makes the ~15 s span deterministic): the box's text contains the
FULL label shape with the complete ``source/relative/path`` as a
substring (the pre-phase ellipsis would have truncated it — the
wrap means nothing is dropped), the box did NOT overflow
horizontally (``scrollWidth <= clientWidth`` — the wrap engaged)
neither as a panel nor in its text, ``#sync-result`` (the
announcer) carries the SAME full string (A4 — one source, two
surfaces), the button's ``aria-label`` equals it, and the box ink
on the panel clears WCAG 2.1 AA (≥ 4.5:1). Then a retry loop over
the slow embed window pins the phase-129 chunk part IN THE BOX
(``· chunk n/m`` — the part is only present while an embed batch is
in flight; the proxy's 1.5 s delay makes the window wide)."""
_start_sync(page, app_url)
# Wait A — the in-flight window of the long file (any frame of it —
# the pre-first-batch frame has no chunk part yet).
snap = page.wait_for_function(
_JS_LONG_FILE_SNAPSHOT, arg=LONG_PATH, timeout=SETTLE_TIMEOUT_MS
).json_value()
# The FULL label shape — the complete long path IN the text (the
# clipping trigger, untruncated).
expected_prefix = f"Syncing… {LONG_PATH} (1/3)"
assert snap["boxText"].startswith(expected_prefix), snap["boxText"]
assert LONG_PATH in snap["boxText"], snap["boxText"]
# The wrap engaged — no horizontal overflow (the whole point of the
# box: the path WRAPS, it is never clipped).
assert snap["boxScrollWidth"] <= snap["boxClientWidth"], snap
assert snap["textScrollWidth"] <= snap["textClientWidth"], snap
# A4: the announcer carries the SAME full string (one source, two
# surfaces) — and the icon-only button's accessible name does too.
assert snap["resultText"] == snap["boxText"], snap
assert snap["ariaLabel"] == snap["boxText"], snap
# WCAG 2.1 AA: the box's mono ink on its panel background ≥ 4.5:1
# (the theme's pinned identity pair — observed, not assumed).
ratio = _wcag_ratio(_parse_rgb(snap["ink"]), _parse_rgb(snap["bg"]))
assert ratio >= 4.5, f"{snap['ink']} on {snap['bg']}: {ratio:.2f}:1 < 4.5:1 (AA)"
# Wait B — the phase-129 chunk part IN THE BOX (the retry loop over
# the slow embed window — the part is only present while an embed
# batch is in flight; the ~15 s long-file window outlives the 2 s
# poll by ≫ 1×).
snap2 = page.wait_for_function(
_JS_LONG_FILE_CHUNK_SNAPSHOT, arg=LONG_PATH, timeout=SETTLE_TIMEOUT_MS
).json_value()
assert snap2["chunkN"] >= 1, snap2
assert snap2["chunkM"] >= 1, snap2
# The longer label (with the chunk part) still wraps — no clipping.
assert snap2["boxScrollWidth"] <= snap2["boxClientWidth"], snap2
assert snap2["textScrollWidth"] <= snap2["textClientWidth"], snap2
_settle(page, app_url)
# ---------------------------------------------------------------------------
# 3. Mobile (390×844): the box is full-width, the button is icon-only
# (the mobile full-width rule), and the label wraps
# ---------------------------------------------------------------------------
def test_mobile_box_full_width(page: Page, app_url: str, db_ready: None) -> None:
"""A fresh page at the house MOBILE viewport (390×844) clicks
**Sync sources**; during the in-flight window of the LONG-path
file: the box is FULL WIDTH (its bounding-box width ≈ the
container's content width — a block in the same container rides
the 100%-width phone shell with no override), no horizontal
clipping (``scrollWidth <= clientWidth``), the button is
icon-only (``is-running`` + the spinning icon, the label parts
hidden) and keeps the mobile full-width rule, and the label wraps
(the full long path is a substring — the old mobile-stuffing
ellipsis is gone)."""
page.set_viewport_size(MOBILE_VIEWPORT)
_start_sync(page, app_url)
snap = page.wait_for_function(
_JS_MOBILE_SNAPSHOT, arg=LONG_PATH, timeout=SETTLE_TIMEOUT_MS
).json_value()
# The label wraps — the full long path is IN the box's text (the
# old mobile-stuffing ellipsis is gone).
assert snap["boxText"].startswith(f"Syncing… {LONG_PATH} ("), snap["boxText"]
assert LONG_PATH in snap["boxText"], snap["boxText"]
# The wrap engaged — no horizontal clipping at the phone width.
assert snap["boxScrollWidth"] <= snap["boxClientWidth"], snap
# The box is FULL WIDTH: its width ≈ the container's content width
# (the 2px slack covers float rounding only).
assert abs(snap["boxRect"]["width"] - snap["containerContentWidth"]) <= 2, snap
# …and it is inside the viewport (the owner's clipped-detail
# report, directly negated).
assert snap["boxRect"]["left"] >= 0, snap["boxRect"]
assert snap["boxRect"]["right"] <= MOBILE_VIEWPORT["width"] + 1, snap["boxRect"]
# The button is icon-only (the spinner) and keeps the mobile
# full-width rule: its width:100% resolves against its containing
# block — the .page-head card's content width (the card's padding
# + border inset it from the container).
assert snap["isRunning"] is True, snap
assert snap["isSpinning"] is True, snap
assert snap["labelHidden"] is True, snap
assert abs(snap["btnRect"]["width"] - snap["headContentWidth"]) <= 2, snap
_settle(page, app_url)
# ---------------------------------------------------------------------------
# 4. Settle: the box hides, the "Synced HH:MM" pill is restored
# byte-identical, and the counts line is unchanged
# ---------------------------------------------------------------------------
def test_settled_state_restores_the_pill(
page: Page, app_url: str, db_ready: None
) -> None:
"""A sync is triggered and observed IN FLIGHT (the box is visible —
so the settle below is a real transition, not a no-op), then let to
settle (the house settle idiom): the box is HIDDEN (run-scoped
only), the button shows the settle label ``Synced HH:MM`` (the
pre-phase byte-identical settle copy) with the label parts
restored — the count + chunk parts hidden and empty —,
``is-running`` / ``is-spinning`` are gone, the ``aria-label`` is
back to ``"Sync sources"`` (the ship value), and the counts line
on ``#sync-result`` matches the pre-phase ``fmtSyncResult`` shape
(``N added`` — the 3 fixture files)."""
_start_sync(page, app_url)
# In flight first (the box is visible + the button is the spinner).
page.wait_for_function(_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS).json_value()
# The settle (the house idiom): the terminal lands at the endpoint,
# then the UI's 2 s poll renders the phase-32 terminal shape.
_wait_terminal(app_url)
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
# The pill is restored (the pre-phase byte-identical settle copy).
expect(page.locator("#sync-label")).to_be_visible()
btn = page.locator("#sync-btn")
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
expect(btn).to_have_attribute("aria-label", "Sync sources")
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
# The box is run-scoped: hidden at settle.
expect(page.locator("#sync-progress")).to_be_hidden()
# The label parts are restored — the settle label carries no count
# or chunk part.
count = page.locator(".sync-label-count")
expect(count).to_be_hidden()
expect(count).to_have_text("")
chunk = page.locator(".sync-label-chunk")
expect(chunk).to_be_hidden()
expect(chunk).to_have_text("")
# The counts line is unchanged (the pre-phase fmtSyncResult pin).
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
expect(page.locator("#sync-error-banner")).to_be_hidden()
# ---------------------------------------------------------------------------
# 5. A page load mid-run re-attaches with the box visible (never stale)
# ---------------------------------------------------------------------------
def test_reattach_shows_the_box(page: Page, app_url: str, db_ready: None) -> None:
"""A sync is triggered and observed IN FLIGHT (the box is visible),
then the page is RELOADED (the admin session cookie persists): the
load-time re-attach (``initSyncButton``) finds the RUNNING sync →
the box is VISIBLE with the current label and the button is
icon-only again (``is-running`` + the spinning icon, the label
parts hidden, the ``aria-label`` equal to the box's text, the
Cancel control visible) — the never-stale contract: a page load
mid-run shows the truth, not an idle pill."""
_start_sync(page, app_url)
# In flight first (the pre-reload state: the box is visible).
page.wait_for_function(_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS).json_value()
# Reload mid-run — the run keeps going on the server; the admin
# session (the cookie) survives the reload.
page.reload(wait_until="domcontentloaded")
# The re-attach: the box is visible with the CURRENT label and the
# button is the spinner again (any in-flight state — the run is
# ~30 s and the reload lands within a few seconds of the start).
snap = page.wait_for_function(
_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
assert snap["isRunning"] is True, snap
assert snap["isSpinning"] is True, snap
assert snap["labelHidden"] is True, snap
assert snap["boxText"], snap
assert snap["ariaLabel"] == snap["boxText"], snap
assert snap["cancelVisible"] is True, snap
_settle(page, app_url)
+31 -33
View File
@@ -727,16 +727,17 @@ def test_sync_button_names_the_summary_phase(
page: Page, app_url: str, syncsum_dir: Path, db_ready: None page: Page, app_url: str, syncsum_dir: Path, db_ready: None
) -> None: ) -> None:
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**; """A fresh admin page on ``/sources.html`` clicks **Sync sources**;
the page's OWN 2 s poll renders ``#sync-label`` as the page's OWN 2 s poll renders the summaries label —
``Summarizing folders… [source/folder]`` + the pinned ``(n/3)`` ``Summarizing folders… [source/folder] (n/3)`` (the folder part is
count span (phase 128: the label is PARTS — the folder part is optional — the phase's first poll may precede the first hook) — in
optional — the phase's first poll may precede the first hook — and the detail box (phase 149: the running label rides #sync-progress
the button ``title`` carries the full untruncated string, main + under the button row; the button is the spinner) with the button
" " + count, A4); on settle the button shows the terminal ``title`` carrying the same full untruncated string (A4 — one
``Synced HH:MM`` (the phase-32 contract) and ``#sync-result`` source, two surfaces); on settle the box hides, the button shows
carries the counts line. The prelude/import labels stay the the terminal ``Synced HH:MM`` (the phase-32 contract) and
phase-64 shape (the endpoint test pins those phases; the UI pins ``#sync-result`` carries the counts line. The prelude/import labels
the new summaries label).""" stay the phase-64 shape (the endpoint test pins those phases; the
UI pins the new summaries label)."""
page.set_default_timeout(30_000) page.set_default_timeout(30_000)
_register_source(app_url, str(syncsum_dir)) _register_source(app_url, str(syncsum_dir))
login(page, app_url) # lands on /sources.html (the button's home) login(page, app_url) # lands on /sources.html (the button's home)
@@ -759,38 +760,35 @@ def test_sync_button_names_the_summary_phase(
# The click's immediate state (the 202 moment — the bare prefix, # The click's immediate state (the 202 moment — the bare prefix,
# the phase-64 contract): disabled, aria-busy, spinning icon, no # the phase-64 contract): disabled, aria-busy, spinning icon, no
# error… # error, and the detail box revealed with the bare label
# (phase 149 — the running label rides the box)…
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true") expect(btn).to_have_attribute("aria-busy", "true")
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b")) expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
# UI layer (D2): the page's own 2 s poll names the summaries phase # UI layer (D2): the page's own 2 s poll names the summaries phase
# — the RENDERED label matches the D2 PARTS shape (phase 128: # — the RENDERED label in the detail box (phase 149: #sync-progress
# the MAIN part carries "Summarizing folders… [folder]" — the # — the button is the spinner) matches the D2 shape —
# folder part is optional, the phase's first poll may precede the # "Summarizing folders… [folder] (n/3)" (the folder part is
# first hook — and the pinned COUNT part carries the (n/3) # optional, the phase's first poll may precede the first hook — the
# counts), and the button ``title`` carries the full untruncated # (n/3) counts always present) — and the button ``title`` carries
# string (main + " " + count, A4). ``wait_for_function`` checks # the same full untruncated string (A4 — one source, two surfaces).
# BOTH in ONE in-browser evaluation: a poll tick updates the parts # ``wait_for_function`` checks BOTH in ONE in-browser evaluation:
# + the title together in a single JS task, so the atomic check # a poll tick updates the box + the title together in a single JS
# can never straddle one (the JS regexes are the D2 shapes, # task (the single setSyncLabel writer), so the atomic check can
# unit-pinned in tests/unit/test_frontend_sync_upload.py). # never straddle one (the D2 shapes are unit-pinned in
# tests/unit/test_frontend_sync_upload.py).
page.wait_for_function( page.wait_for_function(
"""() => { """() => {
const label = document.querySelector('#sync-label'); const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn'); const btn = document.querySelector('#sync-btn');
if (!label || !btn) return false; if (!box || !textEl || !btn || box.hidden) return false;
const main = label.querySelector('.sync-label-main'); const t = textEl.textContent;
const count = label.querySelector('.sync-label-count'); const reFull = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
if (!main || !count || count.hidden) return false; return reFull.test(t) && btn.getAttribute('title') === t;
const reMain = /^Summarizing folders…(?: \\S+)?$/;
const reCount = /^\\(\\d+\\/3\\)$/;
return reMain.test(main.textContent)
&& reCount.test(count.textContent)
&& btn.getAttribute('title')
=== main.textContent + ' ' + count.textContent;
}""", }""",
timeout=SETTLE_TIMEOUT_MS, timeout=SETTLE_TIMEOUT_MS,
) )
+64 -55
View File
@@ -41,8 +41,10 @@ is asserted at BOTH layers the task pins:
non-null ``current_file`` (``source/relative/path``) observed at non-null ``current_file`` (``source/relative/path``) observed at
some tick, the counts advancing, and the file-less ticks (model some tick, the counts advancing, and the file-less ticks (model
probe — A4) preceding the first file tick; probe — A4) preceding the first file tick;
* **UI** — polling the label for the ``Syncing…`` prefix plus a file * **UI** — polling the detail box (``#sync-progress`` — phase 149:
path (generous timeout), which the page's own 2 s poll ticks render. the running label rides the box under the button row, the button is
the spinner) for the ``Syncing…`` prefix plus a file path (generous
timeout), which the page's own 2 s poll ticks render.
The upload archive is built in-test with Python's ``tarfile`` from The upload archive is built in-test with Python's ``tarfile`` from
**25 small ``.md`` files** (``e2e-prog.tar.gz`` → source **25 small ``.md`` files** (``e2e-prog.tar.gz`` → source
@@ -64,9 +66,10 @@ Contract under test:
documents** (the upload unpacked + registered only) and the sync documents** (the upload unpacked + registered only) and the sync
button settled idle with no error UI; clicking **Sync sources** button settled idle with no error UI; clicking **Sync sources**
then imports the uploaded row with the LIVE "Syncing… <file> (n/m)" then imports the uploaded row with the LIVE "Syncing… <file> (n/m)"
label (both layers — the phase-129 pinned chunk part "· chunk n/m" label (both layers — the phase-149 detail box carries the full
rides the label + title while a file's embed batch is in flight) string, the phase-129 pinned chunk part "· chunk n/m" rides the box
and lands the counts ("N added", the catalog refreshes); + title while a file's embed batch is in flight) and lands the
counts ("N added", the catalog refreshes);
* **upload processing (phase 90 A2)**: the upload button shows the * **upload processing (phase 90 A2)**: the upload button shows the
BARE "Processing…" for the whole background run (no file, no BARE "Processing…" for the whole background run (no file, no
"(n/m)", no title — proven across a held first status GET); every "(n/m)", no title — proven across a held first status GET); every
@@ -76,11 +79,12 @@ Contract under test:
result line points at the Sync button; the KB stays empty; result line points at the Sync button; the KB stays empty;
* **sync live file (A4)**: a multi-file local source; clicking * **sync live file (A4)**: a multi-file local source; clicking
**Sync sources** on ``/sources.html`` shows "Syncing…" (bare, the **Sync sources** on ``/sources.html`` shows "Syncing…" (bare, the
pre-64 click state) then "Syncing… <file> (n/m)" (both layers — the pre-64 click state) in the detail box (phase 149 — the button is
phase-129 pinned chunk part "· chunk n/m" rides the label + title the spinner) then "Syncing… <file> (n/m)" (both layers — the phase-
while a file's embed batch is in flight), then the pre-64 success 129 pinned chunk part "· chunk n/m" rides the box + title while a
settle — "Synced HH:MM" + the counts result line — preserved, plus file's embed batch is in flight), then the pre-64 success settle —
the file in the label; "Synced HH:MM" + the counts result line — preserved, plus the file
in the label (the box hides at settle);
* **reload re-attach (A1 + A2, phase 90)**: starting an upload and * **reload re-attach (A1 + A2, phase 90)**: starting an upload and
reloading ``/git-sources.html`` (the sub-second run may still be in reloading ``/git-sources.html`` (the sub-second run may still be in
flight — the boot re-attach enters the bare Processing state — or flight — the boot re-attach enters the bare Processing state — or
@@ -548,9 +552,10 @@ def test_upload_toast_then_navigate_away(
documents** (phase 90 A1: the upload unpacked + registered only) documents** (phase 90 A1: the upload unpacked + registered only)
and the sync button settled idle with no error UI; then click and the sync button settled idle with no error UI; then click
**Sync sources** (the new leg, phase 90) → the LIVE **Sync sources** (the new leg, phase 90) → the LIVE
"Syncing… <file> (n/m)" label while the sync imports the uploaded "Syncing… <file> (n/m)" detail while the sync imports the uploaded
row (both layers), then the success settle with the counts and the row (phase 149: the box under the button row — the button is the
catalog refresh.""" spinner — at both layers), then the success settle with the counts
and the catalog refresh (the box hides)."""
page.set_default_timeout(30_000) page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url) _admin_git_sources_page(page, app_url)
# (A previous test's terminal run may re-render its result line at # (A previous test's terminal run may re-render its result line at
@@ -599,33 +604,34 @@ def test_upload_toast_then_navigate_away(
# The click's immediate state (A4 — the bare prefix until the # The click's immediate state (A4 — the bare prefix until the
# import's first file): disabled, aria-busy, spinning icon, no # import's first file): disabled, aria-busy, spinning icon, no
# error… # error, and the detail box revealed with the bare label
# (phase 149 — the running label rides the box)…
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true") expect(btn).to_have_attribute("aria-busy", "true")
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b")) expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
# UI layer: the label gains the live file at the page's 2 s poll # UI layer (phase 149 task 01): the RUNNING detail rides the
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT # detail box — the page's 2 s poll tick reveals the live file in
# span (phase 128: the space between them is the CSS gap, so the # #sync-progress as the FULL untruncated string: "Syncing…
# rendered concatenation carries none — the FULL string with the # <source/rel/path> (n/m)" (+ " · chunk n/m" while a file's embed
# space rides the button title, A4). Phase 129: the pinned CHUNK # batch is in flight — phase 129, batch granularity). The box is
# part ("· chunk n/m" — the file's embed-batch position, batch # the visible running surface — nothing is ellipsized away, the
# granularity) renders while a file's embed batch is in flight — # "(n/m)" count is IN the full string (the never-clipped pin),
# both the rendered label (no space — the CSS gap) and the title # and the button title carries the same full string (A4 — one
# (with the space, A4) carry it. # source, two surfaces).
expect(page.locator("#sync-label")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(
re.compile( re.compile(
rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md\(\d+/{N_FILES}\)(?:· chunk \d+/\d+)?" rf"^Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"
r"(?: · chunk \d+/\d+)?$"
), ),
timeout=SETTLE_TIMEOUT_MS, timeout=SETTLE_TIMEOUT_MS,
) )
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own # The box is the VISIBLE running surface (the count's old
# visible count span, and the button title carries the full # "never clipped" pin now lives in its full string — the "(n/m)"
# untruncated string (main + " " + count [+ " " + chunk, phase # is in the text above).
# 129], A4). expect(page.locator("#sync-progress")).to_be_visible(
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
timeout=SETTLE_TIMEOUT_MS timeout=SETTLE_TIMEOUT_MS
) )
expect(btn).to_have_attribute( expect(btn).to_have_attribute(
@@ -762,12 +768,14 @@ def test_sync_live_file_label(
page: Page, app_url: str, db_ready: None, sync_local_dir: Path page: Page, app_url: str, db_ready: None, sync_local_dir: Path
) -> None: ) -> None:
"""A multi-file local source (the ``test_sync_button.py`` fixture """A multi-file local source (the ``test_sync_button.py`` fixture
style): on ``/sources.html`` click **Sync sources** → the label style): on ``/sources.html`` click **Sync sources** → the detail
shows "Syncing…" (bare, the pre-64 click state) then "Syncing… box (phase 149 — the running label left the pill; the button is
<file> (n/m)" while running (endpoint layer: ``current_file`` the spinner) shows "Syncing…" (bare, the pre-64 click state) then
non-null at running ticks; UI layer: the label poll), then the "Syncing… <file> (n/m)" while running (endpoint layer:
success settle with the counts result line — the pre-phase-64 sync ``current_file`` non-null at running ticks; UI layer: the box
UX is preserved, plus the file.""" poll), then the success settle with the counts result line (the
box hides) — the pre-phase-64 sync UX's settle is preserved, plus
the file in the label while running."""
page.set_default_timeout(30_000) page.set_default_timeout(30_000)
_seed_local_source(str(sync_local_dir)) _seed_local_source(str(sync_local_dir))
login(page, app_url) # lands on /sources.html (the button's home) login(page, app_url) # lands on /sources.html (the button's home)
@@ -792,33 +800,34 @@ def test_sync_live_file_label(
# The click's immediate state is the pre-64 one (A4 — the bare # The click's immediate state is the pre-64 one (A4 — the bare
# prefix until the import's first file): disabled, aria-busy, # prefix until the import's first file): disabled, aria-busy,
# spinning icon, no error… # spinning icon, no error, and the detail box revealed with the
# bare label (phase 149 — the running label rides the box)…
expect(btn).to_be_disabled() expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true") expect(btn).to_have_attribute("aria-busy", "true")
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b")) expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-error-banner")).to_be_hidden()
# UI layer: the label gains the live file at the page's 2 s poll # UI layer (phase 149 task 01): the RUNNING detail rides the
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT # detail box — the page's 2 s poll tick reveals the live file in
# span (phase 128: the space between them is the CSS gap, so the # #sync-progress as the FULL untruncated string: "Syncing…
# rendered concatenation carries none — the FULL string with the # <source/rel/path> (n/m)" (+ " · chunk n/m" while a file's embed
# space rides the button title, A4). Phase 129: the pinned CHUNK # batch is in flight — phase 129, batch granularity). The box is
# part ("· chunk n/m" — the file's embed-batch position, batch # the visible running surface — nothing is ellipsized away, the
# granularity) renders while a file's embed batch is in flight — # "(n/m)" count is IN the full string (the never-clipped pin),
# both the rendered label (no space — the CSS gap) and the title # and the button title carries the same full string (A4 — one
# (with the space, A4) carry it. # source, two surfaces).
expect(page.locator("#sync-label")).to_have_text( expect(page.locator("#sync-progress-text")).to_have_text(
re.compile( re.compile(
rf"Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md\(\d+/{N_FILES}\)(?:· chunk \d+/\d+)?" rf"^Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)"
r"(?: · chunk \d+/\d+)?$"
), ),
timeout=SETTLE_TIMEOUT_MS, timeout=SETTLE_TIMEOUT_MS,
) )
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own # The box is the VISIBLE running surface (the count's old
# visible count span, and the button title carries the full # "never clipped" pin now lives in its full string — the "(n/m)"
# untruncated string (main + " " + count [+ " " + chunk, phase # is in the text above).
# 129], A4). expect(page.locator("#sync-progress")).to_be_visible(
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
timeout=SETTLE_TIMEOUT_MS timeout=SETTLE_TIMEOUT_MS
) )
expect(btn).to_have_attribute( expect(btn).to_have_attribute(
+231 -38
View File
@@ -92,6 +92,23 @@ contract: shown + enabled in EVERY sync-running state (the entry
shows it for kind "sync"), hidden + disabled for the upload run shows it for kind "sync"), hidden + disabled for the upload run
(the upload job is NOT cancellable in this phase) and after the (the upload job is NOT cancellable in this phase) and after the
settle (settleSyncButton always hides it). settle (settleSyncButton always hides it).
Phase 149 (task 01) adds the `running` flag to the single writer:
setSyncLabel(mainText, countText = "", chunkText = "", running =
false) also writes the SAME untruncated full label into the detail
box (#sync-progress's .sync-progress-text — textContent only) and
reveals the box IFF running (the running entry passes true, the
settle passes the default false), and owns the button's accessible
name (full live label while running, the ship value "Sync sources"
restored on every non-running call). The running entry passes
`running = true` + adds the `is-running` class (with the spin), and
no longer sets aria-label (the writer owns it); the settle removes
`is-running` (with the spin). The node harness rows gain the box's
text/hidden pair, the button's `is-running` + the icon's
`is-spinning` flags, and the button's aria-label — the behavioral
contract: box visible + full label + is-running + spinning + the
aria-label equal to the full label in EVERY running state, box
hidden + the idle flags + the restored aria-label after the settle.
""" """
from __future__ import annotations from __future__ import annotations
@@ -256,22 +273,33 @@ def test_fmt_sync_label_returns_the_parts_contract() -> None:
def test_set_sync_label_is_the_single_parts_writer() -> None: def test_set_sync_label_is_the_single_parts_writer() -> None:
"""setSyncLabel(mainText, countText = "", chunkText = "") — Phase """setSyncLabel(mainText, countText = "", chunkText = "",
128 (task 01) + Phase 129 (task 04): the SINGLE point that writes running = false) — Phase 128 (task 01) + Phase 129 (task 04):
#sync-label. The MAIN part gets mainText (textContent only — the the SINGLE point that writes #sync-label. The MAIN part gets
house rule, no HTML strings); the COUNT part gets countText when mainText (textContent only — the house rule, no HTML strings);
present (textContent + hidden=false — the "(n/m)" position is the COUNT part gets countText when present (textContent +
visible, NEVER clipped) and is emptied + hidden=true when absent; hidden=false — the "(n/m)" position is visible, NEVER clipped)
the CHUNK part (phase 129) gets chunkText under the SAME contract and is emptied + hidden=true when absent; the CHUNK part
(present → the pinned "· chunk n/m" is visible, absent → emptied (phase 129) gets chunkText under the SAME contract (present →
+ hidden=true — the part renders only while a file's embed batch the pinned "· chunk n/m" is visible, absent → emptied +
hidden=true — the part renders only while a file's embed batch
is in flight). Then the UNTRUNCATED full label — mainText + is in flight). Then the UNTRUNCATED full label — mainText +
(countText ? " " + countText : "") + (chunkText ? " " + chunkText (countText ? " " + countText : "") + (chunkText ? " " + chunkText
: "") — rides the button title + #sync-result (the aria-live : "") — rides the button title + #sync-result (the aria-live
announcer) (A4 — the screen reader hears the whole "Syncing… announcer) (A4 — the screen reader hears the whole "Syncing…
<file> (2/3) · chunk 14/38").""" <file> (2/3) · chunk 14/38"). Phase 149 (task 01): the fourth
parameter `running` (the in-flight flag — the running entry
passes true, the settle the default false): the SAME full label
lands in the detail box (.sync-progress-text, textContent only)
and the box (#sync-progress) is visible IFF running (hidden in
every non-running state), and the button's accessible name is
the full label while running / the ship value "Sync sources"
otherwise (overwrites/restores only — never removes)."""
body = _fn(_js(), "setSyncLabel") body = _fn(_js(), "setSyncLabel")
assert 'function setSyncLabel(mainText, countText = "", chunkText = "")' in body assert (
'function setSyncLabel(mainText, countText = "", chunkText = "", running = false)'
in body
), "phase 149: the in-flight flag is the fourth parameter"
# the parts: textContent only (the house rule), null-guarded refs # the parts: textContent only (the house rule), null-guarded refs
assert "syncLabelMain.textContent = mainText" in body assert "syncLabelMain.textContent = mainText" in body
assert "syncLabelCount.textContent = countText" in body assert "syncLabelCount.textContent = countText" in body
@@ -302,7 +330,13 @@ def test_set_sync_label_is_the_single_parts_writer() -> None:
i_full = body.find("const full") i_full = body.find("const full")
i_title = body.find("syncBtn.title = full") i_title = body.find("syncBtn.title = full")
i_result = body.find("syncResult.textContent = full") i_result = body.find("syncResult.textContent = full")
assert -1 < i_full < i_title < i_result, ("built once: full → title → announcer") i_box = body.find("syncProgressText.textContent = full")
i_hide = body.find("syncProgress.hidden = !running")
i_aria = body.find('syncBtn.setAttribute("aria-label", running ? full : "Sync sources")')
assert -1 < i_full < i_title < i_result < i_box < i_hide < i_aria, (
"built once: full → title → announcer → the detail box (revealed IFF"
" running) → the button's accessible name (phase 149)"
)
assert "innerHTML" not in body, "the label path is textContent only" assert "innerHTML" not in body, "the label path is textContent only"
@@ -421,16 +455,25 @@ def test_enter_running_state_is_phase_aware_and_writes_the_parts() -> None:
# the parts are handed to the single writer (A4 lives in it) — # the parts are handed to the single writer (A4 lives in it) —
# phase 129: the chunk part destructures with a "" default (the # phase 129: the chunk part destructures with a "" default (the
# bare builders — the upload job, the 202/409 click — return no # bare builders — the upload job, the 202/409 click — return no
# chunk part, so the part stays hidden there). # chunk part, so the part stays hidden there); phase 149: the
# call passes `running = true` (the detail box reveals + the
# full-label aria-label ride in the writer).
assert "const { main, count, chunk = \"\" } =" in body, ( assert "const { main, count, chunk = \"\" } =" in body, (
"the chunk part destructures with the '' default (bare builders return none)" "the chunk part destructures with the '' default (bare builders return none)"
) )
i_set = body.find("setSyncLabel(main, count, chunk)") i_set = body.find("setSyncLabel(main, count, chunk, true)")
assert -1 < i_fall < i_set, "the parts are written after the split" assert -1 < i_fall < i_set, "the parts are written after the split (running = true)"
assert body.count("setSyncLabel(") == 1, "one writer call per entry" assert body.count("setSyncLabel(") == 1, "one writer call per entry"
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)") assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
assert "syncResult" not in body, ("the announcer write lives in setSyncLabel (A4)") assert "syncResult" not in body, ("the announcer write lives in setSyncLabel (A4)")
assert 'syncBtn.setAttribute("aria-label"' not in body, (
"the aria-label write lives in setSyncLabel (phase 149: the full"
" live label while running)"
)
assert "syncLabel.textContent" not in body, ("no direct span write — the parts") assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
assert "syncBtn.classList.add(\"is-running\")" in body, (
"the icon-only running button class is added with the spin (phase 149)"
)
def test_settle_and_idle_labels_go_through_the_parts_writer() -> None: def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
@@ -448,6 +491,12 @@ def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
assert "function settleSyncButton(label)" in settle assert "function settleSyncButton(label)" in settle
assert "setSyncLabel(label)" in settle, "the settle label goes through the parts writer" assert "setSyncLabel(label)" in settle, "the settle label goes through the parts writer"
assert settle.count("setSyncLabel(") == 1 assert settle.count("setSyncLabel(") == 1
assert "setSyncLabel(label, true)" not in settle, (
"the settle is NEVER a running state — the detail box hides (phase 149)"
)
assert "syncBtn.classList.remove(\"is-running\")" in settle, (
"the running class is removed with the spin on EVERY settle (phase 149)"
)
assert "syncLabel.textContent" not in settle, ("no direct span write — the parts") assert "syncLabel.textContent" not in settle, ("no direct span write — the parts")
# the success settle keeps the "Synced HH:MM" copy + the counts line # the success settle keeps the "Synced HH:MM" copy + the counts line
# AFTER the settle (the settle's announcer write is superseded by # AFTER the settle (the settle's announcer write is superseded by
@@ -487,6 +536,39 @@ def test_sync_label_markup_ships_the_hidden_chunk_part() -> None:
) )
def test_sync_progress_box_markup_ships_under_the_button_row() -> None:
"""Phase 149 (task 01): #sync-progress — the sync detail box under
the button row (the shell's RAG view): between the .page-head block
(the button row + the page sub) and #sync-result. Ships hidden +
aria-hidden (the anonymous-safe/idle state — sources.js reveals it
through the single setSyncLabel writer only while a run is in
flight; presentational, so #sync-result stays the SOLE aria-live
announcer — one live region, no double announce). It carries one
.sync-progress-text span (id-pinned) — the writer's textContent
target, the full untruncated live label."""
html = _html()
box = re.search(
r'<div class="sync-progress" id="sync-progress" hidden aria-hidden="true">\s*'
r'<span class="sync-progress-text" id="sync-progress-text"></span>\s*</div>',
html,
)
assert box, "the shell must carry the #sync-progress detail box (phase 149)"
# Under the button row, above the announcer: the RAG view's
# .page-head block precedes the box, and the box precedes
# #sync-result (one live region — the box is presentational).
rag = html.find('id="view-rag"')
head = html.find('class="page-head"', rag)
result = html.find('id="sync-result"', rag)
assert -1 < head < box.start() < result, (
"the box sits between the RAG view's .page-head block and "
"#sync-result (directly under the button row, above the announcer)"
)
# Ship-hidden + presentational (the reveal is run-scoped, in JS).
tag = html[box.start() : html.find(">", box.start())]
assert re.search(r"\bhidden\b", tag), "the box ships hidden (idle / anonymous-safe)"
assert 'aria-hidden="true"' in tag, ("the box is presentational (no double announce)")
def test_sync_label_chunk_css_is_the_pinned_part() -> None: def test_sync_label_chunk_css_is_the_pinned_part() -> None:
""".sync-label-chunk (phase 129 task 04): the same pinned-part """.sync-label-chunk (phase 129 task 04): the same pinned-part
language as .sync-label-count — flex 0 0 auto (NEVER clipped), language as .sync-label-count — flex 0 0 auto (NEVER clipped),
@@ -515,15 +597,33 @@ const syncLabelMain = { textContent: "" };
const syncLabelCount = { textContent: "", hidden: true }; const syncLabelCount = { textContent: "", hidden: true };
const syncLabelChunk = { textContent: "", hidden: true }; const syncLabelChunk = { textContent: "", hidden: true };
const syncResult = { textContent: "" }; const syncResult = { textContent: "" };
// Phase 149 (task 01): the detail box — presentational, ship-hidden;
// the REAL writer mutates it (full label + the running reveal).
const syncProgress = { hidden: true };
const syncProgressText = { textContent: "" };
// Phase 149 (task 01): class-tracking stubs — the is-running /
// is-spinning travel-together contract is behavioral, not source.
const syncBtn = { const syncBtn = {
disabled: false, disabled: false,
title: "", title: "",
attrs: {}, attrs: {},
setAttribute(k, v) { this.attrs[k] = v; }, setAttribute(k, v) { this.attrs[k] = v; },
removeAttribute(k) { delete this.attrs[k]; }, removeAttribute(k) { delete this.attrs[k]; },
classList: { add() {}, remove() {} }, classes: new Set(),
classList: {
add(c) { syncBtn.classes.add(c); },
remove(c) { syncBtn.classes.delete(c); },
has(c) { return syncBtn.classes.has(c); },
},
};
const syncIcon = {
classes: new Set(),
classList: {
add(c) { syncIcon.classes.add(c); },
remove(c) { syncIcon.classes.delete(c); },
has(c) { return syncIcon.classes.has(c); },
},
}; };
const syncIcon = { classList: { add() {}, remove() {} } };
// Phase 134 (task 02): the Cancel control — ships hidden + disabled // Phase 134 (task 02): the Cancel control — ships hidden + disabled
// (the markup's state); the extracted REAL show/hide helpers mutate // (the markup's state); the extracted REAL show/hide helpers mutate
// it (show also sets the aria-label — the attrs pocket records it). // it (show also sets the aria-label — the attrs pocket records it).
@@ -544,6 +644,11 @@ const show = (tag) => {
syncLabelCount.textContent, syncLabelCount.hidden, syncLabelCount.textContent, syncLabelCount.hidden,
syncLabelChunk.textContent, syncLabelChunk.hidden, syncLabelChunk.textContent, syncLabelChunk.hidden,
syncCancelBtn.hidden, syncCancelBtn.disabled, syncCancelBtn.hidden, syncCancelBtn.disabled,
// Phase 149 (task 01): the detail box (text + hidden), the
// is-running / is-spinning flags, the button's accessible name.
syncProgressText.textContent, syncProgress.hidden,
syncBtn.classes.has('is-running'), syncIcon.classes.has('is-spinning'),
syncBtn.attrs['aria-label'] || '',
])); ]));
}; };
// 1. import, batch in flight (the task's fixture values, 14/38): // 1. import, batch in flight (the task's fixture values, 14/38):
@@ -596,6 +701,34 @@ def _run_node(script: str) -> str:
return proc.stdout return proc.stdout
def _chunk_harness_rows() -> list:
"""The node harness's rows — the REAL extracted label path against
stub element refs (the test-document-viewer pattern), one JSON row
per state: [tag, title, announcer, main, count, countHidden,
chunk, chunkHidden, cancelHidden, cancelDisabled, progressText,
progressHidden, btnRunning, iconSpinning, ariaLabel]. Shared by
the chunk-part test (phase 129) and the detail-box test
(phase 149)."""
js = _js()
fns = "\n".join(
_fn(js, name)
for name in (
"fmtSyncLabel",
"fmtSyncPhaseLabel",
"setSyncLabel",
# Phase 134 (task 02): the running entry's REAL cancel
# show/hide + the REAL settle (step 8) — extracted against
# the syncCancelBtn stub.
"showSyncCancelBtn",
"hideSyncCancelBtn",
"enterSyncRunningState",
"settleSyncButton",
)
)
script = _CHUNK_NODE_HEAD + fns + _CHUNK_NODE_TAIL
return [json.loads(line) for line in _run_node(script).splitlines() if line]
@pytest.mark.skipif(not HAVE_NODE, reason="node not available") @pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_chunk_part_renders_only_in_the_import_phase_under_node() -> None: def test_chunk_part_renders_only_in_the_import_phase_under_node() -> None:
"""Behavioral check (node) of the REAL label path — the extracted """Behavioral check (node) of the REAL label path — the extracted
@@ -620,27 +753,11 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
for the upload run (the upload job is NOT cancellable in this for the upload run (the upload job is NOT cancellable in this
phase) and after the (real) settle. phase) and after the (real) settle.
""" """
js = _js() rows = _chunk_harness_rows()
fns = "\n".join(
_fn(js, name)
for name in (
"fmtSyncLabel",
"fmtSyncPhaseLabel",
"setSyncLabel",
# Phase 134 (task 02): the running entry's REAL cancel
# show/hide + the REAL settle (step 8) — extracted against
# the syncCancelBtn stub.
"showSyncCancelBtn",
"hideSyncCancelBtn",
"enterSyncRunningState",
"settleSyncButton",
)
)
script = _CHUNK_NODE_HEAD + fns + _CHUNK_NODE_TAIL
rows = [json.loads(line) for line in _run_node(script).splitlines() if line]
by_tag = {row[0]: row for row in rows} by_tag = {row[0]: row for row in rows}
# row = [tag, title, announcer, main, count, countHidden, chunk, # row = [tag, title, announcer, main, count, countHidden, chunk,
# chunkHidden, cancelHidden, cancelDisabled] # chunkHidden, cancelHidden, cancelDisabled, progressText,
# progressHidden, btnRunning, iconSpinning, ariaLabel]
# 1. the in-flight import — the chunk part renders, the full # 1. the in-flight import — the chunk part renders, the full
# untruncated label rides the title + the announcer (A4). # untruncated label rides the title + the announcer (A4).
@@ -694,6 +811,79 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
) )
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_progress_box_and_running_button_contract_under_node() -> None:
"""Phase 149 (task 01) — the detail box + the icon-only running
button, behaviorally pinned through the SAME node harness (the
REAL setSyncLabel / enterSyncRunningState / settleSyncButton
against the stub element refs). The contract — the box is
visible IFF a run is in flight, and its text is EXACTLY the full
untruncated label (one source, two surfaces: the box for sighted
users, the announcer for AT):
* EVERY running state (the in-flight import, the pre-batch
import, the prelude, the 202/409 click, the upload run, the
overview, the summaries): the box is REVEALED (hidden=false)
and carries the full label (the same string as the button
title / announcer — the writer's `full`), the button carries
`is-running` + the icon `is-spinning` (the class travels with
the spin), and the button's accessible name (aria-label) is
the FULL live label (the icon-only running button keeps a
name);
* the settle (the REAL settleSyncButton through the writer with
the default `running = false`): the box HIDES, the settle
label is written into the box text ("Sync sources" — hidden,
so inert), `is-running` + `is-spinning` are both gone, and
the aria-label is restored to the ship value "Sync sources".
"""
rows = _chunk_harness_rows()
by_tag = {row[0]: row for row in rows}
# row = [tag, title, announcer, main, count, countHidden, chunk,
# chunkHidden, cancelHidden, cancelDisabled, progressText,
# progressHidden, btnRunning, iconSpinning, ariaLabel]
# Every running state: box visible + the EXACT full label +
# is-running + spinning + the full-label accessible name.
for tag in (
"import-inflight",
"import-prebatch",
"prelude",
"click",
"upload",
"overview",
"summaries",
):
row = by_tag[tag]
assert row[11] is False, (
f"{tag}: the detail box must be VISIBLE while the run is in flight"
)
assert row[10] == row[1], (
f"{tag}: the box carries the EXACT full label (title == box)"
)
assert row[12] is True, (
f"{tag}: the button must carry is-running while in flight"
)
assert row[13] is True, (
f"{tag}: is-running travels with the spin (the icon spins)"
)
assert row[14] == row[1], (
f"{tag}: the running button's accessible name is the full live label"
)
# The settle: the box hides, the running state is fully cleared,
# and the accessible name is back to the ship value.
row = by_tag["idle"]
assert row[11] is True, "the settle must HIDE the detail box (run-scoped only)"
assert row[10] == "Sync sources", (
"the writer wrote the settle label into the box (hidden — inert)"
)
assert row[12] is False, "is-running is removed on the settle (with the spin)"
assert row[13] is False, "the spin is removed on the settle"
assert row[14] == "Sync sources", (
"the settle restores the ship aria-label (the idle truth)"
)
def test_every_other_state_passes_no_chunk_text() -> None: def test_every_other_state_passes_no_chunk_text() -> None:
"""Source-level pin of the render-only-while-chunks_total>0 """Source-level pin of the render-only-while-chunks_total>0
contract: the import fall-through is the ONLY place in sources.js contract: the import fall-through is the ONLY place in sources.js
@@ -701,7 +891,8 @@ def test_every_other_state_passes_no_chunk_text() -> None:
branches and the bare builders never touch them), and every branches and the bare builders never touch them), and every
NON-running label emission passes no chunk text (the part hides): NON-running label emission passes no chunk text (the part hides):
the settle/idle labels go through setSyncLabel(label) — the only the settle/idle labels go through setSyncLabel(label) — the only
three-arg call site is the running entry.""" running-flagged call site (phase 149: `running = true`) is the
running entry."""
js = _js() js = _js()
body = _fn(js, "fmtSyncPhaseLabel") body = _fn(js, "fmtSyncPhaseLabel")
i_fall = body.find("fmtSyncLabel(") i_fall = body.find("fmtSyncLabel(")
@@ -711,7 +902,9 @@ def test_every_other_state_passes_no_chunk_text() -> None:
) )
fall = body[i_fall:] fall = body[i_fall:]
assert "status.chunks_total" in fall and "status.current_chunk" in fall assert "status.chunks_total" in fall and "status.current_chunk" in fall
assert js.count("setSyncLabel(main, count, chunk)") == 1 assert js.count("setSyncLabel(main, count, chunk, true)") == 1, (
"the ONLY running-flagged call site is the running entry (phase 149)"
)
assert "setSyncLabel(label)" in js, "the settle/idle labels stay two-arg (no chunk)" assert "setSyncLabel(label)" in js, "the settle/idle labels stay two-arg (no chunk)"
+38 -5
View File
@@ -49,6 +49,17 @@ flight (the import phase after the first batch — hidden in every
other state); the markup pin below pins the exact three-part ship other state); the markup pin below pins the exact three-part ship
shape. shape.
Phase 149 (task 01): the running button becomes the SPINNER — the
writer gains the `running` flag (the running entry passes `true`,
the settle the default `false`), which reveals the detail box
(#sync-progress — the full untruncated label, presentational
aria-hidden; #sync-result stays the sole announcer) and owns the
button's accessible name (the full live label while running, the
ship value "Sync sources" restored on every settle); the
`is-running` class travels with the spin (added in the running
entry, removed in the settle). The pins below move onto that shape
(the aria-label write leaves the running entry for the writer).
Phase 129 (task 03): the status contract gains the in-flight file's Phase 129 (task 03): the status contract gains the in-flight file's
EMBED-chunk position — ``current_chunk`` (the 1-based first chunk of EMBED-chunk position — ``current_chunk`` (the 1-based first chunk of
the in-flight token-budgeted batch) + ``chunks_total`` (the file's the in-flight token-budgeted batch) + ``chunks_total`` (the file's
@@ -372,7 +383,12 @@ def test_sources_js_running_state_is_never_stale() -> None:
(phase 129 — hidden when absent), and the FULL untruncated label (phase 129 — hidden when absent), and the FULL untruncated label
on the button title + #sync-result (A4) — the direct span / on the button title + #sync-result (A4) — the direct span /
title / announcer writes are GONE from the entry (they live in title / announcer writes are GONE from the entry (they live in
setSyncLabel).""" setSyncLabel). Phase 149 (task 01): the writer call passes
`running = true` (the detail box reveals with the full label),
and the button's accessible name rides in the writer too (the
full live label — the icon-only running button keeps a name);
the entry no longer sets aria-label at all. The `is-running`
class is added here, with the spin."""
js = _text(SOURCES_JS) js = _text(SOURCES_JS)
body = _body(js, "enterSyncRunningState") body = _body(js, "enterSyncRunningState")
assert "syncBtn.disabled = true" in body assert "syncBtn.disabled = true" in body
@@ -381,15 +397,22 @@ def test_sources_js_running_state_is_never_stale() -> None:
assert "fmtSyncPhaseLabel(status)" in body assert "fmtSyncPhaseLabel(status)" in body
assert "fmtSyncLabel(kind, currentFile, done, total)" in body assert "fmtSyncLabel(kind, currentFile, done, total)" in body
# …and the single writer lands the parts (A4 in it — the title is # …and the single writer lands the parts (A4 in it — the title is
# set for EVERY running label: the file-only rule is adjusted away). # set for EVERY running label: the file-only rule is adjusted
assert "setSyncLabel(main, count, chunk)" in body # away) with the phase-149 `running` flag (the box reveals).
assert "setSyncLabel(main, count, chunk, true)" in body
assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer" assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer"
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)") assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
assert "syncLabel.textContent" not in body, ("no direct span write — the parts") assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
assert "syncResult.textContent" not in body, ("the announcer write lives in setSyncLabel (A4)") assert "syncResult.textContent" not in body, ("the announcer write lives in setSyncLabel (A4)")
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body assert 'syncBtn.setAttribute("aria-label"' not in body, (
"the aria-label write lives in setSyncLabel (phase 149: the full"
" live label while running)"
)
assert "syncBtn.classList.remove(\"is-error\")" in body assert "syncBtn.classList.remove(\"is-error\")" in body
assert "syncIcon.classList.add(\"is-spinning\")" in body assert "syncIcon.classList.add(\"is-spinning\")" in body
assert "syncBtn.classList.add(\"is-running\")" in body, (
"the icon-only running button class travels with the spin"
)
def test_sources_js_terminal_states() -> None: def test_sources_js_terminal_states() -> None:
@@ -439,7 +462,11 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
(failed included: retry-ready). Phase 128 (task 01): the settle / (failed included: retry-ready). Phase 128 (task 01): the settle /
idle label ("Sync sources", "Synced HH:MM") goes through the parts idle label ("Sync sources", "Synced HH:MM") goes through the parts
writer — setSyncLabel(label), count hidden (absent), the full writer — setSyncLabel(label), count hidden (absent), the full
string riding the title + #sync-result (A4, every label state).""" string riding the title + #sync-result (A4, every label state).
Phase 149 (task 01): the `is-running` class is removed WITH the
spin (every terminal + both idles pass through the settle), and
the writer call's default `running = false` hides the detail box
+ restores the idle accessible name."""
js = _text(SOURCES_JS) js = _text(SOURCES_JS)
body = _body(js, "settleSyncButton") body = _body(js, "settleSyncButton")
assert "syncBtn.disabled = false" in body assert "syncBtn.disabled = false" in body
@@ -447,7 +474,13 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
assert 'syncBtn.removeAttribute("title")' in body assert 'syncBtn.removeAttribute("title")' in body
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
assert "syncIcon.classList.remove(\"is-spinning\")" in body assert "syncIcon.classList.remove(\"is-spinning\")" in body
assert "syncBtn.classList.remove(\"is-running\")" in body, (
"the running class travels with the spin — the settle removes both"
)
assert "setSyncLabel(label)" in body, ("the settle label goes through the parts writer") assert "setSyncLabel(label)" in body, ("the settle label goes through the parts writer")
assert "setSyncLabel(label, true)" not in body, (
"the settle is NEVER a running state — the box hides (phase 149)"
)
assert "syncLabel.textContent" not in body, ("no direct span write — the parts") assert "syncLabel.textContent" not in body, ("no direct span write — the parts")