phase: 128_sync_label_mobile
Everything verified and 100% green. The earlier rejection was a report-format issue, not a test failure — all work is in the working tree. Final report: **Phase 128 final pass — verified (all 3 tasks complete; no code defects found)** - Verified: `#sync-label` parts markup, `setSyncLabel` writer, part builders (A4 untruncated title/aria-live), flex-row CSS + mobile media query — all match design 1:1. - `uv run pytest --cov=app --cov-report=term`: 3024 passed; TOTAL coverage **99%** (>90% gate). - `uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov`: 4 passed (119s, isolated). - `uv run ruff check .`: clean; `uv run pyright`: 0 errors. - Criteria: (1) mobile 390×844 label in-viewport, count visible in import+summaries — ✅ E2E; (2) desktop strings/cap/title unchanged — ✅ E2E+unit; (3) E2E isolated green — ✅; (4) full suite green — ✅. - Docs: README.md fixed 5 pre-existing gaps (PDF phase-126 section + `pdf` token, hidden-folders toggle ph.105, document dates ph.106, private git-source masked tokens ph.121, doc-draft Discard ph.115); AGENTS.md verified consistent — no changes. - Notable: double-`-q` (addopts `-q` + CLI `-q`) suppresses pytest's summary line — standard `-qq` behavior, not a bug; exit codes confirm green. - Next pending phase: `129_sync_chunk_progress`.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
All green. Final report:
|
||||
|
||||
**Phase 128 — final verification pass (all 3 tasks already complete)**
|
||||
- **Verified implementation**: `#sync-label` split into `.sync-label-main` (ellipsizable) + `.sync-label-count` (pinned, `hidden` when absent); single `setSyncLabel(main, count)` writer; builders return parts; A4 untruncated label still rides button `title` + `#sync-result`; CSS flex-row + mobile `flex: 1 1 auto` media query all match the design 1:1.
|
||||
- **Completion criteria**:
|
||||
- Mobile 390×844 label fully in viewport, count visible in import AND summaries — ✅ (E2E bounding-box assertions)
|
||||
- Desktop byte-identical strings/cap/title — ✅ (E2E + unit pins)
|
||||
- `uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov` in isolation — ✅ 4 passed (119s)
|
||||
- `uv run pytest` (unit+integration) — ✅ 3024 passed
|
||||
- **Coverage**: `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90% gate).
|
||||
- **Lint/types**: `uv run ruff check .` clean; `uv run pyright` → 0 errors.
|
||||
- **Docs**: README.md — fixed 5 pre-existing gaps: added "PDF documents (phase 126)" section + `pdf` token in indexed-formats/config reference; documented hidden-folders toggle (ph.105), document dates (ph.106), private git-source masked tokens (ph.121, old "no credentials stored" claim corrected), doc-draft Discard (ph.115). AGENTS.md — verified consistent, no changes needed.
|
||||
- **Notable**: no code defects found; pytest "missing summary line" under double-`-q` is standard `-qq` behavior, not a bug.
|
||||
- **Next pending phase**: `129_sync_chunk_progress`.
|
||||
@@ -0,0 +1,113 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [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 262 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 374 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 81 0 100%
|
||||
app/rag/prompts.py 144 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 49 0 100%
|
||||
app/schemas.py 400 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5133 22 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,11 @@
|
||||
Everything verified and 100% green. The earlier rejection was a report-format issue, not a test failure — all work is in the working tree. Final report:
|
||||
|
||||
**Phase 128 final pass — verified (all 3 tasks complete; no code defects found)**
|
||||
- Verified: `#sync-label` parts markup, `setSyncLabel` writer, part builders (A4 untruncated title/aria-live), flex-row CSS + mobile media query — all match design 1:1.
|
||||
- `uv run pytest --cov=app --cov-report=term`: 3024 passed; TOTAL coverage **99%** (>90% gate).
|
||||
- `uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov`: 4 passed (119s, isolated).
|
||||
- `uv run ruff check .`: clean; `uv run pyright`: 0 errors.
|
||||
- Criteria: (1) mobile 390×844 label in-viewport, count visible in import+summaries — ✅ E2E; (2) desktop strings/cap/title unchanged — ✅ E2E+unit; (3) E2E isolated green — ✅; (4) full suite green — ✅.
|
||||
- Docs: README.md fixed 5 pre-existing gaps (PDF phase-126 section + `pdf` token, hidden-folders toggle ph.105, document dates ph.106, private git-source masked tokens ph.121, doc-draft Discard ph.115); AGENTS.md verified consistent — no changes.
|
||||
- Notable: double-`-q` (addopts `-q` + CLI `-q`) suppresses pytest's summary line — standard `-qq` behavior, not a bug; exit codes confirm green.
|
||||
- Next pending phase: `129_sync_chunk_progress`.
|
||||
@@ -0,0 +1,113 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [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 262 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 374 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 81 0 100%
|
||||
app/rag/prompts.py 144 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 49 0 100%
|
||||
app/schemas.py 400 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5133 22 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,17 @@
|
||||
All green and consistent. Final report:
|
||||
|
||||
**Implemented (task 01 — `#sync-label` parts)**
|
||||
- `frontend/index.html`: `#sync-label` now ships two child spans — `.sync-label-main` (carries the idle "Sync sources" pre-JS) + `.sync-label-count` (hidden, empty).
|
||||
- `frontend/assets/sources.js`: new `setSyncLabel(mainText, countText = "")` is the SINGLE label writer (main part textContent; count part textContent + `hidden` toggle — hidden/emptied when absent; the untruncated full label `main + " " + count` on `syncBtn.title` + `#sync-result`, A4). `fmtSyncLabel`/`fmtSyncPhaseLabel` return `{main, count}` (decision trees + byte-identical copy preserved, incl. always-emitted summaries count and bare upload "Importing"); `enterSyncRunningState` and `settleSyncButton` (idle/settle) go through it. textContent only — no HTML strings.
|
||||
- `tests/unit/test_frontend_sync_upload.py`: label pins re-pointed to the part shape (main/count per state: prelude, import ±total, overview, summaries ±folder, upload, idle/settle; A4 full-string join; hidden toggle; single-writer + no-direct-write pins). `tests/unit/test_sync_button.py`: markup + running/settle pins updated.
|
||||
- Fixed two existing E2E suites broken by the DOM split (step 8): `test_sync_upload_progress.py` (count-label regexes → new visible concatenation + pinned visible count span + full-space title) and `test_sync_summary_visibility.py` (atomic JS check → main/count part regexes + `title === main + " " + count`).
|
||||
|
||||
**Verification**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **3024 passed, 0 failed**, `app/` coverage **99%** (>90% gate)
|
||||
- `uv run ruff check .` → all passed; `uv run pyright` → 0 errors, 0 warnings
|
||||
- E2E in isolation: `test_sync_button.py` 3 ✓, `test_sync_summary_visibility.py` 3 ✓, `test_sync_upload_progress.py` 4 ✓, `test_sync_model_down.py` 4 ✓, `test_upload_no_scan.py` 3 ✓
|
||||
- Node harness: every running/idle/settle full string byte-identical to pre-task; count span hidden whenever absent.
|
||||
|
||||
**Docs:** no doc impact (README's "Syncing…" line stays accurate; AGENTS.md unchanged).
|
||||
**Decisions:** settled/idle labels now also ride the title + `#sync-result` (the task's unconditional `setSyncLabel` spec — "title + aria-live always carry the whole thing"); visible file↔count space is now the CSS gap (task 02). No live-infra changes.
|
||||
**Next pending task:** `.agents/phases/todo/128_sync_label_mobile/02_mobile_css.md`
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [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 262 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 374 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 81 0 100%
|
||||
app/rag/prompts.py 144 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 49 0 100%
|
||||
app/schemas.py 400 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5133 22 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,15 @@
|
||||
All green. Everything verified — final report:
|
||||
|
||||
**Implemented (task 01 — `#sync-label` parts; changes intact from prior attempt, fully re-verified)**
|
||||
- `frontend/index.html`: `#sync-label` ships `.sync-label-main` (idle "Sync sources" pre-JS) + `.sync-label-count` (hidden, empty).
|
||||
- `frontend/assets/sources.js`: `setSyncLabel(mainText, countText="")` is the single label writer — parts via textContent (count `hidden` whenever absent), untruncated full label `main + " " + count` on `syncBtn.title` + `#sync-result` (A4); `fmtSyncLabel`/`fmtSyncPhaseLabel` return `{main, count}` (decision trees + byte-identical copy); `enterSyncRunningState` + `settleSyncButton` route through it. No HTML strings.
|
||||
- Unit pins re-pointed to the part shape (`test_frontend_sync_upload.py`, `test_sync_button.py`); fixed the two E2E suites broken by the DOM split (`test_sync_upload_progress.py`, `test_sync_summary_visibility.py`).
|
||||
|
||||
**Results (exact commands)**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **3024 passed, 0 failed**; `app/` coverage **99%** (>90% gate)
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
|
||||
- E2E in isolation: `test_sync_button.py` 3 ✓, `test_sync_summary_visibility.py` 3 ✓, `test_sync_upload_progress.py` 4 ✓ (all `--no-cov`)
|
||||
|
||||
**Docs:** no doc impact (README's "Syncing…" line stays accurate; AGENTS.md unchanged).
|
||||
**Decisions:** settle/idle labels also ride title + `#sync-result` per the task's unconditional `setSyncLabel` spec; the visible file↔count space becomes the CSS gap (task 02's job). No live-infra changes.
|
||||
**Next pending task:** `.agents/phases/todo/128_sync_label_mobile/02_mobile_css.md`
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [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 262 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 374 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 81 0 100%
|
||||
app/rag/prompts.py 144 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 49 0 100%
|
||||
app/schemas.py 400 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5133 22 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,18 @@
|
||||
All green. Task 02 is complete.
|
||||
|
||||
**Implemented**
|
||||
- `frontend/assets/styles.css` — `.sync-label` is now a flex row (`inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0; max-width: min(16rem, 40vw)`); new `.sync-label-main` (the shrinkable ellipsizing part) and `.sync-label-count` (`flex: 0 0 auto`, never clipped, `var(--mono)`); phase-64 comment kept + updated to name the parts
|
||||
- Mobile 640px block — `.sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }` (label fills the full-width pill; count pinned at the right edge); comment updated; `#sync-result` clip / aria-labels / other rules untouched
|
||||
- Audit: only base + mobile rules exist; sole stylesheet is `styles.css`; theming is variable-only — B4 byte-identity holds (static CSS, no `ui_settings` dependency)
|
||||
- Updated the existing CSS pin in `tests/unit/test_frontend_sync_upload.py` to the part shape (container props, main/count blocks, mobile fill rule)
|
||||
- Verified layout in Chromium at 390×844 and 1440×900: long path ellipsizes, `(n/m)` fully on-screen at the pill's right edge on mobile; desktop label caps at exactly 16rem; short/idle labels unclipped, hidden count takes no space
|
||||
|
||||
**Tests / lint**
|
||||
- `uv run pytest --cov=app --cov-report=term` → 3024 passed, 0 failed; `app/` TOTAL 99% (>90% gate)
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
|
||||
|
||||
**Docs:** no doc impact (pure CSS layout fix to an existing feature; README/AGENTS.md don't reference the label CSS)
|
||||
|
||||
**Deviations:** none — CSS applied verbatim per task; only the stale mobile-block comment sentence was updated alongside the rule.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/128_sync_label_mobile/03_e2e_mobile_label.md` (the Playwright bounding-box E2E, run in isolation)
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [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 262 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 374 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 81 0 100%
|
||||
app/rag/prompts.py 144 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 49 0 100%
|
||||
app/schemas.py 400 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5133 22 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
All green. Final report:
|
||||
|
||||
**Implemented (task 03 — `tests/e2e/test_sync_label_mobile.py`, new)**
|
||||
- Dedicated mock-only Playwright suite (house pattern from `test_sync_summary_visibility.py`): module SLOW app (port 8164) behind the `slow_llm` proxy (8905, `SLOW_DELAY_S=1.5`), `synclabel/` local source (2 files × `alpha/`+`bravo/` → 3 summary candidates), autouse truncate cleanup
|
||||
- 4 tests: mobile 390×844 import (label in viewport, `(n/4)` count visible & never clipped, `title` untruncated A4) · mobile summaries (`Summarizing folders…` + `(n/3)` visible, settle hides count) · desktop 1280×800 (pre-phase byte-identical string, 256px `min(16rem,40vw)` cap, count pinned) · pre-phase-CSS bug reproduction (permanent pin check)
|
||||
|
||||
**Test / lint / coverage**
|
||||
- `uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov` → 4 passed (in isolation)
|
||||
- Combined with `test_sync_button` + `test_sync_summary_visibility` + `test_sync_upload_progress` → 14 passed
|
||||
- `uv run pytest --cov=app` → 3024 passed, app coverage 99% (>90%) · `uv run ruff check .` clean · `uv run pyright` 0 errors
|
||||
|
||||
**Docs:** no doc impact (no setup/command/API changes; README's sync-button "States" bullet remains accurate)
|
||||
|
||||
**Notable decisions:** added the 4th reproduction test to encode the "mobile tests fail pre-phase" criterion permanently (probe-measured: pre-phase count box 352.9→386.2 fully past the 339.2 clip edge — tests 1–2's pins fail exactly there). Pre-phase CSS injected via CSSOM `insertRule` into the origin stylesheet (A20 CSP blocks `add_style_tag`). Two Playwright quirks handled: object `wait_for_function` results need `.json_value()`; this Chromium build's index-less `insertRule` prepends, so an explicit end index is passed. `_settle` polls the status endpoint for the terminal before the UI settle (informative failures).
|
||||
|
||||
**Next pending task:** phase 128 is complete (01/02/03 done); next is `129_sync_chunk_progress` (00_phase onward, per `todo/` order).
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [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 262 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 374 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 81 0 100%
|
||||
app/rag/prompts.py 144 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 49 0 100%
|
||||
app/schemas.py 400 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5133 22 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
Everything is green — the previous rejection was a reporting hiccup, not a test failure (the suite was already in place and passing). Full re-verification this run:
|
||||
|
||||
**Implemented** — `tests/e2e/test_sync_label_mobile.py` (task 03): mock-only Playwright suite on a SLOW module app (slow-proxy `SLOW_DELAY_S=1.5`, own port) with the `synclabel/` local source (4 files, 3 summary candidates): mobile 390×844 import (label in viewport, `(n/4)` count visible/never clipped, A4 `title` untruncated) · mobile summaries (`Summarizing folders…` + `(n/3)` visible, settle hides count) · desktop 1280×800 (pre-phase byte-identical string, 256 px `min(16rem,40vw)` cap) · pre-phase-CSS reproduction test (permanent pin check — its "count not clipped" pins are exactly what tests 1–2 fail against).
|
||||
|
||||
**Results (this run)**
|
||||
- `uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov` → **4 passed** in isolation
|
||||
- `uv run pytest --cov=app --cov-report=term` → **3024 passed**, app coverage **99%** (>90%)
|
||||
- `uv run ruff check .` → All checks passed · `uv run pyright` → **0 errors, 0 warnings**
|
||||
|
||||
**Docs:** consistency check run (phase final pass) — no impact; README/AGENTS.md remain accurate.
|
||||
|
||||
**Notable decisions:** pre-phase CSS injected via CSSOM `insertRule` with explicit end index into the origin stylesheet (A20 CSP blocks `add_style_tag`; this Chromium build's index-less `insertRule` prepends). `_settle` polls the status endpoint for the terminal first for informative failures.
|
||||
|
||||
**Next pending task:** phase 128 complete → next is `129_sync_chunk_progress` (per `todo/` order).
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [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 262 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 374 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 81 0 100%
|
||||
app/rag/prompts.py 144 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 49 0 100%
|
||||
app/schemas.py 400 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5133 22 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -83,8 +83,13 @@ fresh deployment they seed from `BOR_SUGGESTIONS`.
|
||||
### RAG / Knowledge base (`/sources.html`)
|
||||
|
||||
The indexed document list. The *Path* column opens each document in the same
|
||||
**almost-fullscreen modal**. Admin-only — anonymous visitors see a sign-in
|
||||
gate instead.
|
||||
**almost-fullscreen modal**. Document rows carry a *Created* date and folder
|
||||
rows an *Updated* date (phase 106) — sourced at sync time (git commit dates
|
||||
for git sources, restored metadata for archive uploads, file mtime otherwise;
|
||||
unresolvable or future dates fall back to today) — and the admin can correct a
|
||||
document's date from the document viewer. The date also feeds the small
|
||||
recency boost in retrieval ranking (`BOR_RECENCY_BOOST`). Admin-only —
|
||||
anonymous visitors see a sign-in gate instead.
|
||||
|
||||
### Git sources (`/git-sources.html`)
|
||||
|
||||
@@ -118,7 +123,8 @@ everyone — no login or token required.
|
||||
### Edit doc (`/doc-edit.html`)
|
||||
|
||||
Review and adjust an AI-generated answer before committing it to the docs
|
||||
repository. Admin-only flow page.
|
||||
repository. A draft that should never be pushed can be **Discard**ed from
|
||||
the same page (permanent; admin-only). Admin-only flow page.
|
||||
|
||||
---
|
||||
|
||||
@@ -200,9 +206,15 @@ reflect the new files.
|
||||
so it is safe to run from cron or after every commit.
|
||||
- Indexed formats: **md, markdown, txt, yaml, yml, json, py**, the Podman
|
||||
quadlet family (**container, network, volume, image, pod, kube, swap,
|
||||
os, endpoint**), and **j2** Jinja templates (case-insensitive).
|
||||
os, endpoint**), **j2** Jinja templates — and, **opt-in**, **pdf**
|
||||
(add the `pdf` token to `BOR_IMPORT_EXTENSIONS`; see
|
||||
[PDF documents](#pdf-documents-phase-126)).
|
||||
- Hidden files and common cache dirs (`.venv`, `node_modules`, `.git`,
|
||||
`__pycache__`, etc.) are skipped.
|
||||
`__pycache__`, etc.) are skipped — unless the per-source **Index hidden
|
||||
folders** checkbox on the Git sources page is on for that source
|
||||
(phase 105): then dot-prefixed folders/files with an importable
|
||||
extension are indexed like visible ones, and switching it off prunes
|
||||
them on the next sync.
|
||||
- Non-markdown files get format-aware chunking (YAML keys, JSON keys,
|
||||
Python classes/defs).
|
||||
- With `BOR_IMAGES=1`, standalone images (`png, jpg, jpeg, webp, gif,
|
||||
@@ -227,8 +239,14 @@ BOR_GIT_SOURCES=https://git.reeseapps.com/reese/homelab.git,git@github.com:reese
|
||||
BOR_SOURCES_DIR=~/bor-sources # default; each repo lands in <dir>/<repo-name>/
|
||||
```
|
||||
|
||||
- Auth is whatever the machine supplies — HTTPS via the OS credential
|
||||
helper, or SSH via your key; no credentials are stored in the app.
|
||||
- Public repos need no setup: auth is whatever the machine supplies —
|
||||
HTTPS via the OS credential helper, or SSH via your key.
|
||||
- Private repos: the add/edit form on the Git sources page takes an
|
||||
optional **masked token** field (phase 121). The token is stored in a
|
||||
dedicated DB column and injected into the clone URL only at
|
||||
sync/clone time — it is never returned by any API response or shown
|
||||
in the UI (legacy rows that already embed `user:token@` in the stored
|
||||
URL keep working — the token is sanitized on output).
|
||||
- Every run **clones** (first time, shallow `--depth 1`) or **pulls**
|
||||
(`git pull --ff-only`) each repo, then indexes the checkout.
|
||||
- A failed sync aborts the run — no partial junk.
|
||||
@@ -494,6 +512,33 @@ of its answer:
|
||||
|
||||
---
|
||||
|
||||
## PDF documents (phase 126)
|
||||
|
||||
PDFs are **opt-in**: add the `pdf` token to `BOR_IMPORT_EXTENSIONS`
|
||||
(a stock `.env` never indexes them). Once admitted, a PDF is a
|
||||
first-class document through every entry point (git/local walks and
|
||||
uploaded archives):
|
||||
|
||||
- **Text, page-aware.** Text is extracted page by page (`pymupdf`); one
|
||||
document per PDF, its text-bearing pages joined with `---PAGE-N---`
|
||||
separators. Chunking is page-aware — every chunk keeps a `Page N:`
|
||||
anchor, so a retrieval hit stays readable in context.
|
||||
- **Summarized + dated like any document.** The `lite` model summarizes
|
||||
it, sync sources its date (git commit / file metadata), and it appears
|
||||
in the catalog, the KB tree, and the agent's `ls` / `read` / `grep`
|
||||
tools.
|
||||
- **Embedded images join the image pipeline.** With `BOR_IMAGES=1`,
|
||||
images embedded in the PDF are extracted and run through the same
|
||||
image-document flow as standalone images (chat-model description,
|
||||
persistent copy under `BOR_IMAGE_DIR`, retrievable + displayable);
|
||||
with the toggle off the pass is skipped and the PDF text indexes
|
||||
normally. Embedded images in a format outside `BOR_IMAGE_EXTENSIONS`
|
||||
are skipped with a warning.
|
||||
- **Served as text.** The document viewer renders the extracted text —
|
||||
never the binary PDF.
|
||||
|
||||
---
|
||||
|
||||
## Caching & deploys
|
||||
|
||||
A deploy is a commit — and the browser must see it **without a hard refresh**.
|
||||
@@ -611,7 +656,7 @@ to `.env` and adjust.
|
||||
|
||||
| Env | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `BOR_IMPORT_EXTENSIONS` | A9 family | Importable text formats — the A9 default is `md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2`; any well-formed extension is allowed. Images are **not** added here — see `BOR_IMAGES` |
|
||||
| `BOR_IMPORT_EXTENSIONS` | A9 family | Importable text formats — the A9 default is `md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2`; any well-formed extension is allowed. Add the `pdf` token to make PDFs first-class, retrievable documents (phase 126, opt-in). Images are **not** added here — see `BOR_IMAGES` |
|
||||
| `BOR_GIT_SOURCES` | — (empty) | CSV of git repo URLs — **fallback while the admin Git sources page's list is empty** |
|
||||
| `BOR_SOURCES_DIR` | `~/bor-sources` | Where git repos are cloned/pulled |
|
||||
| `BOR_UPLOAD_DIR` | `~/bor-sources/uploads` | Where uploaded source archives are unpacked |
|
||||
|
||||
+121
-43
@@ -300,6 +300,34 @@
|
||||
* The glyph is static SVG (aria-hidden — the alt text is the
|
||||
* accessible content); the box + img are properties only, never
|
||||
* innerHTML with document-derived data (the house rule).
|
||||
*
|
||||
* Phase 128 (task 01) — the sync label becomes PARTS: the running
|
||||
* label's string is split into a shrinks-to-fit MAIN part (prefix +
|
||||
* the file/folder path — the part CSS may ellipsize) and a pinned
|
||||
* COUNT part ("(n/m)" — the position, NEVER clipped), so on a phone
|
||||
* the file path ellipsizes to fit while the count stays on-screen
|
||||
* (the phase-64 bug: with max-width lifted, the inline-block sized to
|
||||
* its full content and the count ran off-screen). #sync-label now
|
||||
* ships TWO child spans — .sync-label-main (the initial idle text
|
||||
* lives there so the pre-JS label renders) + .sync-label-count
|
||||
* (hidden + empty by default) — and the label builders no longer
|
||||
* return one string: fmtSyncLabel / fmtSyncPhaseLabel return the
|
||||
* PARTS ({ main, count } — the same decision tree, the same byte-
|
||||
* identical copy), and ONE writer — setSyncLabel(mainText,
|
||||
* countText) — lands them: mainText → the main part's textContent,
|
||||
* countText → the count part's textContent with the hidden toggle
|
||||
* (absent count → hidden + empty), then the UNTRUNCATED full label
|
||||
* — mainText + (countText ? " " + countText : "") — onto the button
|
||||
* title + #sync-result (A4 — the screen reader hears the whole
|
||||
* thing; the visible span carries main + count, the space between
|
||||
* them is the CSS gap). Every other #sync-label writer — the idle /
|
||||
* settle labels ("Sync sources", "Synced HH:MM"), the 202/409 click,
|
||||
* the initSyncButton re-attach — goes through setSyncLabel(main)
|
||||
* (count hidden). The full-string shapes are byte-identical to the
|
||||
* pre-task labels ("Syncing…", "Syncing… <file>",
|
||||
* "Syncing… <file> (n/m)", "Importing", "Writing KB overview…",
|
||||
* "Summarizing folders… <folder> (n/m)"); textContent only — no HTML
|
||||
* strings (the house rule).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -598,12 +626,17 @@ export async function mount(root) {
|
||||
* upload the same way; a terminal upload is a no-op there (the
|
||||
* boot-time loadTree() already shows the current catalog).
|
||||
*
|
||||
* Elements: #sync-btn (the button), #sync-label (the text),
|
||||
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
||||
* line), #sync-error-banner / #sync-error-text (error banner).
|
||||
* Elements: #sync-btn (the button), #sync-label (the text —
|
||||
* Phase 128 (task 01): the PARTS — .sync-label-main, the
|
||||
* shrinks-to-fit prefix + file/folder, + .sync-label-count, the
|
||||
* pinned "(n/m)" position (hidden when absent)), #sync-icon (the
|
||||
* spinner icon), #sync-result (aria-live result line),
|
||||
* #sync-error-banner / #sync-error-text (error banner).
|
||||
*/
|
||||
const syncBtn = root.querySelector("#sync-btn");
|
||||
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||||
const syncLabelMain = syncLabel ? syncLabel.querySelector(".sync-label-main") : null;
|
||||
const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null;
|
||||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||||
const syncResult = root.querySelector("#sync-result");
|
||||
const syncErrorBanner = root.querySelector("#sync-error-banner");
|
||||
@@ -632,45 +665,86 @@ export async function mount(root) {
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
|
||||
* "sync" → "Syncing…", "upload" → "Importing" (the background run's
|
||||
* word, A3). The current file — the status endpoint's full
|
||||
* source/relative/path (A4) — is appended while one is being processed;
|
||||
* the BARE prefix shows during the clone/pull (sync), before any file
|
||||
* is indexed. Phase 90: the upload run is unpack + register only (no
|
||||
* scan), so its status never carries a file or counts — the
|
||||
* "Importing" label is always the bare one. The counts appear only
|
||||
* once the import has started (total > 0). CSS ellipsizes the button
|
||||
* label; the same untruncated text goes to the button title +
|
||||
* #sync-result (the aria-live announcer). Phase 98 (task 02): the
|
||||
* SYNC job's post-import phases (overview / summaries) take their
|
||||
* own label via fmtSyncPhaseLabel (below) — this builder keeps that
|
||||
/* Phase 128 (task 01): the label PARTS writer — the SINGLE point
|
||||
* that writes #sync-label. mainText lands in the MAIN part
|
||||
* (.sync-label-main — the shrinks-to-fit prefix + file/folder, the
|
||||
* part CSS ellipsizes); countText lands in the COUNT part
|
||||
* (.sync-label-count — the pinned "(n/m)" position, NEVER clipped):
|
||||
* present → its textContent + hidden=false, absent → emptied +
|
||||
* hidden=true (the count span is hidden whenever there is no
|
||||
* "(n/m)" to show). Then the UNTRUNCATED full label —
|
||||
* mainText + (countText ? " " + countText : "") — rides the button
|
||||
* title + #sync-result (the aria-live announcer) exactly as before
|
||||
* (A4 — the screen reader hears the whole thing; the visible span
|
||||
* carries main + count, the space between them is the CSS gap).
|
||||
* textContent only — the house rule, no HTML strings. Every label
|
||||
* state goes through here (the running builders, the idle / settle
|
||||
* labels, the 202/409 click, the re-attach). */
|
||||
function setSyncLabel(mainText, countText = "") {
|
||||
if (syncLabelMain) syncLabelMain.textContent = mainText;
|
||||
if (syncLabelCount) {
|
||||
if (countText) {
|
||||
syncLabelCount.textContent = countText;
|
||||
syncLabelCount.hidden = false;
|
||||
} else {
|
||||
syncLabelCount.textContent = "";
|
||||
syncLabelCount.hidden = true;
|
||||
}
|
||||
}
|
||||
const full = countText ? `${mainText} ${countText}` : mainText;
|
||||
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
|
||||
}
|
||||
|
||||
/* Phase 64 (task 04) + Phase 128 (task 01, the PARTS rework): the
|
||||
* live-file label. `kind` picks the prefix — "sync" → "Syncing…",
|
||||
* "upload" → "Importing" (the background run's word, A3). The
|
||||
* current file — the status endpoint's full source/relative/path
|
||||
* (A4) — lands in the MAIN part while one is being processed; the
|
||||
* BARE prefix shows during the clone/pull (sync), before any file
|
||||
* is indexed. Phase 90: the upload run is unpack + register only
|
||||
* (no scan), so its status never carries a file or counts — the
|
||||
* "Importing" label is always the bare one. The counts — the PINNED
|
||||
* count part, never clipped (Phase 128) — appear only once the
|
||||
* import has started (total > 0). The builders return the PARTS
|
||||
* ({ main, count }); setSyncLabel writes them (the untruncated full
|
||||
* string — main + " " + count — rides the button title +
|
||||
* #sync-result, A4). Phase 98 (task 02): the SYNC job's
|
||||
* post-import phases (overview / summaries) take their own label
|
||||
* via fmtSyncPhaseLabel (below) — this builder keeps that
|
||||
* prelude/import fall-through byte-identical and serves the UPLOAD
|
||||
* job unchanged (its status has no phase — D2). */
|
||||
function fmtSyncLabel(kind, currentFile, done, total) {
|
||||
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
||||
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
|
||||
if (total > 0) label += ` (${done}/${total})`;
|
||||
return label;
|
||||
const main = currentFile ? `${prefix} ${currentFile}` : prefix;
|
||||
const count = total > 0 ? `(${done}/${total})` : "";
|
||||
return { main, count };
|
||||
}
|
||||
|
||||
/* Phase 98 (task 02, D2): the SYNC job's phase-aware running label
|
||||
* — the post-import span where the file count sits still (the
|
||||
* owner's "the number pauses for a really long time"): "overview"
|
||||
* → "Writing KB overview…"; "summaries" → "Summarizing folders…
|
||||
* <source/folder> (n/m)" — the folder part is omitted while
|
||||
* current_summary is null (the phase's first poll); anything else
|
||||
* (the null prelude, "import") → the phase-64 fmtSyncLabel
|
||||
* fall-through, byte-identical (same builder, same fields, same
|
||||
* order). The UPLOAD job never passes a status (its endpoint has no
|
||||
* phase — D2) and keeps the bare fmtSyncLabel label. The untruncated
|
||||
* result rides the button title + #sync-result like every running
|
||||
* label (A4). */
|
||||
/* Phase 98 (task 02, D2) + Phase 128 (task 01, the PARTS rework):
|
||||
* the SYNC job's phase-aware running label — the post-import span
|
||||
* where the file count sits still (the owner's "the number pauses
|
||||
* for a really long time"): "overview" → "Writing KB overview…"
|
||||
* (no count part); "summaries" → main "Summarizing folders…
|
||||
* <source/folder>" + count "(n/m)" — the folder part is omitted
|
||||
* while current_summary is null (the phase's first poll) and the
|
||||
* COUNT IS ALWAYS EMITTED in this phase (the hook always carries a
|
||||
* total); anything else (the null prelude, "import") → the
|
||||
* phase-64 fmtSyncLabel parts, byte-identical (same builder, same
|
||||
* fields, same order). The UPLOAD job never passes a status (its
|
||||
* endpoint has no phase — D2) and keeps the bare fmtSyncLabel
|
||||
* parts. The untruncated full label rides the button title +
|
||||
* #sync-result like every running label (A4 — setSyncLabel). */
|
||||
function fmtSyncPhaseLabel(status) {
|
||||
if (status.phase === "overview") return "Writing KB overview…";
|
||||
if (status.phase === "overview") {
|
||||
return { main: "Writing KB overview…", count: "" };
|
||||
}
|
||||
if (status.phase === "summaries") {
|
||||
const folder = status.current_summary ? ` ${status.current_summary}` : "";
|
||||
return `Summarizing folders…${folder} (${status.summaries_done}/${status.summaries_total})`;
|
||||
return {
|
||||
main: `Summarizing folders…${folder}`,
|
||||
count: `(${status.summaries_done}/${status.summaries_total})`,
|
||||
};
|
||||
}
|
||||
// null phase (the prelude) or "import" — today's byte-identical
|
||||
// sync label (the phase-64 contract, unchanged).
|
||||
@@ -702,26 +776,26 @@ export async function mount(root) {
|
||||
* is phase-aware (fmtSyncPhaseLabel: "Writing KB overview…" /
|
||||
* "Summarizing folders… <folder> (n/m)"); the UPLOAD job and the
|
||||
* 202/409 click (no status yet) keep the bare fmtSyncLabel label.
|
||||
* Same mechanics as before (disabled, aria-busy, spinning icon, no
|
||||
* is-error) plus: the FULL untruncated label on the button title
|
||||
* (set for EVERY running label — the phase-64 file-only rule is
|
||||
* adjusted for the phase labels) and in #sync-result (the aria-live
|
||||
* announcer reads it; CSS ellipsizes the button's label span only).
|
||||
* Phase 128 (task 01): the builders return the PARTS and the single
|
||||
* writer (setSyncLabel) lands them — the main part (CSS-ellipsized),
|
||||
* the pinned count part (hidden when absent), and the FULL
|
||||
* untruncated label on the button title + #sync-result (A4 — set
|
||||
* for EVERY running label; CSS ellipsizes the main part only). Same
|
||||
* never-stale mechanics as before (disabled, aria-busy, spinning
|
||||
* icon, no is-error).
|
||||
*/
|
||||
function enterSyncRunningState(kind, currentFile, done, total, status) {
|
||||
if (!syncBtn) return;
|
||||
syncBtn.disabled = true;
|
||||
syncBtn.setAttribute("aria-busy", "true");
|
||||
const label =
|
||||
const { main, count } =
|
||||
kind === "sync" && status
|
||||
? fmtSyncPhaseLabel(status)
|
||||
: fmtSyncLabel(kind, currentFile, done, total);
|
||||
syncBtn.title = label; // A4: the untruncated label rides the title, always
|
||||
setSyncLabel(main, count); // the parts + the A4 untruncated title / announcer
|
||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||
syncBtn.classList.remove("is-error");
|
||||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||||
if (syncLabel) syncLabel.textContent = label;
|
||||
if (syncResult) syncResult.textContent = label;
|
||||
}
|
||||
|
||||
function settleSyncButton(label) {
|
||||
@@ -732,7 +806,11 @@ export async function mount(root) {
|
||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||
syncBtn.classList.remove("is-error");
|
||||
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||||
if (syncLabel) syncLabel.textContent = label;
|
||||
// Phase 128 (task 01): the idle / settle labels ("Sync sources",
|
||||
// "Synced HH:MM") go through the parts writer — count hidden
|
||||
// (absent), the label's full string rides the title + #sync-result
|
||||
// (A4, as with every label state).
|
||||
setSyncLabel(label);
|
||||
}
|
||||
|
||||
function showSyncError(detail) {
|
||||
|
||||
@@ -2063,14 +2063,28 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
/* Phase 64: the live-file label ("Syncing… <file> (n/m)" / "Importing
|
||||
<file> (n/m)") — a long source/relative/path ellipsizes inside the
|
||||
pill; the full path lives in the button title + #sync-result (the
|
||||
aria-live announcer). */
|
||||
aria-live announcer). Phase 128 (task 02): the label is a FLEX ROW
|
||||
of two parts — .sync-label-main (the prefix + file/folder: min-width
|
||||
0, the shrinkable item whose ellipsis engages) + .sync-label-count
|
||||
(the pinned "(n/m)": flex 0 0 auto, NEVER clipped — the
|
||||
.sync-result mono language, a position reads as data). */
|
||||
.sync-label {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: min(16rem, 40vw);
|
||||
}
|
||||
.sync-label-main {
|
||||
min-width: 0; /* the flex item that shrinks — what engages the ellipsis */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.sync-label-count {
|
||||
flex: 0 0 auto; /* NEVER clipped — the (n/m) position is the point */
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono); /* the .sync-result mono language (position reads as data) */
|
||||
}
|
||||
/* Last-result announcer — soft ink, small mono */
|
||||
.sync-result {
|
||||
@@ -5313,14 +5327,15 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
target to discover on a phone). The RAG page-head row wraps so
|
||||
the Sync pill drops below the "Knowledge base" title at full
|
||||
width; the History page-head already wraps the pill below its
|
||||
title block. The Sync label's min(16rem, 40vw) cap lifts so the
|
||||
live-file text ellipsizes against the FULL width (min-width: 0
|
||||
lets the nowrap flex item shrink, which is what engages the
|
||||
ellipsis); the refresh glyph joins its visible label. The
|
||||
aria-labels keep the accessible names. */
|
||||
title block. The Sync label's min(16rem, 40vw) cap lifts and the
|
||||
label FILLS the pill (phase 128: flex 1 1 auto) — .sync-label-main
|
||||
ellipsizes against the width left by the pinned .sync-label-count,
|
||||
so the (n/m) stays on-screen at the right edge, never clipped;
|
||||
the refresh glyph joins its visible label. The aria-labels keep
|
||||
the accessible names. */
|
||||
.page-head-row { flex-wrap: wrap; }
|
||||
.sync-btn { width: 100%; }
|
||||
.sync-label { max-width: none; min-width: 0; }
|
||||
.sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }
|
||||
.history-refresh { width: 100%; }
|
||||
.history-refresh svg { display: block; }
|
||||
.sync-result {
|
||||
|
||||
+1
-1
@@ -448,7 +448,7 @@
|
||||
<h1>Knowledge base</h1>
|
||||
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources" hidden>
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
<span class="sync-label" id="sync-label"><span class="sync-label-main">Sync sources</span><span class="sync-label-count" hidden></span></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="page-sub">
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
"""Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync label
|
||||
is fully visible at a phone viewport — the file path ellipsizes to fit
|
||||
and the ``(n/m)`` count span sits in its own non-shrinking part that is
|
||||
NEVER clipped — in BOTH long spans (import + summaries), while desktop
|
||||
keeps the pre-phase strings and the ``min(16rem, 40vw)`` cap.
|
||||
|
||||
The dedicated story suite for ``128_sync_label_mobile`` (A16 — one
|
||||
Playwright file per phase, run in isolation): the owner's report was
|
||||
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
|
||||
string, one ellipsis) whose mobile media query LIFTED the
|
||||
``min(16rem, 40vw)`` cap (``max-width: none``), so the ellipsized tail
|
||||
— where ``(n/m)`` lives — was clipped away on a phone. Phase 128 splits
|
||||
the label into two parts (``.sync-label-main`, the shrinkable
|
||||
prefix + file/folder, + ``.sync-label-count`, the pinned position) so
|
||||
the count is never the ellipsis's sacrifice.
|
||||
|
||||
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
|
||||
|
||||
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 that stretches the run past the UI's 2 s status poll, so the
|
||||
rendered-label assertions are deterministic, not races.
|
||||
|
||||
Timing fixture (the phase-98 sizing rationale, re-sized for this
|
||||
suite): ``SLOW_DELAY_S = 1.5`` — the import span (4 file embeds
|
||||
≈ 6 s) and the summaries span (3 folder summaries ≈ 4.5 s) each
|
||||
outlive the UI's 2 s poll by ≥ 2×, so the page's OWN 2 s poll renders
|
||||
each running label and holds it in the DOM for at least one full poll
|
||||
period (any window ≥ the period catches it). The whole changed sync is
|
||||
≈ 10 LLM calls (2 model probes + 4 file embeds + 1 overview +
|
||||
3 folder summaries) ≈ 15–18 s — well inside the generous settle
|
||||
budgets.
|
||||
|
||||
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
|
||||
(``BOR_LLM_BASE_URL`` = the slow proxy in front of the mock).
|
||||
|
||||
KB fixture — a host temp dir (``tmp_path_factory``; the app runs on
|
||||
the same host) registered as a ``kind=local`` source (the
|
||||
``test_local_directory_sources.py`` / phase-94 API-registration
|
||||
pattern; no git anywhere): ``synclabel/`` with TWO files in ``alpha/``
|
||||
and TWO in ``bravo/`` — the summaries phase then has 3 candidate
|
||||
folders (the source root + the two folders — the recursive-subtree
|
||||
≥ 1 rule), a non-trivial ``(n/m)``.
|
||||
|
||||
Autouse cleanup (the phase-96 pattern): before each test wait for no
|
||||
running sync on the app, then truncate the shared registry + KB
|
||||
tables (documents/chunks/query_log/steering_notes/kb_overview/
|
||||
git_sources/folder_summaries) — each test seeds its own source and
|
||||
runs its own sync; after each test the same truncate.
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule):
|
||||
1. ``test_mobile_label_fits_viewport_with_count_in_import`` — admin
|
||||
page on the sources view at viewport **390×844** (mobile); click
|
||||
**Sync sources**; while the import phase is running (the page's
|
||||
own 2 s poll): the rendered ``#sync-label``'s bounding box is
|
||||
INSIDE the viewport (left ≥ 0, right ≤ 390 — the bug's direct
|
||||
negation), and the ``.sync-label-count`` part is VISIBLE (non-null
|
||||
box) and NOT clipped — its right edge sits at or inside the label's
|
||||
right edge and the button's right edge, with text matching
|
||||
``\\(\\d+/\\d+\\)`` once a file is in flight; ``.sync-label-main``
|
||||
carries ``Syncing… <file>`` — the FULL source/relative/path
|
||||
(visually ellipsized, but the DOM text and the button ``title``
|
||||
are untruncated, A4).
|
||||
2. ``test_mobile_label_shows_summaries_count`` — same mobile
|
||||
viewport; through to the summaries phase: ``.sync-label-main``
|
||||
matches ``Summarizing folders…`` (the folder part optional — the
|
||||
phase's first poll may precede the first hook), the
|
||||
``.sync-label-count`` part matches ``\\(\\d+/3\\)`` and stays
|
||||
visible (same clip pins as test 1); on settle the button leaves
|
||||
the running label (the phase-32 terminal ``Synced HH:MM``) and the
|
||||
count span is hidden + empty.
|
||||
3. ``test_desktop_label_unchanged`` — viewport 1280×800; the same
|
||||
sync: during import the full rendered label text (main + count
|
||||
concatenation) matches ``Syncing… <full source/relative/path>
|
||||
(n/m)`` — the pre-phase byte-identical string — and the label's
|
||||
width respects the ``min(16rem, 40vw)`` cap (bounding box width
|
||||
≤ 256 px + slack); the ``title`` carries the same untruncated
|
||||
string (A4).
|
||||
4. ``test_pre_phase_css_reproduces_the_clip`` — the pin check: the
|
||||
SAME mobile run with the pre-phase-128 ``.sync-label`` rules
|
||||
re-injected (CSSOM ``insertRule`` into the origin's own linked
|
||||
stylesheet — the A20 CSP ``default-src 'self'`` blocks inline
|
||||
``<style>`` injection, while script-driven rule insertion into an
|
||||
allowed stylesheet is CSP-clean; inline-block, the cap lifted, one
|
||||
ellipsis over the whole label, the count riding the ellipsized
|
||||
tail) reproduces the reported bug geometrically — the count part's
|
||||
box extends past the label's clip edge (and the pill's right
|
||||
edge), i.e. the count is painted away. Tests 1–2's "count not
|
||||
clipped" pins fail against exactly this geometry, which is what
|
||||
makes them the regression guard (completion criterion: "the
|
||||
mobile tests fail against the pre-phase markup/CSS").
|
||||
"""
|
||||
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_SYNCLABEL", "8164"))
|
||||
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_SYNCLABEL", "8905"))
|
||||
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
|
||||
|
||||
#: Per-LLM-request delay on the proxy — sized so the import span (4
|
||||
#: file embeds ≈ 6 s) and the summaries span (3 folder summaries
|
||||
#: ≈ 4.5 s) each outlive the UI's 2 s poll period by ≥ 2× (the
|
||||
#: phase-98 rationale): the page's own 2 s poll renders each running
|
||||
#: label and holds it in the DOM for at least one full poll period —
|
||||
#: the rendered-label assertions are deterministic, not races. A
|
||||
#: changed sync is ≈ 10 calls ≈ 15–18 s (see the module docstring).
|
||||
SLOW_DELAY_S = "1.5"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixture constants (deterministic, token-controlled)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: The local source — the temp directory's basename (``kind=local`` →
|
||||
#: the directory's basename is the source name, phase 38).
|
||||
SOURCE = "synclabel"
|
||||
|
||||
#: The two ≥ 2-doc folders (the summaries candidate set = root + these).
|
||||
FOLDER_A = "alpha"
|
||||
FOLDER_B = "bravo"
|
||||
FOLDERS = (FOLDER_A, FOLDER_B)
|
||||
|
||||
N_FILES = 4 # two folders × 2 docs
|
||||
N_CANDIDATES = 3 # the source root + the two folders (the ≥ 1-doc rule)
|
||||
|
||||
#: The four fixture files' full source/relative paths — the A4
|
||||
#: untruncated label's file part (the status endpoint's
|
||||
#: ``current_file`` rides it verbatim).
|
||||
FILE_PATHS = tuple(
|
||||
f"{SOURCE}/{folder}/{folder}-{letter}.md"
|
||||
for folder in FOLDERS
|
||||
for letter in ("a", "b")
|
||||
)
|
||||
FILE_RE = r"synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\.md"
|
||||
|
||||
#: The in-flight import label's PARTS (the phase-64 byte-identical
|
||||
#: strings, unit-pinned in tests/unit/test_frontend_sync_upload.py):
|
||||
#: main "Syncing… <full source/relative/path>" + count "(n/4)" —
|
||||
#: ``done`` starts at 1 (the importer counts the file as current
|
||||
#: BEFORE indexing it), so ``n`` ∈ 1..4.
|
||||
IMPORT_MAIN_RE = re.compile(rf"^Syncing… {FILE_RE}$")
|
||||
IMPORT_COUNT_RE = re.compile(r"^\([1-4]/4\)$")
|
||||
#: The pre-phase byte-identical FULL string (main + " " + count — the
|
||||
#: single span's text before phase 128; the A4 title keeps it).
|
||||
IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)$")
|
||||
|
||||
#: The summaries label's PARTS (phase 98 task 02, unit-pinned): main
|
||||
#: "Summarizing folders… [source/folder]" (the folder part omitted
|
||||
#: while ``current_summary`` is null) + count "(n/3)".
|
||||
SUMMARY_MAIN_RE = re.compile(r"^Summarizing folders…(?: \S+)?$")
|
||||
SUMMARY_COUNT_RE = re.compile(r"^\(\d+/3\)$")
|
||||
|
||||
#: "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 phase-32
|
||||
#: boot re-attach renders the last result).
|
||||
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)
|
||||
#: and the desktop (the unchanged-contract leg).
|
||||
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
|
||||
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
|
||||
#: ≈ 15–18 s; the UI's 2 s poll settles at most one tick after the
|
||||
#: terminal state lands.
|
||||
SETTLE_TIMEOUT_MS = 90_000
|
||||
|
||||
|
||||
def _md(title: str, body: str) -> str:
|
||||
return f"# {title}\n\n{body}\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 geometry snapshot once it is present: the part texts, the
|
||||
# button title, and the getBoundingClientRect boxes of the label, the
|
||||
# count part, and the button — read in the SAME in-browser evaluation
|
||||
# that verified the label state, so the numbers can never straddle a
|
||||
# 2 s poll tick (the parts + the title are written together in one
|
||||
# JS task by setSyncLabel).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Test 1/3 — the in-flight IMPORT label: main carries "Syncing…
|
||||
#: <full source/relative/path>" (the untruncated A4 text — the visual
|
||||
#: ellipsis never touches textContent), the count part is VISIBLE
|
||||
#: (not ``hidden``) with the "(n/4)" position, and the button title
|
||||
#: carries the full untruncated label (main + " " + count, A4).
|
||||
_JS_IMPORT_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 = /^Syncing… synclabel\\/(?:alpha|bravo)\\/(?:alpha|bravo)-[ab]\\.md$/;
|
||||
const reCount = /^\\([1-4]\\/4\\)$/;
|
||||
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),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
#: 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def synclabel_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The one-source temp tree (see the module docstring): the app
|
||||
server runs on the same host, so the paths are visible to it.
|
||||
The directory NAME is the source name (``kind=local``, phase
|
||||
38)."""
|
||||
root = tmp_path_factory.mktemp("bor_synclabel")
|
||||
src = root / SOURCE
|
||||
for folder in FOLDERS:
|
||||
(src / folder).mkdir(parents=True)
|
||||
for letter in ("a", "b"):
|
||||
(src / folder / f"{folder}-{letter}.md").write_text(
|
||||
_md(
|
||||
f"Synclabel {folder.title()} {letter.upper()}",
|
||||
f"Synclabel {folder} fixture note {letter.upper()}: "
|
||||
f"covers topic {letter.upper()} of the {SOURCE} source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert len(list(src.rglob("*.md"))) == N_FILES
|
||||
return src
|
||||
|
||||
|
||||
def _app_env(base_url: str, tmp_path_factory: pytest.TempPathFactory) -> dict[str, str]:
|
||||
"""The per-module app env (the conftest pattern, cf.
|
||||
``test_oneshot_llm_retry.py`` / ``test_kb_tree.py``): the
|
||||
``base_url`` picks the LLM (the slow proxy in front of the mock),
|
||||
``BOR_GIT_SOURCES`` forced empty (the sync source is this suite's
|
||||
own DB-registered local row), and the leak-guarded code defaults."""
|
||||
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 threshold (conftest pattern): this suite never
|
||||
# asks the chat model anything — the gate is never on a path.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
# 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 repo's .env file carries the owner's BOR_GIT_SOURCES (the app
|
||||
# reads it from cwd) — override it with an EMPTY value (the env var
|
||||
# beats the .env file): the registry must hold EXACTLY the local
|
||||
# directory this suite registers.
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
# 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
|
||||
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
|
||||
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: the rendered running labels need the import/summaries spans
|
||||
to outlive the UI's 2 s poll (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,
|
||||
synclabel_dir: 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 — see
|
||||
the module docstring). ``synclabel_dir`` is a dependency only for
|
||||
the fixture ordering (the temp tree exists before the first test).
|
||||
"""
|
||||
env = _app_env(f"{SLOW_URL}/v1", 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 or document would
|
||||
pollute the candidate count and the counts the syncs and the label
|
||||
assertions pin 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
|
||||
if r.json()["state"] != "running":
|
||||
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 seeds its own source and runs its own sync, so the KB
|
||||
rows + folder_summaries rows of the temp 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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_source(base_url: str, path: str) -> None:
|
||||
"""Register the temp directory through the authenticated API (the
|
||||
``test_local_directory_sources.py`` / phase-94 pattern — a plain
|
||||
directory is an API/DB-only operation)."""
|
||||
with httpx.Client(base_url=base_url, timeout=30.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
r = client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": path}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
|
||||
def _start_sync(page: Page, app_url: str, sync_dir: Path) -> None:
|
||||
"""Register the source, land the admin on the sources view, wait
|
||||
for the settled boot label (the phase-64 pattern — also proves the
|
||||
phase-32 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 bare "Syncing…" — the count
|
||||
part hidden, the phase-64 contract)."""
|
||||
_register_source(app_url, str(sync_dir))
|
||||
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): disabled, aria-busy, spinning icon, and
|
||||
# the label's MAIN part alone (the count part is hidden — its text
|
||||
# is empty, so the parts' concatenation is exactly "Syncing…").
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_attribute("aria-busy", "true")
|
||||
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-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 run reaches a SUCCESS terminal
|
||||
at the endpoint, then the button shows "Synced HH:MM" (the count
|
||||
part hidden + empty — the settle goes through the parts writer),
|
||||
the button re-enabled, the running state fully cleared, 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")
|
||||
count = page.locator(".sync-label-count")
|
||||
expect(count).to_be_hidden()
|
||||
expect(count).to_have_text("")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
|
||||
def _assert_count_not_clipped(snap: dict[str, Any], viewport_w: int) -> None:
|
||||
"""The phase 128 geometry contract for a rendered running label at
|
||||
the given viewport: the label box is INSIDE the viewport (the bug's
|
||||
direct negation), the count part is VISIBLE (a non-degenerate box),
|
||||
and the count is NOT clipped — its right edge sits at or inside
|
||||
the label's right edge (the label's own clip boundary) and the
|
||||
button's right edge (the pill). The 1 px slack covers float
|
||||
rounding only — the layout nests the boxes exactly."""
|
||||
label, count, btn = snap["labelRect"], snap["countRect"], snap["btnRect"]
|
||||
assert label["left"] >= 0, f"label runs off the left edge: {label}"
|
||||
assert label["right"] <= viewport_w, (
|
||||
f"label runs off the right edge of the {viewport_w}px viewport: {label}"
|
||||
)
|
||||
assert count["width"] > 0 and count["height"] > 0, (
|
||||
f"the count part has no visible box: {count}"
|
||||
)
|
||||
assert count["left"] >= label["left"] - 1, (
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Mobile (390×844) — import: the label fits the viewport and the
|
||||
# (n/m) count is visible, never clipped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mobile_label_fits_viewport_with_count_in_import(
|
||||
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
|
||||
) -> None:
|
||||
"""A fresh admin page on the sources view at a PHONE viewport
|
||||
(390×844) clicks **Sync sources**; while the import phase is
|
||||
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``
|
||||
is an atomic snapshot of: the label box INSIDE the viewport (left ≥
|
||||
0, right ≤ 390 — the bug's direct negation), the ``.sync-label-count``
|
||||
part VISIBLE with its "(n/4)" position and its right edge at or
|
||||
inside the label's and the button's right edges (NEVER clipped —
|
||||
the pre-phase geometry fails exactly these pins, test 4
|
||||
reproduces it), and the ``.sync-label-main`` part carrying
|
||||
``Syncing… <full source/relative/path>`` — the file part may be
|
||||
VISUALLY ellipsized, but the DOM text is untruncated and the button
|
||||
``title`` carries the FULL label (main + " " + count, A4). The run
|
||||
then settles (the phase-32 terminal shape)."""
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
_start_sync(page, app_url, synclabel_dir)
|
||||
|
||||
# The page's own 2 s poll renders the in-flight import label; the
|
||||
# atomic in-browser snapshot pins its text + geometry together.
|
||||
# (The Playwright sync API wraps a non-primitive result in a
|
||||
# JSHandle — .json_value() unwraps the snapshot dict.)
|
||||
snap = page.wait_for_function(
|
||||
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
# The parts (the phase-64 byte-identical strings, unit-pinned):
|
||||
# main = "Syncing… <full source/relative/path>" (untruncated — the
|
||||
# visual ellipsis never touches textContent), count = "(n/4)".
|
||||
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
||||
assert IMPORT_COUNT_RE.match(snap["countText"]), snap["countText"]
|
||||
# A4: the button title carries the UNTRUNCATED label — the full
|
||||
# path, not the ellipsized visual tail (the in-browser check
|
||||
# already required title == main + " " + count; pin it here too).
|
||||
assert snap["title"] == snap["mainText"] + " " + snap["countText"], 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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Mobile (390×844) — summaries: the folder span's (n/3) count stays
|
||||
# visible through the long span; the settle hides the count part
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mobile_label_shows_summaries_count(
|
||||
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
|
||||
) -> None:
|
||||
"""Same phone viewport; the sync runs through to the SUMMARIES
|
||||
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
|
||||
``.sync-label-main`` as ``Summarizing folders… [source/folder]``
|
||||
(the folder part optional — the phase's first poll may precede the
|
||||
first hook) with the VISIBLE ``.sync-label-count`` part matching
|
||||
``(n/3)`` (the 3 candidate folders) — never clipped (the same pins
|
||||
as test 1) — and the title carrying the full untruncated label
|
||||
(A4). On settle the button leaves the running label (the phase-32
|
||||
terminal ``Synced HH:MM``) and the count span is hidden + empty."""
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
_start_sync(page, app_url, synclabel_dir)
|
||||
|
||||
snap = page.wait_for_function(
|
||||
_JS_SUMMARY_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
assert SUMMARY_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
||||
assert SUMMARY_COUNT_RE.match(snap["countText"]), snap["countText"]
|
||||
assert snap["title"] == snap["mainText"] + " " + snap["countText"], snap
|
||||
_assert_count_not_clipped(snap, MOBILE_VIEWPORT["width"])
|
||||
|
||||
_settle(page, app_url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Desktop (1280×800) — unchanged: the pre-phase byte-identical
|
||||
# string, the min(16rem, 40vw) cap, the title's untruncated label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_desktop_label_unchanged(
|
||||
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
|
||||
) -> None:
|
||||
"""A DESKTOP viewport (1280×800) keeps the pre-phase contract:
|
||||
during import the FULL rendered label text — the main + count
|
||||
parts' concatenation (main + " " + count, the CSS gap standing in
|
||||
for the old single span's space) — matches ``Syncing… <full
|
||||
source/relative/path> (n/m)``: the pre-phase byte-identical string
|
||||
(the parts rework changed the output SHAPE, not the text). The
|
||||
label's width respects the ``min(16rem, 40vw)`` cap — 256 px at
|
||||
this viewport (16 px :root) — with the count part pinned at the
|
||||
right edge and the main part ellipsized (the same cap/ellipsis the
|
||||
pre-phase label had), and the button ``title`` carries the same
|
||||
untruncated string (A4)."""
|
||||
page.set_viewport_size(DESKTOP_VIEWPORT)
|
||||
_start_sync(page, app_url, synclabel_dir)
|
||||
|
||||
snap = page.wait_for_function(
|
||||
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
# The pre-phase byte-identical FULL string (main + " " + count):
|
||||
# "Syncing… <full source/relative/path> (n/m)".
|
||||
full = snap["mainText"] + " " + snap["countText"]
|
||||
assert IMPORT_FULL_RE.match(full), full
|
||||
assert snap["title"] == full, snap
|
||||
# The count part is visible and pinned inside the label (no
|
||||
# clipping on desktop either — the cap ellipsizes the MAIN part
|
||||
# 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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The pin check — the pre-phase CSS reproduces the clip (the count
|
||||
# rides the ellipsized tail and is painted away on a phone)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pre_phase_css_reproduces_the_clip(
|
||||
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
|
||||
) -> None:
|
||||
"""The same mobile run with the PRE-PHASE-128 ``.sync-label`` rules
|
||||
re-injected (CSSOM ``insertRule`` into the origin's own linked
|
||||
stylesheet — inline-block, the cap lifted, one ellipsis over the
|
||||
whole label, the count riding the ellipsized tail): 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
|
||||
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
|
||||
regression sensitivity, made permanent (the completion criterion:
|
||||
the mobile tests fail against the pre-phase markup/CSS)."""
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
_start_sync(page, app_url, synclabel_dir)
|
||||
# Re-inject the pre-phase geometry BEFORE the running label
|
||||
# renders (the boot settled under the real CSS — the override only
|
||||
# concerns the in-flight label the 2 s poll is about to write).
|
||||
# CSSOM rule insertion into the allowed origin stylesheet is the
|
||||
# CSP-clean path (the A20 ``default-src 'self'`` blocks inline
|
||||
# ``<style>`` injection — add_style_tag would be refused).
|
||||
page.evaluate(
|
||||
"""(rules) => {
|
||||
for (const sheet of document.styleSheets) {
|
||||
if (!sheet.href || !sheet.href.includes('styles.css')) continue;
|
||||
// Explicit END index: this Chromium build's
|
||||
// index-less insertRule PREPENDS (the CSSOM spec says
|
||||
// append) — at the front the override would lose every
|
||||
// same-specificity conflict to the base rules.
|
||||
for (const rule of rules) sheet.insertRule(rule, sheet.cssRules.length);
|
||||
}
|
||||
}""",
|
||||
list(_PRE_PHASE_SYNC_RULES),
|
||||
)
|
||||
|
||||
snap = page.wait_for_function(
|
||||
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
||||
assert IMPORT_COUNT_RE.match(snap["countText"]), snap["countText"]
|
||||
label, count, btn = snap["labelRect"], snap["countRect"], snap["btnRect"]
|
||||
# The clip: the count's box runs past the label's clip edge — the
|
||||
# label's overflow: hidden paints it away (the probe measured the
|
||||
# count FULLY past the edge at 390 px with the repo's fonts — the
|
||||
# "(n/m) doesn't show up on mobile" report).
|
||||
assert count["right"] > label["right"] + 1, (
|
||||
f"pre-phase CSS did not clip the count — the pin would be vacuous: "
|
||||
f"label={label} count={count}"
|
||||
)
|
||||
# …and it runs off the pill's right edge (toward the viewport's
|
||||
# edge — the "off-screen" half of the report).
|
||||
assert count["right"] > btn["right"] + 1, (
|
||||
f"pre-phase CSS kept the count inside the pill: btn={btn} count={count}"
|
||||
)
|
||||
|
||||
# Let the run finish (the override only shaped the rendered
|
||||
# label — the status endpoint + the settle contract are
|
||||
# CSS-agnostic); the next test truncates + re-seeds.
|
||||
_settle(page, app_url)
|
||||
@@ -71,12 +71,13 @@ Test → observable mapping (Playwright Mapping Rule):
|
||||
summaries_total == 3`` (the keep-final-counts convention).
|
||||
2. ``test_sync_button_names_the_summary_phase`` (UI — the slow leg):
|
||||
a fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
||||
the page's OWN 2 s poll renders ``#sync-label`` matching
|
||||
``Summarizing folders… [folder] (n/3)`` (the asserted text is the
|
||||
RENDERED DOM label, with the button ``title`` riding the same
|
||||
untruncated value — A4); on settle the button reads the terminal
|
||||
``Synced HH:MM`` (the phase-32 contract) and ``#sync-result``
|
||||
carries the counts line.
|
||||
the page's OWN 2 s poll renders ``#sync-label`` as the D2 PARTS
|
||||
(phase 128: the main part ``Summarizing folders… [folder]`` + the
|
||||
pinned count part ``(n/3)`` — the asserted text is the RENDERED
|
||||
DOM parts, with the button ``title`` riding the full untruncated
|
||||
string, main + " " + count — A4); on settle the button reads the
|
||||
terminal ``Synced HH:MM`` (the phase-32 contract) and
|
||||
``#sync-result`` carries the counts line.
|
||||
3. ``test_missing_folder_summaries_read_as_pending_and_self_heal``
|
||||
(tree — the fast mock leg): after a successful sync (every
|
||||
candidate stores its canned line) NO marker is anywhere; DELETE
|
||||
@@ -727,13 +728,15 @@ def test_sync_button_names_the_summary_phase(
|
||||
) -> None:
|
||||
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
||||
the page's OWN 2 s poll renders ``#sync-label`` as
|
||||
``Summarizing folders… [source/folder] (n/3)`` (asserted on the
|
||||
RENDERED text — the label is built by the page JS — with the
|
||||
button ``title`` carrying the same untruncated value, A4); on
|
||||
settle the button shows the terminal ``Synced HH:MM`` (the
|
||||
phase-32 contract) and ``#sync-result`` carries the counts line.
|
||||
The prelude/import labels stay the phase-64 shape (the endpoint
|
||||
test pins those phases; the UI pins the new summaries label)."""
|
||||
``Summarizing folders… [source/folder]`` + the pinned ``(n/3)``
|
||||
count span (phase 128: the label is PARTS — the folder part is
|
||||
optional — the phase's first poll may precede the first hook — and
|
||||
the button ``title`` carries the full untruncated string, main +
|
||||
" " + count, A4); on settle the button shows the terminal
|
||||
``Synced HH:MM`` (the phase-32 contract) and ``#sync-result``
|
||||
carries the counts line. The prelude/import labels stay the
|
||||
phase-64 shape (the endpoint test pins those phases; the UI pins
|
||||
the new summaries label)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_register_source(app_url, str(syncsum_dir))
|
||||
login(page, app_url) # lands on /sources.html (the button's home)
|
||||
@@ -764,22 +767,30 @@ def test_sync_button_names_the_summary_phase(
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
# UI layer (D2): the page's own 2 s poll names the summaries phase
|
||||
# — the RENDERED label matches the D2 shape (the folder part is
|
||||
# optional — the phase's first poll may precede the first hook)
|
||||
# with the (n/3) counts, and the button ``title`` carries the same
|
||||
# untruncated value (A4). ``wait_for_function`` checks BOTH in ONE
|
||||
# in-browser evaluation: a poll tick updates label + title together
|
||||
# in a single JS task, so the atomic check can never straddle one
|
||||
# (the JS regex is the D2 shape, unit-pinned in
|
||||
# tests/unit/test_frontend_sync_upload.py).
|
||||
# — the RENDERED label matches the D2 PARTS shape (phase 128:
|
||||
# the MAIN part carries "Summarizing folders… [folder]" — the
|
||||
# folder part is optional, the phase's first poll may precede the
|
||||
# first hook — and the pinned COUNT part carries the (n/3)
|
||||
# counts), and the button ``title`` carries the full untruncated
|
||||
# string (main + " " + count, A4). ``wait_for_function`` checks
|
||||
# BOTH in ONE in-browser evaluation: a poll tick updates the parts
|
||||
# + the title together in a single JS task, so the atomic check
|
||||
# can never straddle one (the JS regexes are the D2 shapes,
|
||||
# unit-pinned in tests/unit/test_frontend_sync_upload.py).
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const label = document.querySelector('#sync-label');
|
||||
const btn = document.querySelector('#sync-btn');
|
||||
if (!label || !btn) return false;
|
||||
const text = label.textContent;
|
||||
const re = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
|
||||
return re.test(text) && btn.getAttribute('title') === text;
|
||||
const main = label.querySelector('.sync-label-main');
|
||||
const count = label.querySelector('.sync-label-count');
|
||||
if (!main || !count || count.hidden) return false;
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -604,11 +604,25 @@ def test_upload_toast_then_navigate_away(
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
# UI layer: the label gains the live file at the page's 2 s poll
|
||||
# tick ("Syncing… <source/relative/path> (n/m)").
|
||||
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT
|
||||
# span (phase 128: the space between them is the CSS gap, so the
|
||||
# rendered concatenation carries none — the FULL string with the
|
||||
# space rides the button title, A4).
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
re.compile(rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md\(\d+/{N_FILES}\)"),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own
|
||||
# visible count span, and the button title carries the full
|
||||
# untruncated string (main + " " + count, A4).
|
||||
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
|
||||
timeout=SETTLE_TIMEOUT_MS
|
||||
)
|
||||
expect(btn).to_have_attribute(
|
||||
"title",
|
||||
re.compile(rf"^Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)$"),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
# Deterministic layer: the recorder's full tick series — file-less
|
||||
# model-check ticks first, then the per-file ticks (full
|
||||
@@ -774,11 +788,25 @@ def test_sync_live_file_label(
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
# UI layer: the label gains the live file at the page's 2 s poll
|
||||
# tick ("Syncing… <source/relative/path> (n/m)").
|
||||
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT
|
||||
# span (phase 128: the space between them is the CSS gap, so the
|
||||
# rendered concatenation carries none — the FULL string with the
|
||||
# space rides the button title, A4).
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
re.compile(rf"Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md\(\d+/{N_FILES}\)"),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own
|
||||
# visible count span, and the button title carries the full
|
||||
# untruncated string (main + " " + count, A4).
|
||||
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
|
||||
timeout=SETTLE_TIMEOUT_MS
|
||||
)
|
||||
expect(btn).to_have_attribute(
|
||||
"title",
|
||||
re.compile(rf"^Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)$"),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
# Deterministic layer: the recorder's full tick series — file-less
|
||||
# model-check ticks first, then the per-file ticks (full
|
||||
|
||||
@@ -46,6 +46,23 @@ pass the WHOLE status; the upload job and the 202/409 click stay on
|
||||
the bare label — the upload status has no phase) and the A4 title-rule
|
||||
adjustment (the full untruncated label rides the button title for
|
||||
EVERY running label — the file-only rule is gone).
|
||||
|
||||
Phase 128 (task 01) re-points the label pins to the PARTS shape: the
|
||||
label's string is split into a shrinks-to-fit MAIN part (prefix +
|
||||
file/folder — the part CSS may ellipsize) and a pinned COUNT part
|
||||
("(n/m)" — NEVER clipped, hidden whenever absent), so on a phone the
|
||||
file path ellipsizes while the position stays on-screen. The builders
|
||||
no longer return one string — they return the parts ({ main, count },
|
||||
the same decision tree, the same byte-identical copy) — and ONE
|
||||
writer, setSyncLabel(mainText, countText), lands them: mainText →
|
||||
.sync-label-main's textContent, countText → .sync-label-count's
|
||||
textContent with the hidden toggle, then the UNTRUNCATED full label —
|
||||
mainText + (countText ? " " + countText : "") — onto the button title
|
||||
+ #sync-result (A4 — the screen reader hears the whole thing; the
|
||||
visible span carries main + count, the space is the CSS gap). Every
|
||||
other label state (idle "Sync sources", settle "Synced HH:MM", the
|
||||
202/409 click, the re-attach) goes through setSyncLabel(main) —
|
||||
count hidden.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -159,18 +176,22 @@ def _tick(js: str) -> str:
|
||||
return js[tick:end]
|
||||
|
||||
|
||||
# ---------- fmtSyncLabel: the live-file label contract ----------
|
||||
# ---------- fmtSyncLabel: the live-file label PARTS contract ----------
|
||||
|
||||
|
||||
def test_fmt_sync_label_signature_and_prefixes() -> None:
|
||||
"""fmtSyncLabel(kind, currentFile, done, total): `kind` picks the
|
||||
prefix — "upload" → "Importing" (the background run's word, A3),
|
||||
anything else → "Syncing…". The file is appended only when present
|
||||
(sync: the bare prefix shows during clone/pull, before any file is
|
||||
indexed — A4; phase 90: the upload run never carries a file or
|
||||
counts, so its label is always the bare "Importing…"); the counts
|
||||
are appended only when total > 0 (the import has started); file
|
||||
before counts."""
|
||||
def test_fmt_sync_label_returns_the_parts_contract() -> None:
|
||||
"""fmtSyncLabel(kind, currentFile, done, total) — Phase 128
|
||||
(task 01): the builder returns the PARTS, not one string. `kind`
|
||||
picks the prefix — "upload" → "Importing" (the background run's
|
||||
word, A3), anything else → "Syncing…". The MAIN part — the
|
||||
prefix, the file appended only when present (sync: the bare
|
||||
prefix shows during clone/pull, before any file is indexed — A4;
|
||||
phase 90: the upload run never carries a file or counts, so its
|
||||
label is always the bare "Importing"); the COUNT part — only when
|
||||
total > 0 (the import has started), NO join space (the space is
|
||||
the CSS gap between the parts — the full string's space is
|
||||
re-joined by setSyncLabel for the title / announcer, A4). File
|
||||
before counts in the full-string order."""
|
||||
body = _fn(_js(), "fmtSyncLabel")
|
||||
assert "function fmtSyncLabel(kind, currentFile, done, total)" in body
|
||||
assert 'kind === "upload"' in body
|
||||
@@ -180,31 +201,74 @@ def test_fmt_sync_label_signature_and_prefixes() -> None:
|
||||
i_importing = body.find('"Importing"')
|
||||
i_syncing = body.find('"Syncing…"')
|
||||
assert -1 < i_kind < i_importing < i_syncing
|
||||
# file appended only when present
|
||||
# the MAIN part: the file appended only when present
|
||||
assert "currentFile ?" in body
|
||||
assert "`${prefix} ${currentFile}`" in body
|
||||
# counts only when the import has started (total > 0)
|
||||
# the COUNT part: only when the import has started (total > 0),
|
||||
# no join space (the parts are joined by setSyncLabel, A4)
|
||||
assert "total > 0" in body
|
||||
assert "`(${done}/${total})`" in body
|
||||
assert "` (${done}/${total})`" not in body, ("no space in the count part")
|
||||
i_file = body.find("currentFile ?")
|
||||
i_counts = body.find("total > 0")
|
||||
assert -1 < i_file < i_counts, "the file lands before the counts"
|
||||
# the parts return (the byte-identical full strings hold when
|
||||
# setSyncLabel joins them: "Syncing…", "Syncing… <file>",
|
||||
# "Syncing… <file> (n/m)", "Importing")
|
||||
assert "return { main, count }" in body
|
||||
|
||||
|
||||
# ---------- enterSyncRunningState: full path to title + announcer ----------
|
||||
def test_set_sync_label_is_the_single_parts_writer() -> None:
|
||||
"""setSyncLabel(mainText, countText = "") — Phase 128 (task 01):
|
||||
the SINGLE point that writes #sync-label. The MAIN part gets
|
||||
mainText (textContent only — the house rule, no HTML strings);
|
||||
the COUNT part gets countText when present (textContent +
|
||||
hidden=false — the "(n/m)" position is visible, NEVER clipped) and
|
||||
is emptied + hidden=true when absent (the count span is hidden
|
||||
whenever there is no "(n/m)" to show). Then the UNTRUNCATED full
|
||||
label — mainText + (countText ? " " + countText : "") — rides the
|
||||
button title + #sync-result (the aria-live announcer) exactly as
|
||||
before (A4 — the screen reader hears the whole thing)."""
|
||||
body = _fn(_js(), "setSyncLabel")
|
||||
assert 'function setSyncLabel(mainText, countText = "")' in body
|
||||
# the parts: textContent only (the house rule), null-guarded refs
|
||||
assert "syncLabelMain.textContent = mainText" in body
|
||||
assert "syncLabelCount.textContent = countText" in body
|
||||
# present count → visible; absent count → hidden, emptied
|
||||
assert "syncLabelCount.hidden = false" in body
|
||||
assert 'syncLabelCount.textContent = ""' in body
|
||||
assert "syncLabelCount.hidden = true" in body
|
||||
i_vis = body.find("syncLabelCount.hidden = false")
|
||||
i_hid = body.find("syncLabelCount.hidden = true")
|
||||
assert -1 < i_vis < i_hid, "the present-count branch precedes the absent one"
|
||||
# A4: the untruncated full label rides the title + the announcer —
|
||||
# main + " " + count (the join space), bare main when no count.
|
||||
assert (
|
||||
'const full = countText ? `${mainText} ${countText}` : mainText'
|
||||
) in body, "the full label is main + ' ' + count (bare main when absent)"
|
||||
i_full = body.find("const full")
|
||||
i_title = body.find("syncBtn.title = full")
|
||||
i_result = body.find("syncResult.textContent = full")
|
||||
assert -1 < i_full < i_title < i_result, ("built once: full → title → announcer")
|
||||
assert "innerHTML" not in body, "the label path is textContent only"
|
||||
|
||||
|
||||
def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
|
||||
# ---------- enterSyncRunningState: the parts + the A4 writer ----------
|
||||
|
||||
|
||||
def test_fmt_sync_phase_label_is_the_exact_d2_parts_contract() -> None:
|
||||
"""fmtSyncPhaseLabel(status) — the SYNC job's phase-aware label
|
||||
(phase 98 task 02, D2): "overview" → exactly "Writing KB
|
||||
overview…" (no folder, no counts); "summaries" →
|
||||
"Summarizing folders… <current_summary> (n/m)" — the folder part
|
||||
(one leading space + current_summary) is omitted while
|
||||
current_summary is null (the phase's first poll → the bare
|
||||
"Summarizing folders… (n/m)"); anything else (the null prelude,
|
||||
"import") → the phase-64 fmtSyncLabel fall-through, byte-identical
|
||||
(same builder, same fields, same order). The UPLOAD job never
|
||||
enters here — its status has no phase (D2)."""
|
||||
(phase 98 task 02, D2; phase 128 task 01: the PARTS). "overview"
|
||||
→ main exactly "Writing KB overview…" (no folder, no count part);
|
||||
"summaries" → main "Summarizing folders… <current_summary>" +
|
||||
count "(n/m)" — the folder part (one leading space +
|
||||
current_summary) is omitted while current_summary is null (the
|
||||
phase's first poll → the bare "Summarizing folders…" main), and
|
||||
the COUNT IS ALWAYS EMITTED in this phase (the hook always carries
|
||||
a total); anything else (the null prelude, "import") → the
|
||||
phase-64 fmtSyncLabel parts, byte-identical (same builder, same
|
||||
fields, same order). The UPLOAD job never enters here — its status
|
||||
has no phase (D2)."""
|
||||
body = _fn(_js(), "fmtSyncPhaseLabel")
|
||||
assert "function fmtSyncPhaseLabel(status)" in body
|
||||
# "overview": the exact copy, no folder, no counts.
|
||||
@@ -212,7 +276,8 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
|
||||
i_sum = body.find('status.phase === "summaries"')
|
||||
assert -1 < i_ov < i_sum
|
||||
ov = body[i_ov:i_sum]
|
||||
assert 'return "Writing KB overview…";' in ov
|
||||
assert 'main: "Writing KB overview…"' in ov
|
||||
assert 'count: ""' in ov, "the overview label carries no count part"
|
||||
assert "current_summary" not in ov and "summaries_done" not in ov, (
|
||||
"the overview label carries no folder and no counts"
|
||||
)
|
||||
@@ -223,10 +288,13 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
|
||||
assert 'status.current_summary ? ` ${status.current_summary}` : ""' in sum_, (
|
||||
"the folder part — one leading space + the folder, or nothing"
|
||||
)
|
||||
assert "main: `Summarizing folders…${folder}`" in sum_, (
|
||||
"the exact D2 main copy (with and without the folder)"
|
||||
)
|
||||
assert (
|
||||
"return `Summarizing folders…${folder} "
|
||||
"(${status.summaries_done}/${status.summaries_total})`;"
|
||||
) in sum_, "the exact D2 copy (with and without the folder)"
|
||||
"count: `(${status.summaries_done}/${status.summaries_total})`"
|
||||
) in sum_, "the count part is ALWAYS emitted in the summaries phase"
|
||||
assert "total > 0" not in sum_, ("no conditional — the hook always carries a total")
|
||||
# anything else (null prelude, "import"): byte-identical to today's
|
||||
# sync label — same builder, same fields, same order.
|
||||
assert (
|
||||
@@ -235,18 +303,19 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
|
||||
) in body[i_fall:]
|
||||
|
||||
|
||||
def test_enter_running_state_is_phase_aware_and_announces_full_label() -> None:
|
||||
def test_enter_running_state_is_phase_aware_and_writes_the_parts() -> None:
|
||||
"""The running-state entry keeps the §7.4 never-stale mechanics
|
||||
(disabled, aria-busy, spinning icon, the stale .is-error removed)
|
||||
and — phase 98 (task 02, D2) — is phase-aware for the SYNC job:
|
||||
a sync-kind call carrying the status object takes the
|
||||
fmtSyncPhaseLabel form, while the upload job (no status — its
|
||||
fmtSyncPhaseLabel parts, while the upload job (no status — its
|
||||
endpoint has no phase) and the 202/409 click keep the bare
|
||||
fmtSyncLabel label, byte-identical. The FULL untruncated label —
|
||||
whatever its form — rides the button title (set for EVERY running
|
||||
label: the phase-64 "title only when there is a current file"
|
||||
rule is adjusted away) and #sync-result (the aria-live announcer);
|
||||
CSS ellipsizes only the label span."""
|
||||
fmtSyncLabel parts, byte-identical. Phase 128 (task 01): the entry
|
||||
itself no longer writes the label — it hands the parts to the
|
||||
SINGLE writer (setSyncLabel(main, count)), which lands the main
|
||||
part, the pinned count part (hidden when absent), and the FULL
|
||||
untruncated label on the button title + #sync-result (A4 — set
|
||||
for EVERY running label; CSS ellipsizes the main part only)."""
|
||||
body = _fn(_js(), "enterSyncRunningState")
|
||||
assert (
|
||||
"function enterSyncRunningState(kind, currentFile, done, total, status)"
|
||||
@@ -256,25 +325,48 @@ def test_enter_running_state_is_phase_aware_and_announces_full_label() -> None:
|
||||
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
|
||||
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
||||
assert "syncBtn.classList.remove(\"is-error\")" in body
|
||||
# the phase-aware split: sync + status → the D2 builder; the
|
||||
# the phase-aware split: sync + status → the D2 parts builder; the
|
||||
# fall-through (upload / the click, no status) is the byte-
|
||||
# identical phase-64 builder.
|
||||
# identical phase-64 parts builder.
|
||||
i_phase = body.find("fmtSyncPhaseLabel(status)")
|
||||
i_fall = body.find("fmtSyncLabel(kind, currentFile, done, total)")
|
||||
assert 'kind === "sync" && status' in body
|
||||
assert -1 < i_phase < i_fall, "the phase label precedes the fall-through"
|
||||
# A4: the untruncated label rides the title for EVERY running label
|
||||
# (the remove-when-no-file rule is gone) and #sync-result.
|
||||
assert "syncBtn.title = label" in body, "the full label on hover, always"
|
||||
assert 'syncBtn.removeAttribute("title")' not in body, (
|
||||
"the title is set for every running label (D2: not just file labels)"
|
||||
)
|
||||
i_title = body.find("syncBtn.title = label")
|
||||
i_span = body.find("syncLabel.textContent = label")
|
||||
i_result = body.find("syncResult.textContent = label")
|
||||
assert -1 < i_title < i_span < i_result, (
|
||||
"one label: built once, written to title, span AND the announcer"
|
||||
)
|
||||
# the parts are handed to the single writer (A4 lives in it).
|
||||
i_set = body.find("setSyncLabel(main, count)")
|
||||
assert -1 < i_fall < i_set, "the parts are written after the split"
|
||||
assert body.count("setSyncLabel(") == 1, "one writer call per entry"
|
||||
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 "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||
|
||||
|
||||
def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
||||
"""Every OTHER #sync-label writer — the idle "Sync sources", the
|
||||
success settle "Synced HH:MM" (via settleSyncButton from
|
||||
applySyncSuccess), the failure / upload-terminal "Sync sources"
|
||||
settles — goes through setSyncLabel(main): the count part is
|
||||
hidden (absent), and the label's full string (bare main) rides the
|
||||
button title + #sync-result (A4, as with every label state). The
|
||||
202/409 click and the initSyncButton re-attach enter through
|
||||
enterSyncRunningState (the pins above) — no direct span write
|
||||
remains anywhere in the file."""
|
||||
js = _js()
|
||||
settle = _fn(js, "settleSyncButton")
|
||||
assert "function settleSyncButton(label)" in settle
|
||||
assert "setSyncLabel(label)" in settle, "the settle label goes through the parts writer"
|
||||
assert settle.count("setSyncLabel(") == 1
|
||||
assert "syncLabel.textContent" not in settle, ("no direct span write — the parts")
|
||||
# the success settle keeps the "Synced HH:MM" copy + the counts line
|
||||
# AFTER the settle (the settle's announcer write is superseded by
|
||||
# the counts — the phase-32 contract, unchanged).
|
||||
success = _fn(js, "applySyncSuccess")
|
||||
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
|
||||
i_settle = success.find("settleSyncButton(")
|
||||
i_counts = success.find("syncResult.textContent = fmtSyncResult(status.detail)")
|
||||
assert -1 < i_settle < i_counts, "the counts line supersedes the settle label"
|
||||
# NO direct #sync-label write remains anywhere in the module.
|
||||
assert "syncLabel.textContent" not in js, ("the parts writer is the single entry point")
|
||||
|
||||
|
||||
# ---------- the two-job tick (startSyncPolling) ----------
|
||||
@@ -522,34 +614,56 @@ def test_sources_html_comment_documents_the_live_announcer() -> None:
|
||||
|
||||
|
||||
def test_sync_label_css_ellipsis_truncation() -> None:
|
||||
""".sync-label: the live-file label ellipsizes a long
|
||||
source/relative/path inside the pill (A4) — inline-block with the
|
||||
min(16rem, 40vw) cap, overflow hidden, text-overflow ellipsis, no
|
||||
wrap, baseline-aligned; on mobile the pill goes full width with
|
||||
the label visible (the cap lifts, and min-width: 0 engages the
|
||||
ellipsis against the full width — the icon-only squeeze is
|
||||
gone)."""
|
||||
""".sync-label (phase 128 task 02): the live-file label is a FLEX
|
||||
ROW of two parts inside the pill (A4) — the container is
|
||||
inline-flex with the min(16rem, 40vw) cap + min-width: 0; the
|
||||
.sync-label-main part is the shrinkable item (min-width: 0, overflow
|
||||
hidden, text-overflow ellipsis, no wrap) whose long
|
||||
source/relative/path ellipsizes; the .sync-label-count part is
|
||||
flex 0 0 auto — NEVER clipped — in the .sync-result mono language
|
||||
(a position reads as data). On mobile the pill goes full width and
|
||||
the label FILLS it (the cap lifts, flex 1 1 auto) so the main part
|
||||
ellipsizes against the width left by the pinned count (the
|
||||
icon-only squeeze is gone)."""
|
||||
css = _css()
|
||||
block = re.search(r"\.sync-label\s*\{([^}]*)\}", css)
|
||||
assert block, "styles.css must style .sync-label"
|
||||
body = block.group(1)
|
||||
for prop in (
|
||||
"display: inline-block",
|
||||
"display: inline-flex",
|
||||
"align-items: baseline",
|
||||
"gap: 0.35rem",
|
||||
"min-width: 0",
|
||||
"max-width: min(16rem, 40vw)",
|
||||
):
|
||||
assert prop in body, f".sync-label must carry {prop!r}"
|
||||
assert "inline-block" not in body, "the old inline-block container is gone"
|
||||
assert "vertical-align" not in body, "the flex row aligns the parts"
|
||||
main_block = re.search(r"\.sync-label-main\s*\{([^}]*)\}", css)
|
||||
assert main_block, "styles.css must style .sync-label-main"
|
||||
for prop in (
|
||||
"min-width: 0",
|
||||
"overflow: hidden",
|
||||
"text-overflow: ellipsis",
|
||||
"white-space: nowrap",
|
||||
"vertical-align: bottom",
|
||||
):
|
||||
assert prop in body, f".sync-label must carry {prop!r}"
|
||||
assert prop in main_block.group(1), f".sync-label-main must carry {prop!r}"
|
||||
count_block = re.search(r"\.sync-label-count\s*\{([^}]*)\}", css)
|
||||
assert count_block, "styles.css must style .sync-label-count"
|
||||
for prop in (
|
||||
"flex: 0 0 auto",
|
||||
"white-space: nowrap",
|
||||
"font-family: var(--mono)",
|
||||
):
|
||||
assert prop in count_block.group(1), f".sync-label-count must carry {prop!r}"
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
assert mobile, "the ≤640px media query must stay"
|
||||
mbody = mobile.group(1)
|
||||
assert ".sync-btn { width: 100%; }" in mbody, (
|
||||
"the mobile Sync pill is full width (the icon-only squeeze is gone)"
|
||||
)
|
||||
assert ".sync-label { max-width: none; min-width: 0; }" in mbody, (
|
||||
"the label stays visible and ellipsizes against the full width"
|
||||
assert ".sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }" in mbody, (
|
||||
"the label fills the full-width pill; the main part ellipsizes against the pinned count"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,14 @@ FULL untruncated LABEL (the file label, "Writing KB overview…", or
|
||||
"Summarizing folders… <folder> (n/m)") rides the button title for
|
||||
EVERY running label; the phase-64 "title only when there is a current
|
||||
file" (remove-when-null) rule is gone.
|
||||
|
||||
Phase 128 (task 01): the label becomes PARTS — #sync-label ships a
|
||||
main span (the idle text pre-JS) + a hidden count span (the pinned
|
||||
"(n/m)"), the builders return { main, count }, and the single writer
|
||||
setSyncLabel(main, count) lands the parts + the untruncated full
|
||||
label (main + " " + count) on the title + #sync-result; the
|
||||
running-state / settle pins move onto that shape (their labels stay
|
||||
byte-identical as full strings).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -115,18 +123,30 @@ def test_sync_button_ships_hidden_and_labeled() -> None:
|
||||
assert 'aria-label="Sync sources"' in attrs
|
||||
|
||||
|
||||
def test_sync_button_has_icon_and_label_span() -> None:
|
||||
def test_sync_button_has_icon_and_label_parts() -> None:
|
||||
"""The button body is a refresh-cycle svg (aria-hidden — decorative,
|
||||
the spin is the visible running state) + the .sync-label span with
|
||||
the idle text, so the label can be swapped by sources.js."""
|
||||
the idle text, so the label can be swapped by sources.js.
|
||||
|
||||
Phase 128 (task 01): #sync-label is PARTS — the MAIN span
|
||||
(.sync-label-main) carries the idle text (the pre-JS label renders
|
||||
from the static markup; the part CSS may ellipsize it), and the
|
||||
COUNT span (.sync-label-count — the pinned "(n/m)" position, never
|
||||
clipped) ships hidden + empty (there is no count at idle). The
|
||||
class/id stay (the module query + the E2E label assertions)."""
|
||||
text = _text(SHELL_HTML)
|
||||
btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))]
|
||||
assert re.search(r'<svg[^>]*class="sync-icon"[^>]*aria-hidden="true"', btn)
|
||||
# class for the module query + id for the E2E label assertions
|
||||
# Phase 128 (task 01): the parts markup — the exact shape (the main
|
||||
# span holds the idle text, the count span is hidden + empty).
|
||||
label = re.search(
|
||||
r'<span\b[^>]*id="sync-label"[^>]*>\s*Sync sources\s*</span>', btn
|
||||
r'<span\b[^>]*id="sync-label"[^>]*>'
|
||||
r'<span class="sync-label-main">Sync sources</span>'
|
||||
r'<span class="sync-label-count" hidden></span>'
|
||||
r"</span>",
|
||||
btn,
|
||||
)
|
||||
assert label, "the #sync-label span carrying the idle text is missing"
|
||||
assert label, ("the #sync-label parts markup (main + hidden count) is missing")
|
||||
assert 'class="sync-label"' in label.group(0)
|
||||
|
||||
|
||||
@@ -275,30 +295,32 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
||||
adjusted — the FULL untruncated LABEL (the file label, the overview
|
||||
label, or the summaries label — whatever the running form is) rides
|
||||
the button title for EVERY running label; the "title only when
|
||||
there is a current file" (remove-when-null) rule is gone."""
|
||||
there is a current file" (remove-when-null) rule is gone.
|
||||
Phase 128 (task 01): the entry hands the label PARTS (the
|
||||
phase-aware split — sync + status → fmtSyncPhaseLabel, the
|
||||
fall-through → the byte-identical fmtSyncLabel) to the SINGLE
|
||||
writer, setSyncLabel(main, count), which lands the main part, the
|
||||
pinned count part (hidden when absent), and the FULL untruncated
|
||||
label on the button title + #sync-result (A4) — the direct span /
|
||||
title / announcer writes are GONE from the entry (they live in
|
||||
setSyncLabel)."""
|
||||
js = _text(SOURCES_JS)
|
||||
body = _body(js, "enterSyncRunningState")
|
||||
assert "syncBtn.disabled = true" in body
|
||||
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
|
||||
assert "syncBtn.title = label" in body, (
|
||||
"the full untruncated label on hover, for every running label"
|
||||
)
|
||||
assert 'syncBtn.removeAttribute("title")' not in body, (
|
||||
"the title is set for EVERY running label (phase 98: the file-only "
|
||||
"rule is adjusted away)"
|
||||
)
|
||||
title_i = body.find("syncBtn.title = label")
|
||||
span_i = body.find("syncLabel.textContent = label")
|
||||
result_i = body.find("syncResult.textContent = label")
|
||||
assert -1 < title_i < span_i < result_i, (
|
||||
"one label: built once, written to title, span AND the announcer"
|
||||
)
|
||||
# the parts split (phase 98) is intact…
|
||||
assert "fmtSyncPhaseLabel(status)" in body
|
||||
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
|
||||
# …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).
|
||||
assert "setSyncLabel(main, count)" in body
|
||||
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 "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 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
||||
assert "syncBtn.classList.remove(\"is-error\")" in body
|
||||
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
||||
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
|
||||
assert "syncLabel.textContent = label" in body
|
||||
assert "syncResult.textContent = label" in body, ("the announcer reads the full live path")
|
||||
|
||||
|
||||
def test_sources_js_terminal_states() -> None:
|
||||
@@ -345,7 +367,10 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
|
||||
"""settleSyncButton re-enables the control, drops aria-busy, the
|
||||
title, and the failed affordances, and un-spins the icon — the
|
||||
button can never sit disabled after a run reaches a terminal state
|
||||
(failed included: retry-ready)."""
|
||||
(failed included: retry-ready). Phase 128 (task 01): the settle /
|
||||
idle label ("Sync sources", "Synced HH:MM") goes through the parts
|
||||
writer — setSyncLabel(label), count hidden (absent), the full
|
||||
string riding the title + #sync-result (A4, every label state)."""
|
||||
js = _text(SOURCES_JS)
|
||||
body = _body(js, "settleSyncButton")
|
||||
assert "syncBtn.disabled = false" in body
|
||||
@@ -353,6 +378,8 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
|
||||
assert 'syncBtn.removeAttribute("title")' in body
|
||||
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
||||
assert "syncIcon.classList.remove(\"is-spinning\")" in body
|
||||
assert "setSyncLabel(label)" in body, ("the settle label goes through the parts writer")
|
||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||
|
||||
|
||||
def test_sources_js_never_starts_a_second_poll_loop() -> None:
|
||||
|
||||
Reference in New Issue
Block a user