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`)
|
### RAG / Knowledge base (`/sources.html`)
|
||||||
|
|
||||||
The indexed document list. The *Path* column opens each document in the same
|
The indexed document list. The *Path* column opens each document in the same
|
||||||
**almost-fullscreen modal**. Admin-only — anonymous visitors see a sign-in
|
**almost-fullscreen modal**. Document rows carry a *Created* date and folder
|
||||||
gate instead.
|
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`)
|
### Git sources (`/git-sources.html`)
|
||||||
|
|
||||||
@@ -118,7 +123,8 @@ everyone — no login or token required.
|
|||||||
### Edit doc (`/doc-edit.html`)
|
### Edit doc (`/doc-edit.html`)
|
||||||
|
|
||||||
Review and adjust an AI-generated answer before committing it to the docs
|
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.
|
so it is safe to run from cron or after every commit.
|
||||||
- Indexed formats: **md, markdown, txt, yaml, yml, json, py**, the Podman
|
- Indexed formats: **md, markdown, txt, yaml, yml, json, py**, the Podman
|
||||||
quadlet family (**container, network, volume, image, pod, kube, swap,
|
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`,
|
- 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,
|
- Non-markdown files get format-aware chunking (YAML keys, JSON keys,
|
||||||
Python classes/defs).
|
Python classes/defs).
|
||||||
- With `BOR_IMAGES=1`, standalone images (`png, jpg, jpeg, webp, gif,
|
- 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>/
|
BOR_SOURCES_DIR=~/bor-sources # default; each repo lands in <dir>/<repo-name>/
|
||||||
```
|
```
|
||||||
|
|
||||||
- Auth is whatever the machine supplies — HTTPS via the OS credential
|
- Public repos need no setup: auth is whatever the machine supplies —
|
||||||
helper, or SSH via your key; no credentials are stored in the app.
|
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**
|
- Every run **clones** (first time, shallow `--depth 1`) or **pulls**
|
||||||
(`git pull --ff-only`) each repo, then indexes the checkout.
|
(`git pull --ff-only`) each repo, then indexes the checkout.
|
||||||
- A failed sync aborts the run — no partial junk.
|
- 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
|
## Caching & deploys
|
||||||
|
|
||||||
A deploy is a commit — and the browser must see it **without a hard refresh**.
|
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 |
|
| 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_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_SOURCES_DIR` | `~/bor-sources` | Where git repos are cloned/pulled |
|
||||||
| `BOR_UPLOAD_DIR` | `~/bor-sources/uploads` | Where uploaded source archives are unpacked |
|
| `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
|
* The glyph is static SVG (aria-hidden — the alt text is the
|
||||||
* accessible content); the box + img are properties only, never
|
* accessible content); the box + img are properties only, never
|
||||||
* innerHTML with document-derived data (the house rule).
|
* 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";
|
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
|
* upload the same way; a terminal upload is a no-op there (the
|
||||||
* boot-time loadTree() already shows the current catalog).
|
* boot-time loadTree() already shows the current catalog).
|
||||||
*
|
*
|
||||||
* Elements: #sync-btn (the button), #sync-label (the text),
|
* Elements: #sync-btn (the button), #sync-label (the text —
|
||||||
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
* Phase 128 (task 01): the PARTS — .sync-label-main, the
|
||||||
* line), #sync-error-banner / #sync-error-text (error banner).
|
* 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 syncBtn = root.querySelector("#sync-btn");
|
||||||
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
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 syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||||||
const syncResult = root.querySelector("#sync-result");
|
const syncResult = root.querySelector("#sync-result");
|
||||||
const syncErrorBanner = root.querySelector("#sync-error-banner");
|
const syncErrorBanner = root.querySelector("#sync-error-banner");
|
||||||
@@ -632,45 +665,86 @@ export async function mount(root) {
|
|||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
|
/* Phase 128 (task 01): the label PARTS writer — the SINGLE point
|
||||||
* "sync" → "Syncing…", "upload" → "Importing" (the background run's
|
* that writes #sync-label. mainText lands in the MAIN part
|
||||||
* word, A3). The current file — the status endpoint's full
|
* (.sync-label-main — the shrinks-to-fit prefix + file/folder, the
|
||||||
* source/relative/path (A4) — is appended while one is being processed;
|
* part CSS ellipsizes); countText lands in the COUNT part
|
||||||
* the BARE prefix shows during the clone/pull (sync), before any file
|
* (.sync-label-count — the pinned "(n/m)" position, NEVER clipped):
|
||||||
* is indexed. Phase 90: the upload run is unpack + register only (no
|
* present → its textContent + hidden=false, absent → emptied +
|
||||||
* scan), so its status never carries a file or counts — the
|
* hidden=true (the count span is hidden whenever there is no
|
||||||
* "Importing" label is always the bare one. The counts appear only
|
* "(n/m)" to show). Then the UNTRUNCATED full label —
|
||||||
* once the import has started (total > 0). CSS ellipsizes the button
|
* mainText + (countText ? " " + countText : "") — rides the button
|
||||||
* label; the same untruncated text goes to the button title +
|
* title + #sync-result (the aria-live announcer) exactly as before
|
||||||
* #sync-result (the aria-live announcer). Phase 98 (task 02): the
|
* (A4 — the screen reader hears the whole thing; the visible span
|
||||||
* SYNC job's post-import phases (overview / summaries) take their
|
* carries main + count, the space between them is the CSS gap).
|
||||||
* own label via fmtSyncPhaseLabel (below) — this builder keeps that
|
* 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
|
* prelude/import fall-through byte-identical and serves the UPLOAD
|
||||||
* job unchanged (its status has no phase — D2). */
|
* job unchanged (its status has no phase — D2). */
|
||||||
function fmtSyncLabel(kind, currentFile, done, total) {
|
function fmtSyncLabel(kind, currentFile, done, total) {
|
||||||
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
||||||
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
|
const main = currentFile ? `${prefix} ${currentFile}` : prefix;
|
||||||
if (total > 0) label += ` (${done}/${total})`;
|
const count = total > 0 ? `(${done}/${total})` : "";
|
||||||
return label;
|
return { main, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase 98 (task 02, D2): the SYNC job's phase-aware running label
|
/* Phase 98 (task 02, D2) + Phase 128 (task 01, the PARTS rework):
|
||||||
* — the post-import span where the file count sits still (the
|
* the SYNC job's phase-aware running label — the post-import span
|
||||||
* owner's "the number pauses for a really long time"): "overview"
|
* where the file count sits still (the owner's "the number pauses
|
||||||
* → "Writing KB overview…"; "summaries" → "Summarizing folders…
|
* for a really long time"): "overview" → "Writing KB overview…"
|
||||||
* <source/folder> (n/m)" — the folder part is omitted while
|
* (no count part); "summaries" → main "Summarizing folders…
|
||||||
* current_summary is null (the phase's first poll); anything else
|
* <source/folder>" + count "(n/m)" — the folder part is omitted
|
||||||
* (the null prelude, "import") → the phase-64 fmtSyncLabel
|
* while current_summary is null (the phase's first poll) and the
|
||||||
* fall-through, byte-identical (same builder, same fields, same
|
* COUNT IS ALWAYS EMITTED in this phase (the hook always carries a
|
||||||
* order). The UPLOAD job never passes a status (its endpoint has no
|
* total); anything else (the null prelude, "import") → the
|
||||||
* phase — D2) and keeps the bare fmtSyncLabel label. The untruncated
|
* phase-64 fmtSyncLabel parts, byte-identical (same builder, same
|
||||||
* result rides the button title + #sync-result like every running
|
* fields, same order). The UPLOAD job never passes a status (its
|
||||||
* label (A4). */
|
* 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) {
|
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") {
|
if (status.phase === "summaries") {
|
||||||
const folder = status.current_summary ? ` ${status.current_summary}` : "";
|
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
|
// null phase (the prelude) or "import" — today's byte-identical
|
||||||
// sync label (the phase-64 contract, unchanged).
|
// sync label (the phase-64 contract, unchanged).
|
||||||
@@ -702,26 +776,26 @@ export async function mount(root) {
|
|||||||
* is phase-aware (fmtSyncPhaseLabel: "Writing KB overview…" /
|
* is phase-aware (fmtSyncPhaseLabel: "Writing KB overview…" /
|
||||||
* "Summarizing folders… <folder> (n/m)"); the UPLOAD job and the
|
* "Summarizing folders… <folder> (n/m)"); the UPLOAD job and the
|
||||||
* 202/409 click (no status yet) keep the bare fmtSyncLabel label.
|
* 202/409 click (no status yet) keep the bare fmtSyncLabel label.
|
||||||
* Same mechanics as before (disabled, aria-busy, spinning icon, no
|
* Phase 128 (task 01): the builders return the PARTS and the single
|
||||||
* is-error) plus: the FULL untruncated label on the button title
|
* writer (setSyncLabel) lands them — the main part (CSS-ellipsized),
|
||||||
* (set for EVERY running label — the phase-64 file-only rule is
|
* the pinned count part (hidden when absent), and the FULL
|
||||||
* adjusted for the phase labels) and in #sync-result (the aria-live
|
* untruncated label on the button title + #sync-result (A4 — set
|
||||||
* announcer reads it; CSS ellipsizes the button's label span only).
|
* 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) {
|
function enterSyncRunningState(kind, currentFile, done, total, status) {
|
||||||
if (!syncBtn) return;
|
if (!syncBtn) return;
|
||||||
syncBtn.disabled = true;
|
syncBtn.disabled = true;
|
||||||
syncBtn.setAttribute("aria-busy", "true");
|
syncBtn.setAttribute("aria-busy", "true");
|
||||||
const label =
|
const { main, count } =
|
||||||
kind === "sync" && status
|
kind === "sync" && status
|
||||||
? fmtSyncPhaseLabel(status)
|
? fmtSyncPhaseLabel(status)
|
||||||
: fmtSyncLabel(kind, currentFile, done, total);
|
: 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.setAttribute("aria-label", "Sync sources");
|
||||||
syncBtn.classList.remove("is-error");
|
syncBtn.classList.remove("is-error");
|
||||||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||||||
if (syncLabel) syncLabel.textContent = label;
|
|
||||||
if (syncResult) syncResult.textContent = label;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function settleSyncButton(label) {
|
function settleSyncButton(label) {
|
||||||
@@ -732,7 +806,11 @@ export async function mount(root) {
|
|||||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||||
syncBtn.classList.remove("is-error");
|
syncBtn.classList.remove("is-error");
|
||||||
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||||||
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) {
|
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
|
/* Phase 64: the live-file label ("Syncing… <file> (n/m)" / "Importing
|
||||||
<file> (n/m)") — a long source/relative/path ellipsizes inside the
|
<file> (n/m)") — a long source/relative/path ellipsizes inside the
|
||||||
pill; the full path lives in the button title + #sync-result (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 {
|
.sync-label {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
max-width: min(16rem, 40vw);
|
max-width: min(16rem, 40vw);
|
||||||
|
}
|
||||||
|
.sync-label-main {
|
||||||
|
min-width: 0; /* the flex item that shrinks — what engages the ellipsis */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 */
|
/* Last-result announcer — soft ink, small mono */
|
||||||
.sync-result {
|
.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
|
target to discover on a phone). The RAG page-head row wraps so
|
||||||
the Sync pill drops below the "Knowledge base" title at full
|
the Sync pill drops below the "Knowledge base" title at full
|
||||||
width; the History page-head already wraps the pill below its
|
width; the History page-head already wraps the pill below its
|
||||||
title block. The Sync label's min(16rem, 40vw) cap lifts so the
|
title block. The Sync label's min(16rem, 40vw) cap lifts and the
|
||||||
live-file text ellipsizes against the FULL width (min-width: 0
|
label FILLS the pill (phase 128: flex 1 1 auto) — .sync-label-main
|
||||||
lets the nowrap flex item shrink, which is what engages the
|
ellipsizes against the width left by the pinned .sync-label-count,
|
||||||
ellipsis); the refresh glyph joins its visible label. The
|
so the (n/m) stays on-screen at the right edge, never clipped;
|
||||||
aria-labels keep the accessible names. */
|
the refresh glyph joins its visible label. The aria-labels keep
|
||||||
|
the accessible names. */
|
||||||
.page-head-row { flex-wrap: wrap; }
|
.page-head-row { flex-wrap: wrap; }
|
||||||
.sync-btn { width: 100%; }
|
.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 { width: 100%; }
|
||||||
.history-refresh svg { display: block; }
|
.history-refresh svg { display: block; }
|
||||||
.sync-result {
|
.sync-result {
|
||||||
|
|||||||
+1
-1
@@ -448,7 +448,7 @@
|
|||||||
<h1>Knowledge base</h1>
|
<h1>Knowledge base</h1>
|
||||||
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources" hidden>
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="page-sub">
|
<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).
|
summaries_total == 3`` (the keep-final-counts convention).
|
||||||
2. ``test_sync_button_names_the_summary_phase`` (UI — the slow leg):
|
2. ``test_sync_button_names_the_summary_phase`` (UI — the slow leg):
|
||||||
a fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
a fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
||||||
the page's OWN 2 s poll renders ``#sync-label`` matching
|
the page's OWN 2 s poll renders ``#sync-label`` as the D2 PARTS
|
||||||
``Summarizing folders… [folder] (n/3)`` (the asserted text is the
|
(phase 128: the main part ``Summarizing folders… [folder]`` + the
|
||||||
RENDERED DOM label, with the button ``title`` riding the same
|
pinned count part ``(n/3)`` — the asserted text is the RENDERED
|
||||||
untruncated value — A4); on settle the button reads the terminal
|
DOM parts, with the button ``title`` riding the full untruncated
|
||||||
``Synced HH:MM`` (the phase-32 contract) and ``#sync-result``
|
string, main + " " + count — A4); on settle the button reads the
|
||||||
carries the counts line.
|
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``
|
3. ``test_missing_folder_summaries_read_as_pending_and_self_heal``
|
||||||
(tree — the fast mock leg): after a successful sync (every
|
(tree — the fast mock leg): after a successful sync (every
|
||||||
candidate stores its canned line) NO marker is anywhere; DELETE
|
candidate stores its canned line) NO marker is anywhere; DELETE
|
||||||
@@ -727,13 +728,15 @@ def test_sync_button_names_the_summary_phase(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
||||||
the page's OWN 2 s poll renders ``#sync-label`` as
|
the page's OWN 2 s poll renders ``#sync-label`` as
|
||||||
``Summarizing folders… [source/folder] (n/3)`` (asserted on the
|
``Summarizing folders… [source/folder]`` + the pinned ``(n/3)``
|
||||||
RENDERED text — the label is built by the page JS — with the
|
count span (phase 128: the label is PARTS — the folder part is
|
||||||
button ``title`` carrying the same untruncated value, A4); on
|
optional — the phase's first poll may precede the first hook — and
|
||||||
settle the button shows the terminal ``Synced HH:MM`` (the
|
the button ``title`` carries the full untruncated string, main +
|
||||||
phase-32 contract) and ``#sync-result`` carries the counts line.
|
" " + count, A4); on settle the button shows the terminal
|
||||||
The prelude/import labels stay the phase-64 shape (the endpoint
|
``Synced HH:MM`` (the phase-32 contract) and ``#sync-result``
|
||||||
test pins those phases; the UI pins the new summaries label)."""
|
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)
|
page.set_default_timeout(30_000)
|
||||||
_register_source(app_url, str(syncsum_dir))
|
_register_source(app_url, str(syncsum_dir))
|
||||||
login(page, app_url) # lands on /sources.html (the button's home)
|
login(page, app_url) # lands on /sources.html (the button's home)
|
||||||
@@ -764,22 +767,30 @@ def test_sync_button_names_the_summary_phase(
|
|||||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
# UI layer (D2): the page's own 2 s poll names the summaries phase
|
# UI layer (D2): the page's own 2 s poll names the summaries phase
|
||||||
# — the RENDERED label matches the D2 shape (the folder part is
|
# — the RENDERED label matches the D2 PARTS shape (phase 128:
|
||||||
# optional — the phase's first poll may precede the first hook)
|
# the MAIN part carries "Summarizing folders… [folder]" — the
|
||||||
# with the (n/3) counts, and the button ``title`` carries the same
|
# folder part is optional, the phase's first poll may precede the
|
||||||
# untruncated value (A4). ``wait_for_function`` checks BOTH in ONE
|
# first hook — and the pinned COUNT part carries the (n/3)
|
||||||
# in-browser evaluation: a poll tick updates label + title together
|
# counts), and the button ``title`` carries the full untruncated
|
||||||
# in a single JS task, so the atomic check can never straddle one
|
# string (main + " " + count, A4). ``wait_for_function`` checks
|
||||||
# (the JS regex is the D2 shape, unit-pinned in
|
# BOTH in ONE in-browser evaluation: a poll tick updates the parts
|
||||||
# tests/unit/test_frontend_sync_upload.py).
|
# + 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(
|
page.wait_for_function(
|
||||||
"""() => {
|
"""() => {
|
||||||
const label = document.querySelector('#sync-label');
|
const label = document.querySelector('#sync-label');
|
||||||
const btn = document.querySelector('#sync-btn');
|
const btn = document.querySelector('#sync-btn');
|
||||||
if (!label || !btn) return false;
|
if (!label || !btn) return false;
|
||||||
const text = label.textContent;
|
const main = label.querySelector('.sync-label-main');
|
||||||
const re = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
|
const count = label.querySelector('.sync-label-count');
|
||||||
return re.test(text) && btn.getAttribute('title') === text;
|
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,
|
timeout=SETTLE_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -604,11 +604,25 @@ def test_upload_toast_then_navigate_away(
|
|||||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
# UI layer: the label gains the live file at the page's 2 s poll
|
# UI layer: 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(
|
expect(page.locator("#sync-label")).to_have_text(
|
||||||
re.compile(rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md\(\d+/{N_FILES}\)"),
|
re.compile(rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md\(\d+/{N_FILES}\)"),
|
||||||
timeout=SETTLE_TIMEOUT_MS,
|
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
|
# Deterministic layer: the recorder's full tick series — file-less
|
||||||
# model-check ticks first, then the per-file ticks (full
|
# 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()
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
# UI layer: the label gains the live file at the page's 2 s poll
|
# UI layer: 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(
|
expect(page.locator("#sync-label")).to_have_text(
|
||||||
re.compile(rf"Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md\(\d+/{N_FILES}\)"),
|
re.compile(rf"Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md\(\d+/{N_FILES}\)"),
|
||||||
timeout=SETTLE_TIMEOUT_MS,
|
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
|
# Deterministic layer: the recorder's full tick series — file-less
|
||||||
# model-check ticks first, then the per-file ticks (full
|
# 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
|
the bare label — the upload status has no phase) and the A4 title-rule
|
||||||
adjustment (the full untruncated label rides the button title for
|
adjustment (the full untruncated label rides the button title for
|
||||||
EVERY running label — the file-only rule is gone).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -159,18 +176,22 @@ def _tick(js: str) -> str:
|
|||||||
return js[tick:end]
|
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:
|
def test_fmt_sync_label_returns_the_parts_contract() -> None:
|
||||||
"""fmtSyncLabel(kind, currentFile, done, total): `kind` picks the
|
"""fmtSyncLabel(kind, currentFile, done, total) — Phase 128
|
||||||
prefix — "upload" → "Importing" (the background run's word, A3),
|
(task 01): the builder returns the PARTS, not one string. `kind`
|
||||||
anything else → "Syncing…". The file is appended only when present
|
picks the prefix — "upload" → "Importing" (the background run's
|
||||||
(sync: the bare prefix shows during clone/pull, before any file is
|
word, A3), anything else → "Syncing…". The MAIN part — the
|
||||||
indexed — A4; phase 90: the upload run never carries a file or
|
prefix, the file appended only when present (sync: the bare
|
||||||
counts, so its label is always the bare "Importing…"); the counts
|
prefix shows during clone/pull, before any file is indexed — A4;
|
||||||
are appended only when total > 0 (the import has started); file
|
phase 90: the upload run never carries a file or counts, so its
|
||||||
before counts."""
|
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")
|
body = _fn(_js(), "fmtSyncLabel")
|
||||||
assert "function fmtSyncLabel(kind, currentFile, done, total)" in body
|
assert "function fmtSyncLabel(kind, currentFile, done, total)" in body
|
||||||
assert 'kind === "upload"' 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_importing = body.find('"Importing"')
|
||||||
i_syncing = body.find('"Syncing…"')
|
i_syncing = body.find('"Syncing…"')
|
||||||
assert -1 < i_kind < i_importing < i_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 "currentFile ?" in body
|
||||||
assert "`${prefix} ${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 "total > 0" in body
|
||||||
assert "`(${done}/${total})`" 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_file = body.find("currentFile ?")
|
||||||
i_counts = body.find("total > 0")
|
i_counts = body.find("total > 0")
|
||||||
assert -1 < i_file < i_counts, "the file lands before the counts"
|
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
|
"""fmtSyncPhaseLabel(status) — the SYNC job's phase-aware label
|
||||||
(phase 98 task 02, D2): "overview" → exactly "Writing KB
|
(phase 98 task 02, D2; phase 128 task 01: the PARTS). "overview"
|
||||||
overview…" (no folder, no counts); "summaries" →
|
→ main exactly "Writing KB overview…" (no folder, no count part);
|
||||||
"Summarizing folders… <current_summary> (n/m)" — the folder part
|
"summaries" → main "Summarizing folders… <current_summary>" +
|
||||||
(one leading space + current_summary) is omitted while
|
count "(n/m)" — the folder part (one leading space +
|
||||||
current_summary is null (the phase's first poll → the bare
|
current_summary) is omitted while current_summary is null (the
|
||||||
"Summarizing folders… (n/m)"); anything else (the null prelude,
|
phase's first poll → the bare "Summarizing folders…" main), and
|
||||||
"import") → the phase-64 fmtSyncLabel fall-through, byte-identical
|
the COUNT IS ALWAYS EMITTED in this phase (the hook always carries
|
||||||
(same builder, same fields, same order). The UPLOAD job never
|
a total); anything else (the null prelude, "import") → the
|
||||||
enters here — its status has no phase (D2)."""
|
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")
|
body = _fn(_js(), "fmtSyncPhaseLabel")
|
||||||
assert "function fmtSyncPhaseLabel(status)" in body
|
assert "function fmtSyncPhaseLabel(status)" in body
|
||||||
# "overview": the exact copy, no folder, no counts.
|
# "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"')
|
i_sum = body.find('status.phase === "summaries"')
|
||||||
assert -1 < i_ov < i_sum
|
assert -1 < i_ov < i_sum
|
||||||
ov = body[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, (
|
assert "current_summary" not in ov and "summaries_done" not in ov, (
|
||||||
"the overview label carries no folder and no counts"
|
"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_, (
|
assert 'status.current_summary ? ` ${status.current_summary}` : ""' in sum_, (
|
||||||
"the folder part — one leading space + the folder, or nothing"
|
"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 (
|
assert (
|
||||||
"return `Summarizing folders…${folder} "
|
"count: `(${status.summaries_done}/${status.summaries_total})`"
|
||||||
"(${status.summaries_done}/${status.summaries_total})`;"
|
) in sum_, "the count part is ALWAYS emitted in the summaries phase"
|
||||||
) in sum_, "the exact D2 copy (with and without the folder)"
|
assert "total > 0" not in sum_, ("no conditional — the hook always carries a total")
|
||||||
# anything else (null prelude, "import"): byte-identical to today's
|
# anything else (null prelude, "import"): byte-identical to today's
|
||||||
# sync label — same builder, same fields, same order.
|
# sync label — same builder, same fields, same order.
|
||||||
assert (
|
assert (
|
||||||
@@ -235,18 +303,19 @@ def test_fmt_sync_phase_label_is_the_exact_d2_contract() -> None:
|
|||||||
) in body[i_fall:]
|
) 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
|
"""The running-state entry keeps the §7.4 never-stale mechanics
|
||||||
(disabled, aria-busy, spinning icon, the stale .is-error removed)
|
(disabled, aria-busy, spinning icon, the stale .is-error removed)
|
||||||
and — phase 98 (task 02, D2) — is phase-aware for the SYNC job:
|
and — phase 98 (task 02, D2) — is phase-aware for the SYNC job:
|
||||||
a sync-kind call carrying the status object takes the
|
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
|
endpoint has no phase) and the 202/409 click keep the bare
|
||||||
fmtSyncLabel label, byte-identical. The FULL untruncated label —
|
fmtSyncLabel parts, byte-identical. Phase 128 (task 01): the entry
|
||||||
whatever its form — rides the button title (set for EVERY running
|
itself no longer writes the label — it hands the parts to the
|
||||||
label: the phase-64 "title only when there is a current file"
|
SINGLE writer (setSyncLabel(main, count)), which lands the main
|
||||||
rule is adjusted away) and #sync-result (the aria-live announcer);
|
part, the pinned count part (hidden when absent), and the FULL
|
||||||
CSS ellipsizes only the label span."""
|
untruncated label on the button title + #sync-result (A4 — set
|
||||||
|
for EVERY running label; CSS ellipsizes the main part only)."""
|
||||||
body = _fn(_js(), "enterSyncRunningState")
|
body = _fn(_js(), "enterSyncRunningState")
|
||||||
assert (
|
assert (
|
||||||
"function enterSyncRunningState(kind, currentFile, done, total, status)"
|
"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 'syncBtn.setAttribute("aria-busy", "true")' in body
|
||||||
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
||||||
assert "syncBtn.classList.remove(\"is-error\")" 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-
|
# 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_phase = body.find("fmtSyncPhaseLabel(status)")
|
||||||
i_fall = body.find("fmtSyncLabel(kind, currentFile, done, total)")
|
i_fall = body.find("fmtSyncLabel(kind, currentFile, done, total)")
|
||||||
assert 'kind === "sync" && status' in body
|
assert 'kind === "sync" && status' in body
|
||||||
assert -1 < i_phase < i_fall, "the phase label precedes the fall-through"
|
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 parts are handed to the single writer (A4 lives in it).
|
||||||
# (the remove-when-no-file rule is gone) and #sync-result.
|
i_set = body.find("setSyncLabel(main, count)")
|
||||||
assert "syncBtn.title = label" in body, "the full label on hover, always"
|
assert -1 < i_fall < i_set, "the parts are written after the split"
|
||||||
assert 'syncBtn.removeAttribute("title")' not in body, (
|
assert body.count("setSyncLabel(") == 1, "one writer call per entry"
|
||||||
"the title is set for every running label (D2: not just file labels)"
|
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)")
|
||||||
i_title = body.find("syncBtn.title = label")
|
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||||
i_span = body.find("syncLabel.textContent = label")
|
|
||||||
i_result = body.find("syncResult.textContent = label")
|
|
||||||
assert -1 < i_title < i_span < i_result, (
|
def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
||||||
"one label: built once, written to title, span AND the announcer"
|
"""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) ----------
|
# ---------- 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:
|
def test_sync_label_css_ellipsis_truncation() -> None:
|
||||||
""".sync-label: the live-file label ellipsizes a long
|
""".sync-label (phase 128 task 02): the live-file label is a FLEX
|
||||||
source/relative/path inside the pill (A4) — inline-block with the
|
ROW of two parts inside the pill (A4) — the container is
|
||||||
min(16rem, 40vw) cap, overflow hidden, text-overflow ellipsis, no
|
inline-flex with the min(16rem, 40vw) cap + min-width: 0; the
|
||||||
wrap, baseline-aligned; on mobile the pill goes full width with
|
.sync-label-main part is the shrinkable item (min-width: 0, overflow
|
||||||
the label visible (the cap lifts, and min-width: 0 engages the
|
hidden, text-overflow ellipsis, no wrap) whose long
|
||||||
ellipsis against the full width — the icon-only squeeze is
|
source/relative/path ellipsizes; the .sync-label-count part is
|
||||||
gone)."""
|
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()
|
css = _css()
|
||||||
block = re.search(r"\.sync-label\s*\{([^}]*)\}", css)
|
block = re.search(r"\.sync-label\s*\{([^}]*)\}", css)
|
||||||
assert block, "styles.css must style .sync-label"
|
assert block, "styles.css must style .sync-label"
|
||||||
body = block.group(1)
|
body = block.group(1)
|
||||||
for prop in (
|
for prop in (
|
||||||
"display: inline-block",
|
"display: inline-flex",
|
||||||
|
"align-items: baseline",
|
||||||
|
"gap: 0.35rem",
|
||||||
|
"min-width: 0",
|
||||||
"max-width: min(16rem, 40vw)",
|
"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",
|
"overflow: hidden",
|
||||||
"text-overflow: ellipsis",
|
"text-overflow: ellipsis",
|
||||||
"white-space: nowrap",
|
"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)
|
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||||
assert mobile, "the ≤640px media query must stay"
|
assert mobile, "the ≤640px media query must stay"
|
||||||
mbody = mobile.group(1)
|
mbody = mobile.group(1)
|
||||||
assert ".sync-btn { width: 100%; }" in mbody, (
|
assert ".sync-btn { width: 100%; }" in mbody, (
|
||||||
"the mobile Sync pill is full width (the icon-only squeeze is gone)"
|
"the mobile Sync pill is full width (the icon-only squeeze is gone)"
|
||||||
)
|
)
|
||||||
assert ".sync-label { max-width: none; min-width: 0; }" in mbody, (
|
assert ".sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }" in mbody, (
|
||||||
"the label stays visible and ellipsizes against the full width"
|
"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
|
"Summarizing folders… <folder> (n/m)") rides the button title for
|
||||||
EVERY running label; the phase-64 "title only when there is a current
|
EVERY running label; the phase-64 "title only when there is a current
|
||||||
file" (remove-when-null) rule is gone.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -115,18 +123,30 @@ def test_sync_button_ships_hidden_and_labeled() -> None:
|
|||||||
assert 'aria-label="Sync sources"' in attrs
|
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 button body is a refresh-cycle svg (aria-hidden — decorative,
|
||||||
the spin is the visible running state) + the .sync-label span with
|
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)
|
text = _text(SHELL_HTML)
|
||||||
btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))]
|
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)
|
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(
|
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)
|
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
|
adjusted — the FULL untruncated LABEL (the file label, the overview
|
||||||
label, or the summaries label — whatever the running form is) rides
|
label, or the summaries label — whatever the running form is) rides
|
||||||
the button title for EVERY running label; the "title only when
|
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)
|
js = _text(SOURCES_JS)
|
||||||
body = _body(js, "enterSyncRunningState")
|
body = _body(js, "enterSyncRunningState")
|
||||||
assert "syncBtn.disabled = true" in body
|
assert "syncBtn.disabled = true" in body
|
||||||
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
|
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
|
||||||
assert "syncBtn.title = label" in body, (
|
# the parts split (phase 98) is intact…
|
||||||
"the full untruncated label on hover, for every running label"
|
assert "fmtSyncPhaseLabel(status)" in body
|
||||||
)
|
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
|
||||||
assert 'syncBtn.removeAttribute("title")' not in body, (
|
# …and the single writer lands the parts (A4 in it — the title is
|
||||||
"the title is set for EVERY running label (phase 98: the file-only "
|
# set for EVERY running label: the file-only rule is adjusted away).
|
||||||
"rule is adjusted away)"
|
assert "setSyncLabel(main, count)" in body
|
||||||
)
|
assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer"
|
||||||
title_i = body.find("syncBtn.title = label")
|
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
||||||
span_i = body.find("syncLabel.textContent = label")
|
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||||
result_i = body.find("syncResult.textContent = label")
|
assert "syncResult.textContent" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
||||||
assert -1 < title_i < span_i < result_i, (
|
|
||||||
"one label: built once, written to title, span AND the announcer"
|
|
||||||
)
|
|
||||||
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
||||||
assert "syncBtn.classList.remove(\"is-error\")" in body
|
assert "syncBtn.classList.remove(\"is-error\")" in body
|
||||||
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
||||||
assert "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:
|
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
|
"""settleSyncButton re-enables the control, drops aria-busy, the
|
||||||
title, and the failed affordances, and un-spins the icon — the
|
title, and the failed affordances, and un-spins the icon — the
|
||||||
button can never sit disabled after a run reaches a terminal state
|
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)
|
js = _text(SOURCES_JS)
|
||||||
body = _body(js, "settleSyncButton")
|
body = _body(js, "settleSyncButton")
|
||||||
assert "syncBtn.disabled = false" in body
|
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.removeAttribute("title")' in body
|
||||||
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
||||||
assert "syncIcon.classList.remove(\"is-spinning\")" in body
|
assert "syncIcon.classList.remove(\"is-spinning\")" in body
|
||||||
|
assert "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:
|
def test_sources_js_never_starts_a_second_poll_loop() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user