diff --git a/.agents/phases/todo/154_sync_status_freshness/00_phase.md b/.agents/phases/complete/154_sync_status_freshness/00_phase.md similarity index 100% rename from .agents/phases/todo/154_sync_status_freshness/00_phase.md rename to .agents/phases/complete/154_sync_status_freshness/00_phase.md diff --git a/.agents/phases/todo/154_sync_status_freshness/01_rag_sync_stamp.md b/.agents/phases/complete/154_sync_status_freshness/01_rag_sync_stamp.md similarity index 100% rename from .agents/phases/todo/154_sync_status_freshness/01_rag_sync_stamp.md rename to .agents/phases/complete/154_sync_status_freshness/01_rag_sync_stamp.md diff --git a/.agents/phases/todo/154_sync_status_freshness/02_upload_line_stamp.md b/.agents/phases/complete/154_sync_status_freshness/02_upload_line_stamp.md similarity index 100% rename from .agents/phases/todo/154_sync_status_freshness/02_upload_line_stamp.md rename to .agents/phases/complete/154_sync_status_freshness/02_upload_line_stamp.md diff --git a/.agents/phases/todo/154_sync_status_freshness/03_unit_pins.md b/.agents/phases/complete/154_sync_status_freshness/03_unit_pins.md similarity index 100% rename from .agents/phases/todo/154_sync_status_freshness/03_unit_pins.md rename to .agents/phases/complete/154_sync_status_freshness/03_unit_pins.md diff --git a/.agents/phases/todo/154_sync_status_freshness/04_e2e_status_freshness.md b/.agents/phases/complete/154_sync_status_freshness/04_e2e_status_freshness.md similarity index 100% rename from .agents/phases/todo/154_sync_status_freshness/04_e2e_status_freshness.md rename to .agents/phases/complete/154_sync_status_freshness/04_e2e_status_freshness.md diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__00_phase.a1.err b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__00_phase.a1.md b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__00_phase.a1.md new file mode 100644 index 0000000..b4526c6 --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__00_phase.a1.md @@ -0,0 +1 @@ +All 4 task files exist in `complete/`, and only `00_phase.md` remains in `todo/`. This is the final verification pass. I'll read through the task files and check the current state of the code. diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__00_phase.a1.validate b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__00_phase.a1.validate new file mode 100644 index 0000000..63cc50e --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__00_phase.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +........................................................................ [ 99%] +....................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__01_rag_sync_stamp.a1.err b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__01_rag_sync_stamp.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__01_rag_sync_stamp.a1.md b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__01_rag_sync_stamp.a1.md new file mode 100644 index 0000000..7a0afc5 --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__01_rag_sync_stamp.a1.md @@ -0,0 +1,12 @@ +Everything is green. Final verification of the complete gate sequence was already done: full suite (unit + integration) 3546 passed, ruff clean, pyright 0 errors, app/ coverage 99%. + +**Report — Phase 154, Task 01 (RAG sync badge date-qualified)** + +- `frontend/assets/sources.js::fmtSyncTime` contract widened to the A1 date-qualifier (name kept, `applySyncSuccess` shape unchanged): same local day → `HH:MM` (byte-identical to pre-phase), same year → `M/D, h:MM AM/PM`, other year → `M/D/YYYY, h:MM AM/PM`; 12-hour without leading zero (`12:05 AM` edge), parse guard unchanged (`garbage`/missing → `""` → bare "Synced") +- Parts-writer comment block (L821+) names the qualified stamp where the label parts are listed (cited, not restated); the five other stale `Synced HH:MM` comment cites in the file updated for accuracy +- No CSS change (longer stamp rides the same ellipsized label part) +- Unit pins in `tests/unit/test_sync_button.py`: source-level qualifier pins + the real `fmtSyncTime` run under node with a FAKE clock (patched `globalThis.Date`, fixed now Oct 3 2026 18:30 local; all ISOs designator-less — the wall clock never appears in an assertion): same-day/midnight/end-of-day, yesterday (3:14 PM, 12:05 AM, 12:07 PM, 11:58 PM), last-year (10/3/2025, 9:12 PM; 1/3/2025, 9:12 AM), garbage/empty/undefined → `""`; the `null`-epoch edge pinned TZ-independently (null ≡ epoch-ISO, dated+year form); settle-string test: `Synced 16:06` (byte-identical), `Synced 10/2, 3:14 PM`, bare `Synced` fallback +- Results: `uv run pytest` → **3546 passed** (exit 0); `uv run pytest --cov=app` → TOTAL **99%** (>90%); `uv run ruff check . && uv run pyright` → clean (0 errors); `node --check sources.js` OK +- Docs: README.md sync-settle paragraph now names the date-qualified stamp; AGENTS.md had no mention — no change +- Notable: zero server change (frontend-only per phase design); pre-existing TODO.md working-tree change left untouched (owner's file swap) +- Next pending task: `.agents/phases/todo/154_sync_status_freshness/02_upload_line_stamp.md` diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__01_rag_sync_stamp.a1.validate b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__01_rag_sync_stamp.a1.validate new file mode 100644 index 0000000..63f1bcf --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__01_rag_sync_stamp.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +........................................................................ [ 99%] +.................. [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__02_upload_line_stamp.a1.err b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__02_upload_line_stamp.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__02_upload_line_stamp.a1.md b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__02_upload_line_stamp.a1.md new file mode 100644 index 0000000..708617e --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__02_upload_line_stamp.a1.md @@ -0,0 +1,9 @@ +Task 02 complete — all gates green. Report: + +**Phase 154, task 02 — upload-line completion stamp (frontend-only, zero server change)** + +- `git-sources.js`: `fmtUploadResult(detail, name, when)` — 4 shapes: `Uploaded () — …`, `Uploaded — …`, `Uploaded — …`, `Uploaded — …`; `when === ""` renders byte-identical pre-phase strings; non-`uploaded` branch ("The upload finished.") unchanged. New `fmtUploadStamp(status)`: finished_at first, started_at fallback (corrupt finished degrades to started stamp), `""` when neither parses — A1 qualifier duplicated from sources.js's `fmtSyncTime` (cited, not restated). Both render sites (poll settle + boot re-attach) pass `fmtUploadStamp(status)`; header + section comments updated to the stamped shapes. +- Unit pins (`test_frontend_sync_upload.py`): 3-arg signature + all 4 shape templates; ok-list/boot-reattach regex asserts for the 3-arg call; new `fmtUploadStamp` source pin (finished-before-started, skips, `|| {}` guard, full A1 structure) + real-node behavioral tests — 4 shapes (incl. legacy 2-arg call → pre-phase string) and fixed-clock stamp (finished wins / started fallback / `""` cases / corrupt-finish degrade / same-day, yesterday, last-year, `12:05 AM` + end-of-day edges). +- 8 stale E2E files (exact-string `to_have_text` / `==` pins) updated to same-day `HH:MM` regex helpers (incl. nameless boot re-render, self-dating) — verified in isolation: sync_upload_progress 4✓, upload_no_scan 3✓, archive_upload_sources 5✓, answer_images 5✓. +- Gates: full unit+integration **3549 passed**; `ruff` clean; `pyright` 0/0; `--cov=app` **99%**; `app/` untouched (no server change, per design). +- Docs: README/AGENTS.md don't document the result-line text — **no doc change needed**. Next pending task: `03_unit_pins.md`. diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__02_upload_line_stamp.a1.validate b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__02_upload_line_stamp.a1.validate new file mode 100644 index 0000000..19ceaa6 --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__02_upload_line_stamp.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +........................................................................ [ 99%] +..................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__03_unit_pins.a1.err b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__03_unit_pins.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__03_unit_pins.a1.md b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__03_unit_pins.a1.md new file mode 100644 index 0000000..2ae9430 --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__03_unit_pins.a1.md @@ -0,0 +1,12 @@ +Task 03 complete — all gates green. + +**Report — Phase 154, Task 03 (unit pins: qualifier + the four stamp shapes)** + +- `tests/unit/test_sync_button.py`: `PRE_PHASE_FMT_SYNC_TIME_JS` — the pre-phase `fmtSyncTime` pinned **verbatim** from git history (name renamed, body byte-for-byte) + `test_sources_js_fmt_sync_time_same_day_is_byte_identical_to_pre_phase`: real vs reference side-by-side on the shared fixed clock — same-day + fallbacks byte-identical (incl. the audit `16:06` settle), the divergent set (yesterday/last-year/epoch) byte-different, reference self-checked against the verbatim old strings (assertable, not assumed) +- `tests/unit/test_frontend_sync_upload.py`: `PRE_PHASE_FMT_UPLOAD_RESULT_JS` (pre-phase 2-arg pinned verbatim) + verbatim old-line constants + `test_upload_result_no_stamp_is_byte_identical_to_pre_phase` — the two no-stamp fallbacks + the legacy 2-arg call byte-identical to the reference/verbatim strings; non-`uploaded` branch unchanged on both sides; module docstring gains the task-03 note +- `fmtUploadStamp` (finished/started/missing/garbage) + the four shapes: already pinned by task 02's `test_upload_stamp_behavior_fixed_clock` / `test_upload_result_four_shapes_under_node` — verified green, additions only +- Payload pin (work item 2): `test_idle_upload_status_pins_full_shape` (phase-64 full-dict pin) **unchanged and green** (42/42 in `test_git_sources_upload.py`); `git diff -- app/` empty — the no-server-change regression guard holds +- Defect fixed: the dev DB was stale (alembic 0003 vs head 0026) breaking ~30 DB-backed tests — applied the documented `uv run alembic upgrade head` +- Results: `uv run pytest tests/unit -q` → 2803 passed (exit 0); `uv run pytest -q` (unit+integration) → **3563 passed** (exit 0); `uv run pytest --cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean (0/0/0) +- Docs: no doc impact (unit pins only; README updated by task 01, AGENTS.md doesn't describe the stamps) +- Next pending task: `.agents/phases/todo/154_sync_status_freshness/04_e2e_status_freshness.md` diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__03_unit_pins.a1.validate b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__03_unit_pins.a1.validate new file mode 100644 index 0000000..63cc50e --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__03_unit_pins.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +........................................................................ [ 99%] +....................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__04_e2e_status_freshness.a1.err b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__04_e2e_status_freshness.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__04_e2e_status_freshness.a1.md b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__04_e2e_status_freshness.a1.md new file mode 100644 index 0000000..fb82912 --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__04_e2e_status_freshness.a1.md @@ -0,0 +1,13 @@ +All gates green. Task 04 complete. + +**Report** + +- **Implemented:** `tests/e2e/test_sync_status_freshness.py` — the phase-154 dedicated Playwright suite (mock-only, no `slow_llm` needed): + 1. `test_fresh_sync_settles_same_day_shape` — real mock sync settles `#sync-btn` to `^Synced \d{2}:\d{2}$` (shape pin, never a wall-clock comparison) + real-run proof (counts line, terminal `finished_at` non-null) + 2. `test_upload_line_fresh_settle_carrys_name_and_stamp` — real archive upload → 202 toast → settle line `Uploaded () — press Sync sources to import it.` (stamp capture asserted non-empty; terminal no-count payload + non-null `finished_at`; row + host folder; zero docs indexed) + 3. `test_upload_line_boot_rerender_stamped_nameless` — **the phase's fix**: settle, then reload → `initUploadStatus` success branch renders the NAMELESS line WITH the stamp (from the server payload); run identity pinned on `started_at` (no second upload); page boots honest (no toast/error, button restored) + 4. `test_running_upload_line_unchanged` — regression guard: deterministic in-flight observation (the 202 handler's ≥2 s `Processing…` window before the first poll tick) asserts the bare label + hidden result line; line appears ONLY on settle (distinct safe name proves the stale boot line was replaced) +- **Gates:** `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` → **4 passed** (run 4×, no flake); `uv run pytest` (unit+integration) → **exit 0, 100% green**; `uv run pytest --cov=app` → **app/ 99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**; cross-check `test_upload_no_scan.py` in isolation → 3 passed. `app/` untouched (phase is frontend-only). +- **Docs:** README "Archive upload sources" gained the ready-for-sync line with the date-qualified completion stamp + both render paths (the sync badge was already named by tasks 01/02); AGENTS.md — no impact (no new commands/env/rules). +- **Decisions:** sync label pinned exactly as the task specifies (`Synced \d{2}:\d{2}`); upload stamp uses the task's dual shape regex (same-day + date-qualified, midnight-safe); no delay proxy — the upload's 2 s poll window makes the in-flight observation deterministic; test-4 pre-upload hidden-assertion removed (shared module app's boot re-render of the prior test's line is the intended phase-154 behavior, not a regression). +- **Next pending task:** none — 154 is fully complete (01–04); phase can move to `complete/`. diff --git a/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__04_e2e_status_freshness.a1.validate b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__04_e2e_status_freshness.a1.validate new file mode 100644 index 0000000..63cc50e --- /dev/null +++ b/.agents/reports/154_sync_status_freshness/154_sync_status_freshness__04_e2e_status_freshness.a1.validate @@ -0,0 +1,121 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +........................................................................ [ 99%] +....................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 443 0 100% +-------------------------------------------------- +TOTAL 5719 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/README.md b/README.md index b181012..ca220df 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,15 @@ first). - Re-uploading the same filename replaces the source **in place** (no second folder, no duplicate row). +- After the 202 the form settles into the ready-for-sync line + "Uploaded () — press Sync sources to import it." — + the completion stamp (phase 154: the same date-qualified freshness + rule as the sync badge — `HH:MM` on the same local day, + `M/D, h:MM AM/PM` otherwise, `M/D/YYYY` across years) rides the + line on EVERY render path, so a line that outlives its upload is + self-dating: after a reload the source name is gone (it was + page-local) but the stamp survives, coming from the server + payload's `finished_at`. - Zip-bomb guard: absolute member paths, `..` traversal, and symlinks escaping the unpack folder are rejected. - Archives unpack under `BOR_UPLOAD_DIR` (default `~/bor-sources/uploads`). @@ -358,9 +367,13 @@ whole git-source refresh in one click: pre-phase result-line announcer into the box). At settle the box STAYS visible to carry the result line — the counts line ("N added · …") after a success, "Sync cancelled" after a cancel — - and the button leaves the running state (the "Synced HH:MM" pill - after a success, the retry-ready "Sync sources" pill after a - failure); the box hides only when its text is empty (a failed sync + and the button leaves the running state (the "Synced " pill + after a success — the date-qualified freshness stamp (phase 154): + `HH:MM` on the same local day, `M/D, h:MM AM/PM` otherwise + (`M/D/YYYY` across years — a badge that tells the owner *when*), + the bare "Synced" when the stamp is missing — the retry-ready + "Sync sources" pill after a failure); the box hides only when its + text is empty (a failed sync — the banner + modal carry the error — and the true idle). No client-side timeout — a clone + embed can legitimately take minutes. - One sync at a time: a second trigger while a run is in flight gets a diff --git a/frontend/assets/git-sources.js b/frontend/assets/git-sources.js index eb21c93..4795863 100644 --- a/frontend/assets/git-sources.js +++ b/frontend/assets/git-sources.js @@ -75,8 +75,12 @@ * "Processing…" label for the WHOLE run (phase 90, A2 — the run * is unpack + register only: no file, no "(n/m)" counts, no * title) and settles it: success → the ready-for-sync line - * ("Uploaded — press Sync sources to import it.", - * fmtUploadResult off the status's {"message": "uploaded"} + * ("Uploaded () — press Sync sources to import + * it." — the phase-154 completion stamp, fmtUploadStamp off the + * status's finished_at, the A1 date-qualified form cited in + * sources.js's fmtSyncTime, not restated; a running / partial + * payload without timestamps renders the byte-identical pre-phase + * line — fmtUploadResult off the status's {"message": "uploaded"} * detail — the role=status result line) + the "Archive uploaded * — press Sync sources to import it." announce + loadSources * (the new/updated row lands with the Local badge; a re-upload @@ -93,8 +97,10 @@ * (a reload mid-run re-attaches — no second upload), a terminal * run re-renders its result line / error banner (the safe name * was page-local — lastUploadName is null after a reload — so the - * re-rendered line is the nameless "Uploaded — press Sync sources - * to import it."). + * re-rendered line is the nameless "Uploaded — press + * Sync sources to import it." — the stamp comes from the SERVER + * payload, so the line is self-dating even with the name + * unknown, phase 154). * • remove — a row's Remove button opens the page-local * confirmation modal (#remove-confirm-dialog, a real * role="alertdialog" — the native confirm() retired, phase 69): @@ -1096,7 +1102,10 @@ export async function mount(root) { * "Processing…" label for the WHOLE run (phase 90, A2 — the unpack * has no file-level progress: no file, no "(n/m)" counts, no title) * and settles it: success → the ready-for-sync line ("Uploaded - * — press Sync sources to import it.", fmtUploadResult off + * () — press Sync sources to import it." — the + * phase-154 completion stamp, fmtUploadStamp off the status's + * finished_at; the A1 date-qualified form is cited in + * fmtUploadResult's comment, not restated — fmtUploadResult off * the status's {"message": "uploaded"} detail) in the role=status * result line + the "Archive uploaded — press Sync sources to * import it." announce + loadSources (NO second toast — it already @@ -1117,23 +1126,75 @@ export async function mount(root) { * (The phase-49 synchronous 200 paragraph is superseded by phase 64; * the phase-64 scan counts are superseded by phase 90.) */ - /* The result line's text (phase 90, A2 — the no-count contract): the + /* The result line's text (phase 90, A2 — the no-count contract, + extended by phase 154 task 02 with the completion stamp): the status success detail is exactly {"message": "uploaded"} — the sync-style counts the phase-64 line rendered are gone (the scan — and its counts — belong to the Sync button, which renders them on the RAG page). `name` is the accepted 202's safe source name (lastUploadName) when the run started on this page; it is null after a reload or on the 409 re-attach (the line still points at - the next step, only without the name). */ - function fmtUploadResult(detail, name) { + the next step, only without the name). `when` is the PRECOMPUTED + completion stamp (fmtUploadStamp(status) — the phase-154 A1 + date-qualified form, the rule cited in sources.js's fmtSyncTime, + not restated); "" (a running / legacy / partial payload without + timestamps) renders the byte-identical pre-phase-154 strings. */ + function fmtUploadResult(detail, name, when) { if (detail && detail.message === "uploaded") { - return name - ? `Uploaded ${name} — press Sync sources to import it.` + if (name) { + return when + ? `Uploaded ${name} (${when}) — press Sync sources to import it.` + : `Uploaded ${name} — press Sync sources to import it.`; + } + return when + ? `Uploaded ${when} — press Sync sources to import it.` : "Uploaded — press Sync sources to import it."; } return "The upload finished."; } + /* Phase 154 (task 02): the upload line's completion stamp — + * PRECOMPUTED from the /upload/status payload (the per-page + * duplication idiom: the SAME date-qualifier as sources.js's + * fmtSyncTime, duplicated, not shared — the A1 rule — same local + * day `HH:MM`, same year `M/D, h:MM AM/PM`, across years + * `M/D/YYYY, h:MM AM/PM` — is cited there, not restated). Reads + * status.finished_at first (the success state always has it on the + * server), falls back to status.started_at (the defensive guard + * for a legacy / partial payload — a corrupt finished_at degrades + * to the started_at stamp rather than to no stamp), and yields + * "" when both are missing/absent or every ISO is unparseable + * (the pre-phase-154 byte-identical fallback — the line renders + * the pre-stamp copy). */ + function fmtUploadStamp(status) { + const s = status || {}; + let d = null; + for (const iso of [s.finished_at, s.started_at]) { + if (!iso) continue; + const t = new Date(iso); + if (!Number.isNaN(t.getTime())) { + d = t; + break; + } + } + if (d === null) return ""; + const now = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + if ( + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate() + ) + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; + const h12 = d.getHours() % 12 || 12; + const ampm = d.getHours() < 12 ? "AM" : "PM"; + const date = + d.getFullYear() === now.getFullYear() + ? `${d.getMonth() + 1}/${d.getDate()}` + : `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`; + return `${date}, ${h12}:${pad(d.getMinutes())} ${ampm}`; + } + /* Upload-success toast (phase 64 task 05, A2 — owner-locked): the * "successfully uploaded" confirmation, the phase-55 share-toast * pattern (frontend/assets/app.js) made page-local. A SINGLE node — @@ -1237,11 +1298,16 @@ export async function mount(root) { if (status.state === "success") { // The run finished (unpack + register only — phase 90): the // ready-for-sync line (fmtUploadResult reads the no-count - // detail), the announcement, the row lands. NO toast here — it + // detail + fmtUploadStamp's completion stamp — phase 154), + // the announcement, the row lands. NO toast here — it // already fired at the 202 (A2). const detail = status.detail || {}; if (uploadResult) { - uploadResult.textContent = fmtUploadResult(detail, lastUploadName); + uploadResult.textContent = fmtUploadResult( + detail, + lastUploadName, + fmtUploadStamp(status) + ); uploadResult.hidden = false; } announce("Archive uploaded — press Sync sources to import it."); @@ -1276,10 +1342,11 @@ export async function mount(root) { * no second upload, no error); a finished run re-renders its result * line ONLY (no announce, no toast — the toast fired at the 202, A2; * the name is unknown after a reload — lastUploadName is null — so - * the line is the nameless "Uploaded — press Sync sources to import - * it.", phase 90); a failed run re-renders its error banner; idle - * does nothing (and a blip is a no-op — the page boots honest - * either way). */ + * the line is the nameless "Uploaded — press Sync sources + * to import it." — the stamp from the SERVER payload, so the line + * is self-dating even with the name unknown, phase 154; phase 90); + * a failed run re-renders its error banner; idle does nothing (and a + * blip is a no-op — the page boots honest either way). */ async function initUploadStatus() { if (!uploadBtn) return; let status; @@ -1299,9 +1366,15 @@ export async function mount(root) { if (status.state === "success") { // The last run's result line only — no announce, no toast (A2). // The safe name was page-local (lastUploadName is null after a - // reload) — the line still points at the next step (phase 90). + // reload) — the line still points at the next step (phase 90), + // self-dating through fmtUploadStamp's completion stamp + // (phase 154 — the stamp comes from the server payload). if (uploadResult) { - uploadResult.textContent = fmtUploadResult(status.detail, lastUploadName); + uploadResult.textContent = fmtUploadResult( + status.detail, + lastUploadName, + fmtUploadStamp(status) + ); uploadResult.hidden = false; } return; diff --git a/frontend/assets/sources.js b/frontend/assets/sources.js index ae42f97..1824dee 100644 --- a/frontend/assets/sources.js +++ b/frontend/assets/sources.js @@ -321,7 +321,9 @@ * title + #sync-progress (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, + * settle labels ("Sync sources", "Synced " — the date- + * qualified stamp from fmtSyncTime, phase 154: `HH:MM` on the same + * local day, the dated form otherwise), 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… ", @@ -385,7 +387,8 @@ * every settle). The `is-running` * class travels with the spin: added in enterSyncRunningState, removed * in settleSyncButton (every terminal + both idles pass through it). - * The settle rendering ("Synced HH:MM" + the counts line), the Cancel + * The settle rendering ("Synced " — the date-qualified stamp, + * phase 154 — + the counts line), the Cancel * control, the 409 double-trigger, the error banner/modal, and the * sync status API are all UNCHANGED. * @@ -404,7 +407,8 @@ * writes * the SAME untruncated full label through setSyncProgress, ONLY * while running (the idle / settle button labels — "Sync sources", - * "Synced HH:MM" — must not fill the panel; they stay on the + * "Synced " (the date-qualified stamp, phase 154) — must + * not fill the panel; they stay on the * button, A2); * • result mode — the settle paths write the result line through * setSyncProgress: the counts line (fmtSyncResult — "N added · @@ -811,11 +815,40 @@ export async function mount(root) { } } + /* Phase 154 (task 01): the contract widens to the DATE-QUALIFIER + * (the phase design's A1 rule — owner-locked; the stamp is + * computed from the ISO's LOCAL calendar date, the page's clock — + * the table `Updated` columns already render local dates): + * • same local calendar day as now → `HH:MM` (byte-identical to + * the pre-phase output — a same-day badge is unchanged); + * • same local year, different day → `M/D, h:MM AM/PM` + * (e.g. `10/2, 3:14 PM`); + * • different local year → `M/D/YYYY, h:MM AM/PM` + * (e.g. `1/3/2025, 9:12 PM`). + * `h:MM` is the 12-hour form WITHOUT a leading zero on the hour + * (the `12:05 AM` edge), month/day without leading zero (the table + * dates' `M/D/YYYY` / `5:34:06 PM` family, the seconds dropped). + * The parse guard is UNCHANGED — an unparseable/missing ISO still + * yields "" (the bare "Synced" settle — e.g. the app-restart case, + * A12). */ function fmtSyncTime(iso) { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; + const now = new Date(); const pad = (n) => String(n).padStart(2, "0"); - return `${pad(d.getHours())}:${pad(d.getMinutes())}`; + if ( + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate() + ) + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; + const h12 = d.getHours() % 12 || 12; + const ampm = d.getHours() < 12 ? "AM" : "PM"; + const date = + d.getFullYear() === now.getFullYear() + ? `${d.getMonth() + 1}/${d.getDate()}` + : `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`; + return `${date}, ${h12}:${pad(d.getMinutes())} ${ampm}`; } /* Phase 128 (task 01) + Phase 129 (task 04) + Phase 152 (task 03): @@ -861,7 +894,14 @@ export async function mount(root) { * live label (the title already carries it for hover); every * non-running call restores the ship value "Sync sources" (the * static HTML attribute is the idle truth — the JS only - * overwrites/restores it, never removes it). */ + * overwrites/restores it, never removes it). + * Phase 154 (task 01): the settle badge among these label parts is + * date-qualified — `Synced `, where `` is produced + * by fmtSyncTime (its contract widened to the phase-154 A1 rule — + * same-day stays `HH:MM`, non-same-day carries the date; the rule + * is cited there, not restated). The parts themselves are + * UNCHANGED — the longer stamp is just more text in the same part + * (the label part ellipsizes as before). */ function setSyncLabel(mainText, countText = "", chunkText = "", running = false, imageText = "") { if (syncLabelMain) syncLabelMain.textContent = mainText; if (syncLabelCount) { @@ -1106,7 +1146,8 @@ export async function mount(root) { // the running state. syncBtn.classList.remove("is-running"); // Phase 128 (task 01): the idle / settle labels ("Sync sources", - // "Synced HH:MM") go through the parts writer — count hidden + // "Synced " — the date-qualified stamp, phase 154) go + // through the parts writer — count hidden // (absent), the label's full string rides the title (A4, as with // every label state). Phase 150 (task 02): the default // `running = false` leaves the detail box alone in every @@ -1196,7 +1237,8 @@ export async function mount(root) { settleSyncButton(time ? `Synced ${time}` : "Synced"); // Phase 150 (task 02): the settle's counts line IS the box's // result line (the sole live region) — the string builder is - // unchanged (A2: the "Synced HH:MM" label stays on the button). + // unchanged (A2: the "Synced " label stays on the button; + // the stamp is date-qualified, phase 154). setSyncProgress(fmtSyncResult(status.detail)); hideSyncError(); emitSyncStatus(status); diff --git a/tests/e2e/test_answer_images.py b/tests/e2e/test_answer_images.py index f490634..1029203 100644 --- a/tests/e2e/test_answer_images.py +++ b/tests/e2e/test_answer_images.py @@ -335,10 +335,24 @@ def _offered_tool_names(body: dict[str, Any]) -> list[str]: return [t["function"]["name"] for t in (body.get("tools") or [])] +def _ready_for_sync_line(source: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync + sources to import it." (the fresh 202 settle). In E2E the stamp + is ALWAYS the same-day ``HH:MM`` form (the run settles within + seconds; the date-qualified forms are unit-pinned on a fixed + clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + + def _upload_zip(page: Page, app_url: str, archive: Path) -> None: """The real admin upload flow: form login → the git-sources page → - pick the archive → submit → the terminal result line (phase 90: - "Uploaded — press Sync sources to import it.").""" + pick the archive → submit → the terminal result line (phase 90 A3, + phase 154 task 02: "Uploaded () — press Sync + sources to import it." — the completion stamp).""" login(page, app_url, next=GIT_SOURCES_URL) expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) @@ -347,7 +361,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path) -> None: result = page.locator("#archive-upload-result") expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) expect(result).to_have_text( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it.", + _ready_for_sync_line(SOURCE_NAME), timeout=UPLOAD_TIMEOUT_MS, ) diff --git a/tests/e2e/test_archive_upload_sources.py b/tests/e2e/test_archive_upload_sources.py index b5285ee..743c749 100644 --- a/tests/e2e/test_archive_upload_sources.py +++ b/tests/e2e/test_archive_upload_sources.py @@ -20,7 +20,8 @@ server-side behind ``GET /api/git-sources/upload/status`` (the phase-32 ``SyncStatus`` pattern; the phase-64 key set with ``current_file``/``files_done``/``files_total`` null/0/0 for the whole run — phase 90 A2). The settled result line points at the next step: -"Uploaded — press Sync sources to import it." (phase 90 A3) — +"Uploaded () — press Sync sources to import it." +(phase 90 A3, the phase-154 completion stamp) — the scan is the RAG page's **Sync sources** button's job, so this suite asserts **zero indexed documents** after every upload. @@ -66,8 +67,9 @@ Contract under test: button then carries the BARE "Processing…" label for the whole background run (no file, no "(n/m)" — the first status GET is held so the in-run window outlives the 2 s poll) until the status - ``success`` lands: the result line reads "Uploaded e2e-upload — - press Sync sources to import it.", the button restores ("Upload"), + ``success`` lands: the result line reads "Uploaded e2e-upload + () — press Sync sources to import it." (the phase-154 + completion stamp), the button restores ("Upload"), the file input clears, the list gains exactly one row for ``e2e-upload`` with the **Local** badge and its "Ignore paths" control (the phase-89 editor the deferral exists for), the terminal @@ -139,6 +141,19 @@ SOURCES_URL = "/sources.html" #: filename minus the archive suffix (the phase's locked naming rule). SOURCE_NAME = "e2e-upload" + +def _ready_for_sync_line(name: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync sources + to import it." (the fresh 202 settle, the page-local name). In + E2E the stamp is ALWAYS the same-day ``HH:MM`` form — the run + settles within seconds (the date-qualified forms are unit-pinned + on a fixed clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(name)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + #: v1: two sentinel docs. v2 (same filename): alpha CHANGED, beta DROPPED, #: gamma ADDED — the in-place-replace subject (the on-disk folder swap). ALPHA_SENTINEL_V1 = "ALPHA-TOKEN-v1-7f31" @@ -436,8 +451,9 @@ def test_upload_registers_without_indexing( then carries the BARE "Processing…" label for the whole background run (no file, no "(n/m)" — phase 90 A2, proven across a held first status GET); when the status ``success`` lands the result line - reads "Uploaded — press Sync sources to import it." - (A3), the button restores ("Upload"), the file input clears, the + reads "Uploaded () — press Sync sources to + import it." (A3 — the phase-154 completion stamp), the button + restores ("Upload"), the file input clears, the list gains exactly ONE row for ``e2e-upload`` (Local badge, the "Ignore paths" control), the terminal status carries the no-count ``{"message": "uploaded"}`` detail with null/0/0 progress — and @@ -490,9 +506,7 @@ def test_upload_registers_without_indexing( # …release: the status success lands → the ready-for-sync line + # the never-stale restore (input cleared). expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) - expect(result).to_have_text( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it." - ) + expect(result).to_have_text(_ready_for_sync_line(SOURCE_NAME)) expect(btn).to_be_enabled() expect(btn).to_have_text("Upload") expect(page.locator("#archive-upload-file")).to_have_value("") @@ -560,8 +574,8 @@ def test_reupload_replaces_in_place( # Baseline: v1 through the page (202 → the ready-for-sync line, # one row, no index). - assert _upload_via_page(page, tarball_v1) == ( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it." + assert _ready_for_sync_line(SOURCE_NAME).fullmatch( + _upload_via_page(page, tarball_v1) ) expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1) folder = upload_dir / SOURCE_NAME @@ -570,8 +584,8 @@ def test_reupload_replaces_in_place( # Re-upload v2 — SAME basename, different parent dir (the file # input's selection is replaced wholesale). - assert _upload_via_page(page, tarball_v2) == ( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it." + assert _ready_for_sync_line(SOURCE_NAME).fullmatch( + _upload_via_page(page, tarball_v2) ) # No duplicate: exactly ONE row for that source (and one row total). @@ -635,8 +649,8 @@ def test_bad_file_inline_error( # The form is not wedged: a good upload right after still works — # and indexes nothing (phase 90 A1). - assert _upload_via_page(page, tarball_v1) == ( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it." + assert _ready_for_sync_line(SOURCE_NAME).fullmatch( + _upload_via_page(page, tarball_v1) ) expect(error).to_be_hidden() expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1) diff --git a/tests/e2e/test_image_anchor_context.py b/tests/e2e/test_image_anchor_context.py index f937053..c8c3ad6 100644 --- a/tests/e2e/test_image_anchor_context.py +++ b/tests/e2e/test_image_anchor_context.py @@ -296,10 +296,24 @@ def _health_db(app_url: str) -> bool: return False +def _ready_for_sync_line(source: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync + sources to import it." (the fresh 202 settle). In E2E the stamp + is ALWAYS the same-day ``HH:MM`` form (the run settles within + seconds; the date-qualified forms are unit-pinned on a fixed + clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + + def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None: """The real admin upload flow: form login → the git-sources page → - pick the archive → submit → the terminal result line (phase 90: - "Uploaded — press Sync sources to import it.").""" + pick the archive → submit → the terminal result line (phase 90 A3, + phase 154 task 02: "Uploaded () — press Sync + sources to import it." — the completion stamp).""" login(page, app_url, next=GIT_SOURCES_URL) expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) @@ -308,7 +322,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None: result = page.locator("#archive-upload-result") expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) expect(result).to_have_text( - f"Uploaded {source} — press Sync sources to import it.", + _ready_for_sync_line(source), timeout=UPLOAD_TIMEOUT_MS, ) diff --git a/tests/e2e/test_image_documents.py b/tests/e2e/test_image_documents.py index d3079ea..19b3ad0 100644 --- a/tests/e2e/test_image_documents.py +++ b/tests/e2e/test_image_documents.py @@ -233,10 +233,24 @@ def _health_db(app_url: str) -> bool: return False +def _ready_for_sync_line(source: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync + sources to import it." (the fresh 202 settle). In E2E the stamp + is ALWAYS the same-day ``HH:MM`` form (the run settles within + seconds; the date-qualified forms are unit-pinned on a fixed + clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + + def _upload_zip(page: Page, app_url: str, archive: Path) -> None: """The real admin upload flow: form login → the git-sources page → - pick the archive → submit → the terminal result line (phase 90: - "Uploaded — press Sync sources to import it.").""" + pick the archive → submit → the terminal result line (phase 90 A3, + phase 154 task 02: "Uploaded () — press Sync + sources to import it." — the completion stamp).""" login(page, app_url, next=GIT_SOURCES_URL) expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) @@ -245,7 +259,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path) -> None: result = page.locator("#archive-upload-result") expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) expect(result).to_have_text( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it.", + _ready_for_sync_line(SOURCE_NAME), timeout=UPLOAD_TIMEOUT_MS, ) diff --git a/tests/e2e/test_pdf_image_context.py b/tests/e2e/test_pdf_image_context.py index 7f45000..cea82d5 100644 --- a/tests/e2e/test_pdf_image_context.py +++ b/tests/e2e/test_pdf_image_context.py @@ -320,10 +320,24 @@ def _health_db(app_url: str) -> bool: return False +def _ready_for_sync_line(source: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync + sources to import it." (the fresh 202 settle). In E2E the stamp + is ALWAYS the same-day ``HH:MM`` form (the run settles within + seconds; the date-qualified forms are unit-pinned on a fixed + clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + + def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None: """The real admin upload flow: form login → the git-sources page → - pick the archive → submit → the terminal result line (phase 90: - "Uploaded — press Sync sources to import it.").""" + pick the archive → submit → the terminal result line (phase 90 A3, + phase 154 task 02: "Uploaded () — press Sync + sources to import it." — the completion stamp).""" login(page, app_url, next="/git-sources.html") expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) @@ -332,7 +346,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None: result = page.locator("#archive-upload-result") expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) expect(result).to_have_text( - f"Uploaded {source} — press Sync sources to import it.", + _ready_for_sync_line(source), timeout=UPLOAD_TIMEOUT_MS, ) diff --git a/tests/e2e/test_pdf_image_extraction.py b/tests/e2e/test_pdf_image_extraction.py index 6767c08..5d99c94 100644 --- a/tests/e2e/test_pdf_image_extraction.py +++ b/tests/e2e/test_pdf_image_extraction.py @@ -379,10 +379,24 @@ def _health_db(app_url: str) -> bool: return False +def _ready_for_sync_line(source: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync + sources to import it." (the fresh 202 settle). In E2E the stamp + is ALWAYS the same-day ``HH:MM`` form (the run settles within + seconds; the date-qualified forms are unit-pinned on a fixed + clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + + def _upload_zip(page: Page, app_url: str, archive: Path, source_name: str) -> None: """The real admin upload flow: form login → the git-sources page → - pick the archive → submit → the terminal result line (phase 90: - "Uploaded — press Sync sources to import it.").""" + pick the archive → submit → the terminal result line (phase 90 A3, + phase 154 task 02: "Uploaded () — press Sync + sources to import it." — the completion stamp).""" login(page, app_url, next="/git-sources.html") expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) @@ -391,7 +405,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path, source_name: str) -> No result = page.locator("#archive-upload-result") expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) expect(result).to_have_text( - f"Uploaded {source_name} — press Sync sources to import it.", + _ready_for_sync_line(source_name), timeout=UPLOAD_TIMEOUT_MS, ) diff --git a/tests/e2e/test_pdf_support.py b/tests/e2e/test_pdf_support.py index a104948..8f146e6 100644 --- a/tests/e2e/test_pdf_support.py +++ b/tests/e2e/test_pdf_support.py @@ -278,10 +278,24 @@ def _health_db(app_url: str) -> bool: return False +def _ready_for_sync_line(source: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync + sources to import it." (the fresh 202 settle). In E2E the stamp + is ALWAYS the same-day ``HH:MM`` form (the run settles within + seconds; the date-qualified forms are unit-pinned on a fixed + clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + + def _upload_zip(page: Page, app_url: str, archive: Path) -> None: """The real admin upload flow: form login → the git-sources page → - pick the archive → submit → the terminal result line (phase 90: - "Uploaded — press Sync sources to import it.").""" + pick the archive → submit → the terminal result line (phase 90 A3, + phase 154 task 02: "Uploaded () — press Sync + sources to import it." — the completion stamp).""" login(page, app_url, next="/git-sources.html") expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) @@ -290,7 +304,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path) -> None: result = page.locator("#archive-upload-result") expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) expect(result).to_have_text( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it.", + _ready_for_sync_line(SOURCE_NAME), timeout=UPLOAD_TIMEOUT_MS, ) diff --git a/tests/e2e/test_pictorial_pdf.py b/tests/e2e/test_pictorial_pdf.py index a2b5a93..185f04f 100644 --- a/tests/e2e/test_pictorial_pdf.py +++ b/tests/e2e/test_pictorial_pdf.py @@ -427,10 +427,24 @@ def _clean_kb(app_url: str) -> Iterator[None]: _truncate_all() +def _ready_for_sync_line(source: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync + sources to import it." (the fresh 202 settle). In E2E the stamp + is ALWAYS the same-day ``HH:MM`` form (the run settles within + seconds; the date-qualified forms are unit-pinned on a fixed + clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + + def _upload_tar(page: Page, app_url: str, archive: Path) -> None: """The real admin upload flow: form login → the git-sources page → - pick the archive → submit → the terminal result line (phase 90: - "Uploaded — press Sync sources to import it.").""" + pick the archive → submit → the terminal result line (phase 90 A3, + phase 154 task 02: "Uploaded () — press Sync + sources to import it." — the completion stamp).""" login(page, app_url, next="/git-sources.html") expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) @@ -439,7 +453,7 @@ def _upload_tar(page: Page, app_url: str, archive: Path) -> None: result = page.locator("#archive-upload-result") expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) expect(result).to_have_text( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it.", + _ready_for_sync_line(SOURCE_NAME), timeout=UPLOAD_TIMEOUT_MS, ) diff --git a/tests/e2e/test_sync_status_freshness.py b/tests/e2e/test_sync_status_freshness.py new file mode 100644 index 0000000..cabe59a --- /dev/null +++ b/tests/e2e/test_sync_status_freshness.py @@ -0,0 +1,769 @@ +"""Phase 154 task 04 E2E (Playwright, mock-only): the two visible +FRESHNESS-STAMP contracts on the live UI (the phase-154 date-qualified +stamps — the same-day shape is pinned here; the non-same-day shapes +are unit-pinned on a fixed clock, so this suite never depends on the +wall clock beyond "the run finishes today"). + +1. **The RAG sync badge settles same-day-shaped**: a fresh mock sync + (a small registered local source, the mock LLM keeps it short) + settles the ``#sync-btn`` label to ``Synced HH:MM`` — the + DATE-QUALIFIER (phase 154, task 01, ``fmtSyncTime``'s widened + contract) must yield the same-day ``HH:MM`` shape for a run that + finished "now". The assertion is a SHAPE pin (the regex + ``^Synced \\d{2}:\\d{2}$`` — the zero-padded 24-hour form + ``fmtSyncTime`` produces for the same local calendar day), never a + comparison against the wall clock's actual value (the + date-qualified forms are unit-pinned on a fixed clock, phase 154, + task 03 — the E2E never depends on the wall clock beyond "the + run finishes today"). +2. **The upload line's fresh settle carries the name AND the stamp**: + a real small fixture archive through the page's upload form → the + 202 "Successfully uploaded — " toast → the 2 s poll's + settle renders the ready-for-sync line + ``Uploaded () — press Sync sources to import it.`` + (the phase-154 task 02 shape, the page-local safe name + + ``fmtUploadStamp``'s completion stamp off the server payload's + ``finished_at`` — the terminal status pin carries the non-null + ``finished_at`` the stamp is computed from). The stamp matches the + shape pin (same-day ``HH:MM`` in practice; the shape regex also + covers the date-qualified forms, so the pin never breaks across a + midnight boundary). +3. **The boot re-render is NAMELESS but still STAMPED** (the phase's + actual fix — the owner's "the line can sit there indefinitely and + mislead"): after the settled fresh line, a page reload boots + ``initUploadStatus``'s success branch — the safe name was + page-local (``lastUploadName`` is null after a reload — the + phase-90 contract), so the line is the NAMELESS + ``Uploaded — press Sync sources to import it.`` — but the + stamp SURVIVES because it comes from the SERVER payload + (``finished_at``), not from page state. The run's identity pin + (``started_at`` unchanged across the reload) proves the re-render + is the same run's line, not a second upload's. +4. **A running upload never renders the ready-for-sync line** (the + phase-90 regression guard, extended): at the 202 moment the button + is the BARE ``Processing…`` (phase 90, A2 — no file, no counts, + title cleared) and ``#archive-upload-result`` is HIDDEN — the line + appears ONLY on settle (the poll's success tick). The + deterministic in-flight window: the 202 handler synchronously + enters the processing state and schedules the first poll tick at + +2 s, so the ``Processing…`` state holds for ≥ 2 s regardless of + how fast the sub-second background run finishes — the + in-flight observation needs no delay proxy (the upload makes zero + LLM calls; the sync leg of test 1 is 2 files and settles within + the generous budget). + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the whole +point is the deterministic mock (no real-LLM dependency), and the +time assertions are shape pins, never wall-clock value comparisons. + +Per-module app env (the conftest pattern — mirroring +``test_upload_no_scan.py``'s local helpers, NOT importing that +module): ``BOR_UPLOAD_DIR`` points at a scratch dir (the app runs on +the same host), ``BOR_GIT_SOURCES`` is forced empty (this suite's +own registered rows / upload rows are the only sync sources), +``BOR_LLM_BASE_URL`` is the mock (the sync leg's embeds + overview), +the retry knobs pinned (the sync leg never burns a 5 s retry wait), +and the autouse ``_clean`` truncates the shared Postgres (plus waits +for no running background job — a leaked run must not survive into +the next test's truncated KB). +""" +from __future__ import annotations + +import io +import os +import re +import subprocess +import sys +import tarfile +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, 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, + SESSION_SECRET, + USE_REAL_LLM, + _wait_http, +) + +REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): in a combined session run the +# conftest session app already owns its port — a second uvicorn on it +# dies on bind and this suite would silently drive the wrong server. +# The module app binds its own port instead (env-overridable). +APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCFRESH", "8172")) +APP_URL = f"http://127.0.0.1:{APP_PORT}" + +SOURCES_URL = "/sources.html" +GIT_SOURCES_URL = "/git-sources.html" + +#: The sync-leg fixture source (a host temp dir, ``kind=local``) — +#: exactly two small markdown files, so the counts line is "2 added" +#: and the run is short against the fast mock LLM. +SYNC_SOURCE = "freshness-sync" +SYNC_FILES: dict[str, str] = { + "alpha.md": ( + "# Freshness alpha\n" + "\n" + "A small note that exists only to give the phase-154 sync\n" + "fixture a real import.\n" + "\n" + "Marker: FRESHNESS-SYNC-ALPHA-7f31\n" + ), + "beta.md": ( + "# Freshness beta\n" + "\n" + "The second small note — the counts line's denominator.\n" + "\n" + "Marker: FRESHNESS-SYNC-BETA-2c90\n" + ), +} + +#: The upload-leg archive basenames (one per upload test — distinct +#: names keep the in-place-replace identity out of the assertions). +UPLOAD_A = "e2e-freshness-a" +UPLOAD_B = "e2e-freshness-b" +UPLOAD_C = "e2e-freshness-c" + + +def _targz_files(name: str) -> dict[str, str]: + """One small deterministic markdown file per archive (a valid + replacement archive — what the KB indexes with it is the sync's + call, phase 90).""" + return { + "note.md": ( + f"# Freshness {name}\n" + "\n" + "A small note carried by this suite's archive.\n" + f"\nMarker: FRESHNESS-{name.upper()}-9d16\n" + ) + } + + +# --------------------------------------------------------------------------- +# Shape pins (the task's contract: shapes, never wall-clock values) +# --------------------------------------------------------------------------- + +#: The date-qualified stamp's shape (phase 154, A1 rule): the +#: same-day ``HH:MM`` (zero-padded — ``fmtSyncTime`` / +#: ``fmtUploadStamp`` pad both fields) OR the date-qualified forms +#: (``M/D(, YYYY), h:MM AM/PM`` — no leading zero on month/day/hour). +#: In practice a run that settles "now" always lands on the same-day +#: branch; the shape regex covers both so the pin never breaks across +#: a midnight boundary. The date-qualified VALUES are unit-pinned on a +#: fixed clock (phase 154, task 03) — this suite never compares a +#: stamp against the actual time. +STAMP = ( + r"\d{2}:\d{2}" + r"|\d{1,2}/\d{1,2}(?:/\d{4})?, \d{1,2}:\d{2} [AP]M" +) + +#: ``Synced HH:MM`` — the same-day settled sync label (task 01: the +#: qualifier yields the zero-padded ``HH:MM`` for a run that finished +#: on the same local calendar day — byte-identical to the pre-phase +#: shape). The date-qualified ``M/D(, YYYY), h:MM AM/PM`` forms are +#: unit-pinned on a fixed clock (phase 154, task 03) — this pin is +#: the same-day shape the task pins; a run that settles "now" always +#: lands on the same-day branch. +SYNCED_SAME_DAY = re.compile(r"^Synced \d{2}:\d{2}$") + +#: The fresh-settle ready-for-sync line WITH the safe name and the +#: stamp (phase 154, task 02 — the page-local ``lastUploadName`` + +#: ``fmtUploadStamp(status)`` off the server payload's +#: ``finished_at``). +def _ready_line(name: str) -> re.Pattern[str]: + return re.compile( + rf"^Uploaded {re.escape(name)} \(({STAMP})\)" + r" — press Sync sources to import it\.$" + ) + + +#: The boot re-render ready-for-sync line — NAMELESS (the safe name +#: was page-local, ``lastUploadName`` is null after a reload — the +#: phase-90 contract) but STAMPED (the stamp comes from the SERVER +#: payload — the phase-154 fix: a line that outlives its upload is +#: self-dating). +NAMELESS_READY_LINE = re.compile( + rf"^Uploaded ({STAMP}) — press Sync sources to import it\.$" +) + +#: Generous settle budgets: the upload run settles in milliseconds +#: (phase 90 — unpack + register only, zero LLM calls) and the UI's +#: 2 s poll lands one tick after; the sync leg is 2 files against the +#: fast mock LLM and may settle between the 2 s poll ticks — the +#: settled-state assertions retry until then. +UPLOAD_TIMEOUT_MS = 30_000 +SYNC_TIMEOUT_MS = 90_000 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _build_targz(path: Path, files: dict[str, str]) -> Path: + """A deterministic ``.tar.gz`` (mtime 0) over the given files.""" + with tarfile.open(path, "w:gz") as tf: + for rel, content in files.items(): + data = content.encode("utf-8") + info = tarfile.TarInfo(rel) + info.size = len(data) + info.mtime = 0 + tf.addfile(info, io.BytesIO(data)) + return path + + +@pytest.fixture(scope="module") +def sync_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """The sync-leg's local source (``kind=local`` — the directory's + basename is the source name, phase 38): EXACTLY the two fixture + files (the counts line reads ``2 added``).""" + root = tmp_path_factory.mktemp("bor_freshness_sync") + src = root / SYNC_SOURCE + src.mkdir() + for rel, content in SYNC_FILES.items(): + (src / rel).write_text(content, encoding="utf-8") + assert len(list(src.rglob("*"))) == len(SYNC_FILES) + return src + + +@pytest.fixture(scope="module") +def upload_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """The app's ``BOR_UPLOAD_DIR`` for this suite — a scratch dir + the host-side assertions inspect (the app server runs on the same + machine). The app creates it on the first upload.""" + return tmp_path_factory.mktemp("bor_freshness_uploads") / "uploads" + + +@pytest.fixture(scope="module") +def tarball_a(tmp_path_factory: pytest.TempPathFactory) -> Path: + root = tmp_path_factory.mktemp("bor_freshness_a") + return _build_targz(root / f"{UPLOAD_A}.tar.gz", _targz_files(UPLOAD_A)) + + +@pytest.fixture(scope="module") +def tarball_b(tmp_path_factory: pytest.TempPathFactory) -> Path: + root = tmp_path_factory.mktemp("bor_freshness_b") + return _build_targz(root / f"{UPLOAD_B}.tar.gz", _targz_files(UPLOAD_B)) + + +@pytest.fixture(scope="module") +def tarball_c(tmp_path_factory: pytest.TempPathFactory) -> Path: + root = tmp_path_factory.mktemp("bor_freshness_c") + return _build_targz(root / f"{UPLOAD_C}.tar.gz", _targz_files(UPLOAD_C)) + + +@pytest.fixture(scope="module") +def app_server( + mock_llm: int, + upload_dir: Path, + tmp_path_factory: pytest.TempPathFactory, +) -> Iterator[str]: + """The real app under test — per-module env: uploads unpack into + a scratch dir, the env git list is forced empty (this suite's own + rows are the only sync sources), and the LLM base URL is the mock + (the upload makes zero LLM calls — phase 90; the mock serves the + sync leg's embeds + overview). No ``slow_llm`` proxy: the upload + run is sub-second and the 2 s poll window is what the in-flight + assertion leans on (see the module docstring's test 4 paragraph). + Skips the module (clear instructions) when Postgres is down — + the sync needs the KB tables.""" + env = dict(os.environ) + env.pop("DEBUGPY", None) + env["BOR_ENVIRONMENT"] = "e2e" + env["BOR_STATIC_DIR"] = str(REPO / "frontend") + env["BOR_LLM_BASE_URL"] = ( + "https://aipi.reeseapps.com/v1" + if USE_REAL_LLM + else f"http://127.0.0.1:{mock_llm}/v1" + ) + # Mock-calibrated 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 — the sync leg never burns a 5 s + # retry wait). + 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 this + # suite's own rows. + env["BOR_GIT_SOURCES"] = "" + # Phase 49: unpack uploads into the suite's scratch dir (host- + # inspectable) and keep the (unused) git checkouts out of the dev + # location. + env["BOR_UPLOAD_DIR"] = str(upload_dir) + env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts")) + 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") + body = httpx.get(f"{APP_URL}/api/health", timeout=5).json() + if body["db"] != "up": + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + 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 per test (the E2E isolation pattern): the + row-count and doc-list assertions must be this test's own doing. + The E2E suites share one Postgres, and a leftover git_sources row + or document would corrupt them (a leftover row would be a SECOND + sync source, skewing the sync's counts). ``sources_meta`` (the KB + generation counter) resets with the KB — the sync re-creates the + row; ``folder_summaries`` / ``steering_notes`` ride along (the + phase-96 pattern).""" + with SessionLocal() as db: + db.execute( + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources, sources_meta, folder_summaries" + ) + ) + db.commit() # without the commit the TRUNCATE rolls back (the house pattern) + + +def _wait_no_running_jobs(app_url: str) -> None: + """No background job may leak across tests (the run states live in + the app's memory, and a still-running sync would keep importing + into the NEXT test's truncated KB, and a still-running upload + would 409 the next test's upload): wait for BOTH status endpoints + to be non-running BEFORE the truncate. Own admin session (the + endpoints are admin-only) — usually a no-op: the tests settle + only after their terminal states.""" + with httpx.Client(timeout=5.0) as client: + client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD}) + for path in ("/api/sync/status", "/api/git-sources/upload/status"): + body: dict[str, Any] = {} + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + r = client.get(f"{app_url}{path}") + if r.status_code == 200: + body = r.json() + if body["state"] != "running": + break + time.sleep(0.2) + assert body["state"] != "running", ( + f"a background job was still running at test boundary: {path} {body}" + ) + + +@pytest.fixture(autouse=True) +def _clean(app_url: str, db_ready: None) -> Iterator[None]: + _wait_no_running_jobs(app_url) + _truncate_all() + yield + _truncate_all() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _register_local_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 _status(page: Page, app_url: str, path: str) -> dict[str, Any]: + """The status endpoint as the signed-in page (admin cookie).""" + r = page.request.get(f"{app_url}{path}") + assert r.status == 200, r.text + return r.json() + + +def _admin_git_sources_page(page: Page, app_url: str) -> None: + """Real form login landing on the git sources page (admin settled: + Sign out visible, the manager revealed by the page module).""" + login(page, app_url, next=GIT_SOURCES_URL) + expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) + expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) + expect(page.locator("#git-sources-gate")).to_be_hidden() + expect(page.locator("#git-sources-content")).to_be_visible() + + +def _upload_via_page(page: Page, archive: Path) -> None: + """Pick the archive and submit the form (the 202 path: the toast + fires at the 202, then the button's status polling settles the + line — the caller asserts the line's shape). Every upload in this + suite succeeds (the failure path is another suite's subject), so + any non-202 outcome here is a test error.""" + page.set_input_files("#archive-upload-file", str(archive)) + page.click("#archive-upload-btn") + toast = page.locator(".toast") + expect(toast).to_have_count(1, timeout=UPLOAD_TIMEOUT_MS) + expect(toast).to_have_class(re.compile(r"\bis-visible\b")) + + +# --------------------------------------------------------------------------- +# 1. The RAG sync badge settles in the same-day shape (the qualifier's +# E2E half — the date-qualified values are unit-pinned, the shape +# is pinned here on a run that finished "now") +# --------------------------------------------------------------------------- + + +def test_fresh_sync_settles_same_day_shape( + page: Page, app_url: str, db_ready: None, sync_dir: Path +) -> None: + """A fresh mock sync (the small registered local source — the mock + LLM keeps the run short) settles the RAG ``#sync-btn`` label to + the same-day ``Synced HH:MM`` shape (phase 154, task 01 — the + qualifier must yield ``HH:MM`` for a run that finished on the + same local calendar day — the SHAPE pin, never a wall-clock value + comparison). The run is real (the counts line ``2 added`` + the + terminal status's non-null ``finished_at`` — the ISO the + badge's stamp is computed from — + 2 of 2 files), the settled + button is re-enabled with the ship aria value restored, and no + error UI is present.""" + page.set_default_timeout(30_000) + _register_local_source(app_url, str(sync_dir)) + login(page, app_url, next=SOURCES_URL) + expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000) + + btn = page.locator("#sync-btn") + expect(btn).to_be_visible(timeout=30_000) + # The settled boot label — the fresh app's idle state (this is the + # module's first test; the app's in-memory sync state is idle). + expect(page.locator("#sync-label")).to_have_text("Sync sources") + expect(btn).to_be_enabled() + expect(page.locator("#sync-error-banner")).to_be_hidden() + + btn.click() + + # The click's immediate state (the 202 moment — the bare prefix in + # the box — phase 149: the running label rides the box, the + # button is the spinner). + 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-progress")).to_have_text("Syncing…") + + # The settle (phase 154, task 01 — the SAME-DAY SHAPE pin): + # "Synced HH:MM" — the date-qualified ``M/D(, YYYY), h:MM AM/PM`` + # forms are unit-pinned on a fixed clock (phase 154, task 03); + # this run finished "now", so the qualifier must yield the + # same-day shape (the shape is asserted; the actual time value is + # never compared against the wall clock). + expect(page.locator("#sync-label")).to_have_text( + SYNCED_SAME_DAY, timeout=SYNC_TIMEOUT_MS + ) + # The run was real (the counts line + the terminal status agree — + # the status is the source of truth for the settle; the UI's 2 s + # poll renders it at most one tick later). + expect(page.locator("#sync-progress-text")).to_have_text( + f"{len(SYNC_FILES)} added", timeout=SYNC_TIMEOUT_MS + ) + btn = page.locator("#sync-btn") + expect(btn).to_be_enabled() + expect(btn).not_to_have_attribute("aria-busy") + # Phase 150: the settle restores the ship aria value (the running + # full label is gone — the badge is the pill's text). + assert btn.get_attribute("aria-label") == "Sync sources", ( + btn.get_attribute("aria-label") + ) + expect(page.locator("#sync-error-banner")).to_be_hidden() + + terminal = _status(page, app_url, "/api/sync/status") + assert terminal["state"] == "success", terminal + # The run was a fresh import of exactly the two fixture files — + # and its ``finished_at`` (non-null) is the ISO the badge's stamp + # is computed from (the stamp's server-side half). + assert terminal["detail"]["added"] == len(SYNC_FILES), terminal + assert terminal["detail"]["pruned"] == 0, terminal + assert terminal["files_done"] == len(SYNC_FILES), terminal + assert terminal["files_total"] == len(SYNC_FILES), terminal + assert terminal["finished_at"], terminal + + +# --------------------------------------------------------------------------- +# 2. The upload line's fresh settle carries the name AND the stamp +# (phase 154, task 02 — the phase-90 no-count contract extended) +# --------------------------------------------------------------------------- + + +def test_upload_line_fresh_settle_carrys_name_and_stamp( + page: Page, + app_url: str, + db_ready: None, + tarball_a: Path, + upload_dir: Path, +) -> None: + """One real upload through the page: the 202 fires the + "Successfully uploaded — " toast (phase 64, A2); the + sub-second background run (unpack + register only — phase 90) + settles at the next 2 s poll tick into the ready-for-sync line + ``Uploaded () — press Sync sources to import it.`` + — the SAFE name (the 202's ``UploadAccepted.name`` — the + phase-90 contract) + the phase-154 COMPLETION STAMP + (``fmtUploadStamp`` off the server payload's ``finished_at`` — + the shape pin: same-day ``HH:MM`` in practice, the date-qualified + forms covered by the shape regex). The never-stale restore + (button + input) lands with the line, the terminal status is the + no-count payload (``{"message": "uploaded"}``, null/0/0 — the + phase-90 A2 key set) with a NON-NULL ``finished_at`` (the stamp's + source), the source row is present (Local badge), and the + unpacked folder exists on the host (the run did its unpack + + register work — zero documents indexed: the scan is the Sync + button's job, phase 90, A1).""" + page.set_default_timeout(30_000) + _admin_git_sources_page(page, app_url) + expect(page.locator("#git-sources-tbody tr")).to_have_count(0) + + btn = page.locator("#archive-upload-btn") + expect(btn).to_be_enabled() + expect(btn).to_have_text("Upload") + + _upload_via_page(page, tarball_a) + + # The 202's toast names the safe source name (phase 64, A2)… + toast = page.locator(".toast") + expect(toast).to_have_attribute("role", "status") + expect(toast).to_have_text(f"Successfully uploaded — {UPLOAD_A}") + # …and the settle: the ready-for-sync line WITH the name and the + # completion stamp (phase 154, task 02 — the shape pin). + result = page.locator("#archive-upload-result") + expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) + expect(result).to_have_text(_ready_line(UPLOAD_A)) + # The stamp is present and non-empty (the regex's capture is the + # stamp itself — the pre-phase string has no "(…)" at all, so a + # match IS the stamp's proof). + m = _ready_line(UPLOAD_A).fullmatch(result.text_content() or "") + assert m is not None, "the settled line did not match the named + stamped shape" + assert m.group(1), "the stamp is empty in the settled line" + # The never-stale restore (the poll's success tick owns it)… + expect(btn).to_be_enabled() + expect(btn).to_have_text("Upload") + expect(page.locator("#archive-upload-file")).to_have_value("") + # …and the terminal status: the no-count payload (phase 90, A2) + # with the NON-NULL finished_at the stamp is computed from. + status = _status(page, app_url, "/api/git-sources/upload/status") + assert status["state"] == "success", status + assert status["detail"] == {"message": "uploaded"}, status + assert status["current_file"] is None, status + assert status["files_done"] == 0 and status["files_total"] == 0, status + assert status["finished_at"], status + assert status["started_at"], status + # The row landed (Local badge) and the folder is on the host — + # but the upload indexed NOTHING (phase 90, A1 — the scan is the + # Sync button's job). + row = page.locator("#git-sources-tbody tr", has_text=UPLOAD_A) + expect(row).to_have_count(1, timeout=30_000) + expect(row.locator("span.git-source-kind")).to_have_text("Local") + assert (upload_dir / UPLOAD_A).is_dir(), ( + f"the unpacked folder is missing: {upload_dir / UPLOAD_A}" + ) + r = page.request.get(f"{app_url}/api/docs") + assert r.status == 200, r.text + assert r.json()["documents"] == [] + + +# --------------------------------------------------------------------------- +# 3. The boot re-render is nameless but STAMPED — the phase's actual +# fix (the stale "Uploaded" line is self-dating after a reload) +# --------------------------------------------------------------------------- + + +def test_upload_line_boot_rerender_stamped_nameless( + page: Page, + app_url: str, + db_ready: None, + tarball_b: Path, +) -> None: + """Seed a settled upload (the fresh named line — the run's + identity pinned on the status's ``started_at``), then RELOAD the + page: the boot re-attach (``initUploadStatus``'s success branch — + phase 64, task 05) re-renders the ready-for-sync line — the SAFE + NAME IS GONE (``lastUploadName`` was page-local, null after a + reload — the phase-90 contract) but the STAMP SURVIVES + (``fmtUploadStamp`` reads the SERVER payload's ``finished_at`` — + phase 154, task 02 — the line is self-dating even with the name + unknown; THIS is the regression the phase fixes: a line that + outlives its upload can no longer masquerade as fresh). The page + boots honest (no error banner, no toast — it fired at the 202 on + the previous document life — the button restored), the list shows + exactly one row for the archive, and the terminal status is the + SAME run (the ``started_at`` is unchanged — no second upload).""" + page.set_default_timeout(30_000) + _admin_git_sources_page(page, app_url) + + # The seed: a settled upload (the fresh named line with the + # stamp — test 2's shape, re-proven here as the pre-reload state). + _upload_via_page(page, tarball_b) + expect(page.locator(".toast")).to_have_text( + f"Successfully uploaded — {UPLOAD_B}" + ) + result = page.locator("#archive-upload-result") + expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) + expect(result).to_have_text(_ready_line(UPLOAD_B)) + # The run's identity (a second upload would reset it — the + # single-run claim is pinned on it). + before = _status(page, app_url, "/api/git-sources/upload/status") + assert before["state"] == "success", before + started_at = before["started_at"] + assert started_at, before + + # The reload (the session cookie survives — the boot re-attach is + # the admin's). + page.reload() + expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) + expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) + expect(page.locator("#git-sources-content")).to_be_visible() + + # The boot re-render: the NAMELESS line WITH the stamp (phase + # 154, task 02 — the stamp from the server payload). The + # nameless shape has NO "(…)" and NO — the regex's match + # IS the nameless+stamped proof (the pre-phase nameless string + # "Uploaded — press …" has no stamp at all). + expect(result).to_have_text(NAMELESS_READY_LINE, timeout=UPLOAD_TIMEOUT_MS) + m = NAMELESS_READY_LINE.fullmatch(result.text_content() or "") + assert m is not None, "the boot re-render line did not match the nameless + stamped shape" + assert m.group(1), ( + "the stamp is empty in the boot re-render line" + ) + # The page boots honest: no error banner, no toast at boot (A2 — + # the toast fired at the 202 on the previous document life), the + # never-stale restore (button re-enabled / "Upload"). + expect(page.locator("#archive-upload-error")).to_be_hidden() + expect(page.locator(".toast")).to_have_count(0) + expect(page.locator("#archive-upload-btn")).to_be_enabled() + expect(page.locator("#archive-upload-btn")).to_have_text("Upload") + # The list shows exactly one row for the archive (the in-place + # identity survived the reload). + expect(page.locator("#git-sources-tbody tr")).to_have_count(1) + row = page.locator("#git-sources-tbody tr", has_text=UPLOAD_B) + expect(row).to_have_count(1) + expect(row.locator("span.git-source-kind")).to_have_text("Local") + # No second upload: the terminal status is the SAME run — its + # started_at is the one from before the reload. + terminal = _status(page, app_url, "/api/git-sources/upload/status") + assert terminal["state"] == "success", terminal + assert terminal["started_at"] == started_at, ( + f"the run's identity changed (a second upload ran): {terminal['started_at']}" + ) + assert terminal["detail"] == {"message": "uploaded"}, terminal + assert terminal["finished_at"], terminal + + +# --------------------------------------------------------------------------- +# 4. A running upload never renders the ready-for-sync line (the +# phase-90 regression guard, extended — the line appears ONLY on +# settle) +# --------------------------------------------------------------------------- + + +def test_running_upload_line_unchanged( + page: Page, + app_url: str, + db_ready: None, + tarball_c: Path, +) -> None: + """At the 202 moment the upload run is in flight: the button is + the BARE "Processing…" (phase 90, A2 — no file, no "(n/m)" + counts, the title cleared) and ``#archive-upload-result`` is + HIDDEN — the ready-for-sync line NEVER renders during the run + (the phase-90 contract: the line appears ONLY on settle). The + deterministic in-flight window: the 202 handler synchronously + enters the processing state and schedules the first poll tick at + +2 s, so the "Processing…" state holds for ≥ 2 s regardless of + how fast the sub-second background run finishes — no delay proxy + is needed (the upload makes zero LLM calls). Then the poll's + success tick settles the line (the NAMED shape with the + completion stamp — the line appears only on settle), with the + never-stale restore and no error UI throughout.""" + page.set_default_timeout(30_000) + _admin_git_sources_page(page, app_url) + + btn = page.locator("#archive-upload-btn") + expect(btn).to_be_enabled() + expect(btn).to_have_text("Upload") + expect(page.locator("#archive-upload-error")).to_be_hidden() + + # The 202 moment (the toast fires at the 202 — the run is in + # flight from here until the poll's success tick). The submit + # handler hid the result line before the fetch (a new attempt + # starts clean), so at the in-flight moment the line is hidden + # even though the shared module app's boot re-render may have + # re-shown the PREVIOUS test's settled line at page load (the + # phase-154 boot re-attach — the line's self-dating contract, + # test 3's subject — not a partial-state regression). + _upload_via_page(page, tarball_c) + expect(page.locator(".toast")).to_have_text( + f"Successfully uploaded — {UPLOAD_C}" + ) + + # The IN-FLIGHT state (the ≥ 2 s window — deterministic): the + # bare "Processing…" label (phase 90, A2), the button disabled + # (the poll owns it — PLAN §7.4), the title cleared, and the + # ready-for-sync line HIDDEN (the line appears ONLY on settle). + expect(btn).to_be_disabled() + expect(btn).to_have_text("Processing…") + expect(btn).to_have_attribute("title", "") + expect(page.locator("#archive-upload-result")).to_be_hidden() + expect(page.locator("#archive-upload-error")).to_be_hidden() + # The in-flight status: the bare payload (phase 90, A2 — the + # run's unpack + register is in flight or just finished; either + # way the line is the poll tick's job, not the in-flight state's). + running = _status(page, app_url, "/api/git-sources/upload/status") + assert running["state"] in ("running", "success"), running + + # The SETTLE (the poll's success tick — the line appears ONLY + # here, and the line is THIS run's — the named shape with the + # completion stamp; the distinct safe name also proves the + # previous test's boot re-render line was replaced, not carried + # through) + the never-stale restore, no error UI. + expect(page.locator("#archive-upload-result")).to_have_text( + _ready_line(UPLOAD_C), timeout=UPLOAD_TIMEOUT_MS + ) + expect(btn).to_be_enabled() + expect(btn).to_have_text("Upload") + expect(page.locator("#archive-upload-error")).to_be_hidden() + status = _status(page, app_url, "/api/git-sources/upload/status") + assert status["state"] == "success", status + assert status["detail"] == {"message": "uploaded"}, status + assert status["finished_at"], status diff --git a/tests/e2e/test_sync_upload_progress.py b/tests/e2e/test_sync_upload_progress.py index 13a8e62..19fef75 100644 --- a/tests/e2e/test_sync_upload_progress.py +++ b/tests/e2e/test_sync_upload_progress.py @@ -16,8 +16,9 @@ unpack → swap → row upsert continues server-side behind pattern; the phase-64 key set with ``current_file``/``files_done``/ ``files_total`` null/0/0 for the whole run — phase 90 A2), and the upload's UI processing state is the BARE "Processing…" (no file, no -"(n/m)") until the no-count "Uploaded — press Sync sources to -import it." result line lands (phase 90 A3). The scan — with its live +"(n/m)") until the no-count "Uploaded () — press Sync +sources to import it." result line lands (phase 90 A3 — the phase-154 +completion stamp). The scan — with its live file label — is the RAG page's **Sync sources** button's job, and the suite proves the new loop: upload → nothing indexed → **the sync that follows the upload shows its live file label and lands the counts**. @@ -159,6 +160,31 @@ SLOW_DELAY_S = "0.15" #: The uploaded archive: 25 small docs under ``docs/``. UPLOAD_NAME = "e2e-prog" + + +def _ready_for_sync_line(name: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync sources + to import it." (the fresh 202 settle, the page-local name). In + E2E the stamp is ALWAYS the same-day ``HH:MM`` form — the run + settles within seconds (the date-qualified forms are unit-pinned + on a fixed clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(name)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + + +def _nameless_ready_for_sync_line() -> re.Pattern[str]: + """Phase 154 (task 02): the boot re-render line — nameless (the + safe name was page-local — ``lastUploadName`` is null after a + reload, phase 90) but SELF-DATING through the completion stamp + (``fmtUploadStamp`` from the server payload): "Uploaded — + press Sync sources to import it." (the same-day ``HH:MM`` form in + E2E).""" + return re.compile( + r"^Uploaded \d{1,2}:\d{2} — press Sync sources to import it\.$" + ) UPLOAD_ARCHIVE = f"{UPLOAD_NAME}.tar.gz" N_FILES = 25 UPLOAD_FILES: dict[str, str] = { @@ -747,7 +773,7 @@ def test_upload_progress_is_bare( # the archive — and the KB empty (no scan, phase 90 A1). result = page.locator("#archive-upload-result") expect(result).to_have_text( - f"Uploaded {UPLOAD_NAME} — press Sync sources to import it.", + _ready_for_sync_line(UPLOAD_NAME), timeout=30_000, ) expect(btn).to_be_enabled() @@ -915,9 +941,12 @@ def test_upload_reattach_after_reload( expect(page.locator("#archive-upload-error")).to_be_hidden() expect(page.locator(".toast")).to_have_count(0) # …and the settled result line: the nameless variant (the safe - # name was page-local — lastUploadName is null after a reload). + # name was page-local — lastUploadName is null after a reload), + # self-dating through the completion stamp (phase 154 — the stamp + # comes from the server payload, so the line is still self-dating + # with the name unknown). expect(page.locator("#archive-upload-result")).to_have_text( - "Uploaded — press Sync sources to import it.", + _nameless_ready_for_sync_line(), timeout=SETTLE_TIMEOUT_MS, ) expect(page.locator("#archive-upload-btn")).to_be_enabled() diff --git a/tests/e2e/test_upload_no_scan.py b/tests/e2e/test_upload_no_scan.py index 596b345..49ec87a 100644 --- a/tests/e2e/test_upload_no_scan.py +++ b/tests/e2e/test_upload_no_scan.py @@ -54,8 +54,9 @@ Contract under test: * **test 1 — upload does not scan**: the "Upload" button (phase 90 A3) → the archive → the 202 "Successfully uploaded — " toast → - the settled result line "Uploaded — press Sync sources to - import it." with the terminal no-count status payload + the settled result line "Uploaded () — press Sync + sources to import it." (the phase-154 completion stamp) with the + terminal no-count status payload (``{"message": "uploaded"}``, null/0/0 progress — phase 90 A2); the source row is present (Local badge + the phase-89 "Ignore paths" control) and its folder exists on the host under ``BOR_UPLOAD_DIR`` @@ -127,6 +128,19 @@ SOURCES_URL = "/sources.html" #: filename minus the archive suffix (the phase-49 naming rule). SOURCE_NAME = "e2e-upload-no-scan" + +def _ready_for_sync_line(name: str) -> re.Pattern[str]: + """Phase 154 (task 02): the ready-for-sync line carrying the + completion stamp — "Uploaded () — press Sync sources + to import it." (the fresh 202 settle, the page-local name). In + E2E the stamp is ALWAYS the same-day ``HH:MM`` form — the run + settles within seconds (the date-qualified forms are unit-pinned + on a fixed clock — E2E never depends on the wall clock).""" + return re.compile( + rf"^Uploaded {re.escape(name)} \(\d{{1,2}}:\d{{2}}\)" + r" — press Sync sources to import it\.$" + ) + #: v1: two sentinel docs + the notes/ file the phase-89 ignore entry #: (``notes``) excludes from the sync. v2 (same basename): beta CHANGED, #: alpha DROPPED, gamma ADDED — the in-place-replace subject (the @@ -394,7 +408,8 @@ def test_upload_does_not_scan( """One real upload through the page: the button reads **Upload** (phase 90 A3); the 202 fires the "Successfully uploaded — " toast; the settled result line points at the next step — "Uploaded - — press Sync sources to import it." (A3) — with the + () — press Sync sources to import it." (A3 — the + phase-154 completion stamp) — with the terminal no-count status payload (``{"message": "uploaded"}``, null/0/0 progress — A2). The source row is present (Local badge + the phase-89 "Ignore paths" control) and its folder exists on the @@ -428,9 +443,7 @@ def test_upload_does_not_scan( # never-stale restore (button + input)… result = page.locator("#archive-upload-result") expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS) - expect(result).to_have_text( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it." - ) + expect(result).to_have_text(_ready_for_sync_line(SOURCE_NAME)) expect(btn).to_be_enabled() expect(btn).to_have_text("Upload") expect(page.locator("#archive-upload-file")).to_have_value("") @@ -507,9 +520,10 @@ def test_ignore_list_then_sync_scans( page.set_default_timeout(30_000) _admin_git_sources_page(page, app_url) - # Upload: the ready-for-sync line, and still nothing indexed. - assert _upload_via_page(page, tarball_v1) == ( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it." + # Upload: the ready-for-sync line (the phase-154 completion + # stamp), and still nothing indexed. + assert _ready_for_sync_line(SOURCE_NAME).fullmatch( + _upload_via_page(page, tarball_v1) ) assert _docs(page, app_url) == [] expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1) @@ -622,8 +636,8 @@ def test_reupload_replaces_without_scan( # Baseline: v1 through the page (202 → the ready-for-sync line, # one row, no index). - assert _upload_via_page(page, tarball_v1) == ( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it." + assert _ready_for_sync_line(SOURCE_NAME).fullmatch( + _upload_via_page(page, tarball_v1) ) expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1) folder = upload_dir / SOURCE_NAME @@ -632,8 +646,8 @@ def test_reupload_replaces_without_scan( # Re-upload v2 — SAME basename, different parent dir (the file # input's selection is replaced wholesale). - assert _upload_via_page(page, tarball_v2) == ( - f"Uploaded {SOURCE_NAME} — press Sync sources to import it." + assert _ready_for_sync_line(SOURCE_NAME).fullmatch( + _upload_via_page(page, tarball_v2) ) # No duplicate: exactly ONE row for that source (and one row diff --git a/tests/unit/test_frontend_sync_upload.py b/tests/unit/test_frontend_sync_upload.py index 0c33d5e..315c5d5 100644 --- a/tests/unit/test_frontend_sync_upload.py +++ b/tests/unit/test_frontend_sync_upload.py @@ -149,6 +149,46 @@ positions are distinct and each disappears independently. The node harness gains the two in-flight image rows (both parts at once — the owner's stuck label — and the image-only pictorial PDF) and pins the composed full string byte-for-byte. + +Phase 154 (task 02) extends the Sources-page upload line with the +COMPLETION STAMP (the phase's date-qualified freshness): the settled +line reads "Uploaded () — press Sync sources to import +it." (the fresh 202 settle, the page-local name) or the nameless +"Uploaded — press Sync sources to import it." (the boot +re-render — the stamp comes from the SERVER payload, so the line is +self-dating even with the name unknown); a running / legacy / partial +payload without timestamps renders the byte-identical pre-phase-154 +strings (the nameful / nameless "Uploaded … — press Sync sources to +import it." lines). The stamp is PRECOMPUTED by the page-local +fmtUploadStamp(status) (the per-page duplication idiom — the same A1 +date-qualifier as sources.js's fmtSyncTime, duplicated, not shared; +the rule's boundary set is pinned in test_sync_button.py on a fixed +clock): it reads status.finished_at first (the success state always +has it on the server), falls back to status.started_at (a defensive +guard — a corrupt finished_at degrades to the started_at stamp rather +than to no stamp), and yields "" when both are absent or every ISO is +unparseable. Both render sites — the poll's success settle and the +boot re-attach — pass fmtUploadStamp(status) as the third argument; +the NO server change (the /upload/status payload is untouched — +started_at / finished_at already shipped, phase 64). + +Phase 154 (task 03) pins the BYTE-IDENTITY of the no-stamp fallbacks +against the VERBATIM pre-phase-154 strings: the old 2-arg +fmtUploadResult (git history) is pinned in this file as a reference +implementation (the name renamed, the body byte-for-byte) and run +side by side with the real 3-arg function under node — the no-stamp +(nameful / nameless) outputs + the legacy 2-arg call are asserted +byte-identical to the reference's output, and the reference's +output to the verbatim old line strings (assertable, not assumed). +Clock-free: `when` is a precomputed argument, so the no-stamp +shapes need no clock at all. The /upload/status full-dict pin +(tests/integration/test_git_sources_upload.py, the phase-64 +precedent) stays UNCHANGED — this phase adds no server keys (the +app/ no-touch regression guard). The qualifier's byte-identity +(same-day + fallbacks vs the verbatim pre-phase fmtSyncTime) is +pinned in test_sync_button.py on the shared fixed clock; the four +shapes and the fmtUploadStamp behavior (finished / started / +missing / garbage) ride the task-02 pins below. """ from __future__ import annotations @@ -1639,23 +1679,42 @@ def test_processing_state_is_bare_for_the_whole_run() -> None: def test_fmt_upload_result_is_the_ready_for_sync_line() -> None: - """Phase 90 (A2/A3): the result line reads the no-count - {"message": "uploaded"} detail and renders "Uploaded — - press Sync sources to import it." (the name from the accepted - 202); without a name (a reload re-render, the 409 re-attach) it - renders the nameless variant; the old sync-style count keys + """Phase 90 (A2/A3) extended by Phase 154 (task 02): the result + line reads the no-count {"message": "uploaded"} detail and renders + the ready-for-sync line in the FOUR phase-154 shapes — + "Uploaded () — press Sync sources to import it." (the + name + the completion stamp), "Uploaded — press Sync sources + to import it." (name, no stamp — BYTE-IDENTICAL to the pre-phase + string), "Uploaded — press Sync sources to import it." (no + name + stamp — the boot re-render, self-dating with the name + unknown), "Uploaded — press Sync sources to import it." (no name, + no stamp — BYTE-IDENTICAL to the pre-phase string; the running / + legacy / partial payload). The non-uploaded detail keeps the + unchanged "The upload finished."; the old sync-style count keys (added / updated / unchanged / pruned) are no longer read at all. The 202 branch records the safe name (lastUploadName); the poll's - settle and the boot re-attach both render the line from - (detail, lastUploadName); the button's idle label is "Upload".""" + settle and the boot re-attach both render the line from (detail, + lastUploadName, fmtUploadStamp(status)); the button's idle label + is "Upload".""" js = _gjs() body = _gfn(js, "fmtUploadResult") - assert "function fmtUploadResult(detail, name)" in body - assert 'detail.message === "uploaded"' in body - assert "Uploaded ${name} — press Sync sources to import it." in body - assert "Uploaded — press Sync sources to import it." in body, ( - "the nameless variant (reload / 409 re-attach)" + assert "function fmtUploadResult(detail, name, when)" in body, ( + "phase 154: the precomputed completion stamp is the third parameter" ) + assert 'detail.message === "uploaded"' in body + # The four shapes (name×stamp, name×no-stamp, no-name×stamp, + # no-name×no-stamp). + assert "Uploaded ${name} (${when}) — press Sync sources to import it." in body + assert "Uploaded ${name} — press Sync sources to import it." in body, ( + "the no-stamp nameful variant — byte-identical to pre-phase" + ) + assert "Uploaded ${when} — press Sync sources to import it." in body, ( + "the nameless + stamp variant (the boot re-render — self-dating)" + ) + assert "Uploaded — press Sync sources to import it." in body, ( + "the nameless no-stamp variant — byte-identical to pre-phase" + ) + assert '"The upload finished."' in body, ("the non-uploaded branch is unchanged") for key in ("added", "updated", "unchanged", "pruned"): assert key not in body, f"the sync-style count {key!r} is gone" # The 202 branch records the safe name for the settled line. @@ -1664,9 +1723,19 @@ def test_fmt_upload_result_is_the_ready_for_sync_line() -> None: i409 = sub.find("r.status === 409") assert "lastUploadName = name" in sub[i202:i409] assert "let lastUploadName = null" in js, "page-local (null after a reload)" - # Both render sites pass (detail, lastUploadName). - assert "fmtUploadResult(detail, lastUploadName)" in _utick(js) - assert "fmtUploadResult(status.detail, lastUploadName)" in _gfn(js, "initUploadStatus") + # Both render sites pass (…, lastUploadName, fmtUploadStamp(status)). + tick = _utick(js) + assert re.search( + r"uploadResult\.textContent = fmtUploadResult\(\s*detail,\s*" + r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)", + tick, + ), "the poll's success settle passes the completion stamp (phase 154)" + init = _gfn(js, "initUploadStatus") + assert re.search( + r"uploadResult\.textContent = fmtUploadResult\(\s*status\.detail,\s*" + r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)", + init, + ), "the boot re-attach passes the completion stamp (phase 154)" # The button's idle label (static + the poll's restores). restore = _gfn(js, "restoreUploadButton") assert 'uploadBtn.textContent = "Upload"' in restore @@ -1751,7 +1820,7 @@ def test_upload_polling_decision_tree() -> None: # next-step announce + reload, NO toast. ok = tick[i_ok:i_fail] for line in ( - "fmtUploadResult(detail, lastUploadName)", + "fmtUploadResult(", "uploadResult.hidden = false", 'announce("Archive uploaded — press Sync sources to import it.")', 'uploadFileInput.value = ""', @@ -1759,6 +1828,11 @@ def test_upload_polling_decision_tree() -> None: "loadSources()", ): assert line in ok, f"the success settle must carry {line!r}" + assert re.search( + r"uploadResult\.textContent = fmtUploadResult\(\s*detail,\s*" + r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)", + ok, + ), "the success settle passes the completion stamp (phase 154)" assert "showUploadToast" not in tick, "no toast in the poll — it fired at the 202 (A2)" # failed: the sanitized error banner + reload. fail = tick[i_fail:] @@ -1815,8 +1889,14 @@ def test_boot_reattach_branches() -> None: assert "uploadError" not in run and "showUploadToast" not in run ok = body[i_ok:i_fail] # The last result line — the nameless variant after a reload - # (lastUploadName is null: the safe name was page-local, phase 90). - assert "fmtUploadResult(status.detail, lastUploadName)" in ok + # (lastUploadName is null: the safe name was page-local, phase 90), + # self-dating through the completion stamp (phase 154 — the stamp + # comes from the server payload). + assert re.search( + r"uploadResult\.textContent = fmtUploadResult\(\s*status\.detail,\s*" + r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)", + ok, + ), "the boot re-attach passes the completion stamp (phase 154)" assert "uploadResult.hidden = false" in ok assert "announce(" not in ok, "no announce at boot (A2)" assert "showUploadToast" not in ok, "no toast at boot (A2)" @@ -1860,3 +1940,330 @@ def test_git_sources_html_comment_documents_the_202_contract() -> None: assert "Phase 90" in comment, "the unpack-only rework is documented" assert "UNPACK + REGISTER ONLY" in comment assert "Sync sources" in comment, "the result line points at the RAG page's button" + + +# ===================================================================== +# Phase 154 (task 02) — the Sources upload line's completion stamp +# ===================================================================== + + +def test_fmt_upload_stamp_is_the_duplicated_qualifier() -> None: + """Phase 154 (task 02): ``fmtUploadStamp(status)`` — the page-local + DUPLICATE of sources.js's ``fmtSyncTime`` date-qualifier (the + per-page duplication idiom — not shared). It reads + ``status.finished_at`` first (the success state always has it on + the server), falls back to ``status.started_at`` (the defensive + guard — a corrupt ``finished_at`` degrades to the ``started_at`` + stamp rather than to no stamp), and yields ``""`` when both are + missing/absent or every ISO is unparseable (the byte-identical + pre-phase-154 fallback). The qualifier is the SAME A1 structure as + the phase-154 task-01 pin in test_sync_button.py — local calendar + dates, ``HH:MM`` same day, ``M/D, h:MM AM/PM`` same year, + ``M/D/YYYY, h:MM AM/PM`` across years, the 12-hour form WITHOUT a + leading zero on the hour, month/day without leading zeros — cited + in sources.js, not restated. The ``status || {}`` guard defends a + missing status object.""" + js = _gjs() + body = _gfn(js, "fmtUploadStamp") + assert "function fmtUploadStamp(status)" in body + assert "const s = status || {};" in body, ( + "defensive: a missing / null status object yields ''" + ) + assert "s.finished_at" in body and "s.started_at" in body + i_fin = body.find("s.finished_at") + i_st = body.find("s.started_at") + assert -1 < i_fin < i_st, "finished_at is read first — the started_at fallback" + assert "if (!iso) continue;" in body, ( + "absent / null timestamps are skipped (the fallback reads)" + ) + assert "if (!Number.isNaN(t.getTime()))" in body, ( + "an unparseable ISO is skipped — a corrupt finished_at degrades" + " to started_at, '' when neither parses" + ) + assert 'if (d === null) return "";' in body, ( + "both absent (or unparseable) → '' — the byte-identical pre-" + "phase-154 fallback" + ) + # The duplicated A1 qualifier (cited in sources.js's fmtSyncTime, + # not restated) — the same structure as the task-01 pin. + assert "const now = new Date();" in body, "'now' is the page's clock" + assert ( + "d.getFullYear() === now.getFullYear() &&" in body + and "d.getMonth() === now.getMonth()" in body + and "d.getDate() === now.getDate()" in body + ), "same local calendar day as now → HH:MM (byte-identical to pre-phase)" + assert "d.getHours() % 12 || 12" in body, ( + "12-hour WITHOUT leading zero on the hour (the 12 o'clock edge)" + ) + assert '"AM"' in body and '"PM"' in body, "the AM/PM family" + assert "pad(d.getMinutes())" in body, "MM zero-padded in every form" + assert "`${d.getMonth() + 1}/${d.getDate()}`" in body, ( + "M/D, no leading zeros (same year, different day)" + ) + assert "`${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`" in body, ( + "M/D/YYYY across years" + ) + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_upload_result_four_shapes_under_node() -> None: + """Phase 154 (task 02): the REAL git-sources.js ``fmtUploadResult`` + under node — the four shapes, ``when`` PRECOMPUTED by the caller + (so the shape itself is clock-free): + + * name + stamp: ``Uploaded docs.zip (21:14) — press Sync sources + to import it.`` (the fresh 202 settle, the page-local name); + * name, no stamp (``""``): ``Uploaded docs.zip — press Sync + sources to import it.`` — BYTE-IDENTICAL to the pre-phase-154 + string (the running / legacy / partial payload); + * no name + stamp: ``Uploaded 10/2, 3:14 PM — press Sync sources + to import it.`` (the boot re-render — self-dating with the name + unknown); + * no name, no stamp (``""``): ``Uploaded — press Sync sources to + import it.`` — BYTE-IDENTICAL to the pre-phase-154 string; + * a legacy 2-arg call (``when`` undefined) renders the pre-phase + nameful string (the fallback is falsy-driven); + * the non-``uploaded`` detail and a missing detail keep the + unchanged ``The upload finished.``.""" + js = _gjs() + fn = _gfn(js, "fmtUploadResult") + script = ( + fn + + "\n" + 'const S = { message: "uploaded" };\n' + 'const O = { message: "other" };\n' + "console.log(JSON.stringify([\n" + ' fmtUploadResult(S, "docs.zip", "21:14"),\n' + ' fmtUploadResult(S, "docs.zip", ""),\n' + ' fmtUploadResult(S, null, "10/2, 3:14 PM"),\n' + ' fmtUploadResult(S, null, ""),\n' + ' fmtUploadResult(S, "docs.zip"),\n' + ' fmtUploadResult(O, "docs.zip", "21:14"),\n' + " fmtUploadResult(null, \"docs.zip\", \"21:14\"),\n" + "]));\n" + ) + rows = json.loads(_run_node(script)) + assert rows == [ + "Uploaded docs.zip (21:14) — press Sync sources to import it.", + # byte-identical to pre-phase (the no-stamp nameful variant): + "Uploaded docs.zip — press Sync sources to import it.", + "Uploaded 10/2, 3:14 PM — press Sync sources to import it.", + # byte-identical to pre-phase (the no-stamp nameless variant): + "Uploaded — press Sync sources to import it.", + # the legacy 2-arg call (when undefined → falsy → pre-phase): + "Uploaded docs.zip — press Sync sources to import it.", + "The upload finished.", + "The upload finished.", + ], rows + + +# ===================================================================== +# Phase 154 (task 03) — the byte-identity pins (the pre-phase +# strings, pinned verbatim — assertable, not assumed) +# ===================================================================== + +#: The pre-phase-154 fmtUploadResult, pinned VERBATIM from git +#: history (the 2-arg no-count formatter — the phase-90 contract +#: pre-stamp). Only the function NAME is renamed (so the real + the +#: reference run side by side under node); the body is the old code +#: byte-for-byte — the two old line strings + "The upload finished." +#: live in this constant, so the byte-identity below is assertable, +#: not assumed. +PRE_PHASE_FMT_UPLOAD_RESULT_JS = """ +function prePhaseUploadResult(detail, name) { + if (detail && detail.message === "uploaded") { + return name + ? `Uploaded ${name} — press Sync sources to import it.` + : "Uploaded — press Sync sources to import it."; + } + return "The upload finished."; +} +""" + +#: The two pre-phase-154 result-line strings, pinned verbatim as +#: constants (the byte-identity targets — the task-03 completion +#: criterion). The nameful one is the old template instantiated with +#: the test's input name. +PRE_PHASE_UPLOAD_LINE_NAMEFUL = "Uploaded docs.zip — press Sync sources to import it." +PRE_PHASE_UPLOAD_LINE_NAMELESS = "Uploaded — press Sync sources to import it." + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_upload_result_no_stamp_is_byte_identical_to_pre_phase() -> None: + """Phase 154 (task 03): the two BYTE-IDENTITY fallbacks — the + REAL 3-arg ``fmtUploadResult`` and the VERBATIM pre-phase 2-arg + formatter (pinned above) run side by side under node (clock-free + — ``when`` is precomputed): + + * name + ``when === ""`` → the pre-phase nameful string, + byte-identical (the running / legacy / partial payload); + * no name + ``when === ""`` → the pre-phase nameless string, + byte-identical (the same payload, the name unknown); + * the legacy 2-arg call (``when`` undefined → falsy) renders the + SAME two byte-identical strings (the old call shape is + unchanged in output); + * the non-``uploaded`` detail (and a missing detail) keep + ``The upload finished.`` on BOTH sides (the branch the phase + never touched). + + The reference implementation's own output is asserted against the + verbatim old strings FIRST (a self-check on the pinned constant — + a typo in the pinned function fails here, not silently), so the + real-vs-reference identity is an assertion against the verbatim + pre-phase strings (the completion criterion), not an + assumption.""" + # Source-level self-check: the pinned constant carries the old + # strings verbatim (both line templates + the non-uploaded + # branch). + assert ( + "return name\n" + " ? `Uploaded ${name} — press Sync sources to import it.`\n" + ' : "Uploaded — press Sync sources to import it.";' + in PRE_PHASE_FMT_UPLOAD_RESULT_JS + ), "the old nameful + nameless line templates are pinned verbatim" + assert '"The upload finished."' in PRE_PHASE_FMT_UPLOAD_RESULT_JS, ( + "the non-uploaded branch is pinned verbatim" + ) + + fn = _gfn(_gjs(), "fmtUploadResult") + script = ( + fn + + "\n" + + PRE_PHASE_FMT_UPLOAD_RESULT_JS + + "\n" + 'const S = { message: "uploaded" };\n' + 'const O = { message: "other" };\n' + "console.log(JSON.stringify({\n" + " REAL: [\n" + ' fmtUploadResult(S, "docs.zip", ""),\n' + " fmtUploadResult(S, null, \"\"),\n" + ' fmtUploadResult(S, "docs.zip"),\n' # legacy 2-arg call + " fmtUploadResult(S, null),\n" # legacy 2-arg call + ' fmtUploadResult(O, "docs.zip", "21:14"),\n' + ' fmtUploadResult(null, "docs.zip", "21:14"),\n' + " ],\n" + " PRE: [\n" + ' prePhaseUploadResult(S, "docs.zip"),\n' + " prePhaseUploadResult(S, null),\n" + ' prePhaseUploadResult(O, "docs.zip"),\n' + ' prePhaseUploadResult(null, "docs.zip"),\n' + " ]\n" + "}));\n" + ) + rows = json.loads(_run_node(script)) + # 1. the pinned reference reproduces the verbatim old strings + # (self-check on the constant — assertable, not assumed). + assert rows["PRE"] == [ + PRE_PHASE_UPLOAD_LINE_NAMEFUL, + PRE_PHASE_UPLOAD_LINE_NAMELESS, + "The upload finished.", + "The upload finished.", + ], rows + # 2. the no-stamp + legacy shapes are byte-identical to the + # reference (i.e. to the verbatim old strings). + assert rows["REAL"][:4] == [ + PRE_PHASE_UPLOAD_LINE_NAMEFUL, # name + when === "" + PRE_PHASE_UPLOAD_LINE_NAMELESS, # no name + when === "" + PRE_PHASE_UPLOAD_LINE_NAMEFUL, # legacy 2-arg call + PRE_PHASE_UPLOAD_LINE_NAMELESS, # legacy 2-arg call + ], rows + assert rows["REAL"][0] == rows["PRE"][0], ( + "the nameful no-stamp shape is byte-identical to pre-phase" + ) + assert rows["REAL"][1] == rows["PRE"][1], ( + "the nameless no-stamp shape is byte-identical to pre-phase" + ) + # 3. the non-uploaded branch is unchanged on both sides. + assert rows["REAL"][4] == rows["PRE"][2] == "The upload finished." + assert rows["REAL"][5] == rows["PRE"][3] == "The upload finished." + + +#: Phase 154 (task 02): the fixed clock for the stamp's boundary set — +#: the SAME fixed now as test_sync_button.py's task-01 pin (Oct 3, +#: 2026, 18:30 LOCAL). The test body never calls `new Date()` for its +#: own timing: the fake clock is installed in the driver (global Date +#: patched BEFORE the call — `new Date()` → the fixed now, +#: `new Date(iso)` → the real parse), and every ISO is +#: designator-less (local) so the comparison is +#: timezone-independent. + +_UPLOAD_STAMP_NOW_JS = "new RealDate(2026, 9, 3, 18, 30, 0)" + + +def _upload_stamp_driver(fn_src: str, expr: str) -> str: + """Run `expr` under node with the REAL git-sources.js function + + the fake clock (the idiom: the Date patch precedes the call).""" + driver = ( + f"{fn_src}\n" + "const RealDate = Date;\n" + f"const NOW = {_UPLOAD_STAMP_NOW_JS};\n" + "globalThis.Date = function (...args) {\n" + " return args.length === 0 ? NOW : new RealDate(...args);\n" + "};\n" + f"{expr}\n" + ) + proc = subprocess.run( + ["node", "-e", driver], capture_output=True, text=True, timeout=60 + ) + assert proc.returncode == 0, f"node failed: {proc.stderr}" + return proc.stdout + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_upload_stamp_behavior_fixed_clock() -> None: + """Phase 154 (task 02): the REAL git-sources.js + ``fmtUploadStamp`` under node (fake clock, the fixed now Oct 3 + 2026 18:30 local — the same clock as test_sync_button.py's + task-01 pin): finished wins over started; started when finished is + missing/null; ``""`` when both are absent (or the status object is + missing — the defensive guard); a garbage ISO → ``""`` with no + fallback (and a corrupt finished_at degrades to the started_at + stamp when a started_at is present — the defensive guard); the A1 + date-qualifier's boundary set (same day ``HH:MM`` — the audit's + own ``21:14`` case + the end-of-day edge; yesterday + ``M/D, h:MM AM/PM`` with the ``12:05 AM`` edge; last year + ``M/D/YYYY, h:MM AM/PM``). The wall clock never appears in an + assertion.""" + fn = _gfn(_gjs(), "fmtUploadStamp") + + def _lit(status: object) -> str: + if status is None: + return "null" + assert isinstance(status, dict) + parts = [] + for k, v in status.items(): + if v is None: + parts.append(f"{k}: null") + else: + parts.append(f'{k}: "{v}"') + return "{" + ", ".join(parts) + "}" + + # (status, expected) — the expected strings are the pins. + cases = [ + ( + {"finished_at": "2026-10-03T16:06:00", "started_at": "2026-10-03T16:05:00"}, + "16:06", # finished wins over started + ), + ({"started_at": "2026-10-02T15:14:00"}, "10/2, 3:14 PM"), # no finished → started + ( + {"finished_at": None, "started_at": "2025-10-03T21:12:00"}, + "10/3/2025, 9:12 PM", # null finished → started, last year + ), + ({"finished_at": "garbage"}, ""), # unparseable, no fallback + ( + {"finished_at": "garbage", "started_at": "2026-10-03T16:06:00"}, + "16:06", # corrupt finished degrades to the started_at stamp + ), + ({"finished_at": None, "started_at": None}, ""), # both absent + ({}, ""), # both missing + (None, ""), # the status object itself missing (the guard) + ({"finished_at": "2026-10-02T00:05:00"}, "10/2, 12:05 AM"), # 12:05 AM edge + ({"finished_at": "2026-10-03T23:59:00"}, "23:59"), # same day, end-of-day + ] + driver_expr = ( + "const CASES = [" + ", ".join(_lit(s) for s, _ in cases) + "];\n" + "const STAMPS = CASES.map((c) => fmtUploadStamp(c));\n" + "console.log(JSON.stringify(STAMPS));" + ) + out = json.loads(_upload_stamp_driver(fn, driver_expr)) + assert out == [expected for _, expected in cases], out diff --git a/tests/unit/test_sync_button.py b/tests/unit/test_sync_button.py index d2e51f4..c5b33a7 100644 --- a/tests/unit/test_sync_button.py +++ b/tests/unit/test_sync_button.py @@ -164,8 +164,11 @@ tests/unit/test_frontend_sync_upload.py's node check). from __future__ import annotations import asyncio +import json import logging import re +import shutil +import subprocess import threading import time from collections.abc import Callable, Iterator, Sequence @@ -198,6 +201,8 @@ RETIRED_ID = "-".join(("sync", "result")) # built at runtime: 3.12+ folds # adjacent/"+" constant strings at compile time, which would leave the # retired name in the .pyc — the phase gate greps even the bytecode. +HAVE_NODE = shutil.which("node") is not None + def _text(path: Path) -> str: assert path.is_file(), f"missing frontend file: {path}" @@ -563,6 +568,10 @@ def test_sources_js_terminal_states() -> None: success = _body(js, "applySyncSuccess") assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success + assert 'settleSyncButton(time ? `Synced ${time}` : "Synced")' in success, ( + "the settle string — phase 154: the qualified stamp rides the SAME" + " shape (the qualifier lives inside the formatter)" + ) failure = _body(js, "applySyncFailure") assert 'settleSyncButton("Sync sources")' in failure # retry-ready @@ -578,6 +587,313 @@ def test_sources_js_terminal_states() -> None: time = _body(js, "fmtSyncTime") assert "getHours()" in time and "getMinutes()" in time, "local HH:MM of finished_at" + assert 'if (Number.isNaN(d.getTime())) return "";' in time, ( + "the parse guard is UNCHANGED — unparseable ISO → "" (the bare" + " 'Synced' settle)" + ) + # Phase 154 (task 01): the DATE-QUALIFIER (A1 rule — the LOCAL + # calendar dates, the page's clock; the table dates' family). + assert "const now = new Date();" in time, "'now' is the page's clock" + assert ( + "d.getFullYear() === now.getFullYear() &&" in time + and "d.getMonth() === now.getMonth()" in time + and "d.getDate() === now.getDate()" in time + ), "same local calendar day as now → HH:MM (byte-identical to pre-phase)" + assert "d.getHours() % 12 || 12" in time, ( + "12-hour WITHOUT leading zero on the hour (the 12 o'clock edge)" + ) + assert '"AM"' in time and '"PM"' in time, "the AM/PM family" + assert "pad(d.getMinutes())" in time, "MM zero-padded in every form" + assert "`${d.getMonth() + 1}/${d.getDate()}`" in time, ( + "M/D, no leading zeros (same year, different day)" + ) + assert "`${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`" in time, ( + "M/D/YYYY across years" + ) + + +# Phase 154 (task 01) — the date-qualifier's boundary set, behavioral +# under node with a FIXED clock. The test body never calls `new +# Date()` for its own timing: the fake clock is installed in the +# driver (global Date patched BEFORE the call — `new Date()` → the +# fixed now, `new Date(iso)` → the real parse), and every ISO is +# designator-less (local) so the comparison is timezone-independent. +# The fixed now is Oct 3, 2026, 18:30 LOCAL. + +_STAMP_NOW_JS = "new RealDate(2026, 9, 3, 18, 30, 0)" + + +def _stamp_driver(fn_src: str, expr: str) -> str: + """Run `expr` under node with the REAL sources.js function + the + fake clock (the idiom: the Date patch precedes the call).""" + driver = ( + f"{fn_src}\n" + "const RealDate = Date;\n" + f"const NOW = {_STAMP_NOW_JS};\n" + "globalThis.Date = function (...args) {\n" + " return args.length === 0 ? NOW : new RealDate(...args);\n" + "};\n" + f"{expr}\n" + ) + proc = subprocess.run( + ["node", "-e", driver], capture_output=True, text=True, timeout=60 + ) + assert proc.returncode == 0, f"node failed: {proc.stderr}" + return proc.stdout + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_sources_js_fmt_sync_time_qualifier_boundaries_fixed_clock() -> None: + """The REAL sources.js ``fmtSyncTime`` under node (fake clock, the + fixed now Oct 3 2026 18:30 local) — the A1 boundary set: + same-day → ``HH:MM`` (byte-identical to the pre-phase output — + the audit's own ``16:06`` case, plus the midnight / end-of-day + edges); yesterday (same year) → ``M/D, h:MM AM/PM`` (no leading + zero on the hour — ``3:14 PM``; the ``12:05 AM`` / ``12:07 PM`` + o'clock edges); last-year same M/D → ``M/D/YYYY, h:MM AM/PM``; + garbage / missing ISO → ``""`` (the guard unchanged). + The ``null`` edge (``new Date(null)`` IS the epoch — parseable, + the guard unchanged) is pinned TZ-independently: ``null`` and the + epoch ISO render identically (same instant), in the dated + year + form (1969 ≠ the fixed 2026). The wall clock never appears in an + assertion.""" + fn = _body(_text(SOURCES_JS), "fmtSyncTime") + # (iso, expected) — the expected strings are the pins. + cases = [ + ("2026-10-03T16:06:00", "16:06"), # same day — the audit case + ("2026-10-03T00:05:00", "00:05"), # same day, midnight edge + ("2026-10-03T23:59:00", "23:59"), # same day, end-of-day edge + ("2026-10-02T15:14:00", "10/2, 3:14 PM"), # yesterday, no zero hour + ("2026-10-02T00:05:00", "10/2, 12:05 AM"), # yesterday, 12:05 AM edge + ("2026-10-02T12:07:00", "10/2, 12:07 PM"), # yesterday, noon edge + ("2026-10-02T23:58:00", "10/2, 11:58 PM"), # yesterday, PM minutes + ("2025-10-03T21:12:00", "10/3/2025, 9:12 PM"), # last year, same M/D + ("2025-01-03T09:12:00", "1/3/2025, 9:12 AM"), # last year, month 1 + ("garbage", ""), # unparseable → the pre-phase fallback + ("", ""), # empty → the pre-phase fallback + ("undefined", ""), # missing ISO (the app-restart case) → "" + ] + iso_literals = [] + for iso, _ in cases: + if iso == "undefined": + iso_literals.append("undefined") + else: + iso_literals.append(f'"{iso}"') + driver_expr = ( + "const CASES = [" + ", ".join(iso_literals) + "];\n" + "const STAMPS = CASES.map((c) => fmtSyncTime(c));\n" + "const EPOCH = [fmtSyncTime(null), fmtSyncTime(new RealDate(0).toISOString())];\n" + "console.log(JSON.stringify({ STAMPS, EPOCH }));" + ) + out = json.loads(_stamp_driver(fn, driver_expr)) + assert out["STAMPS"] == [expected for _, expected in cases], out + assert out["EPOCH"][0] == out["EPOCH"][1], ( + "null and the epoch ISO render identically (the same instant —" + " the guard is unchanged: new Date(null) IS the epoch)" + ) + assert re.fullmatch( + r"\d{1,2}/\d{1,2}/\d{4}, \d{1,2}:\d{2} (AM|PM)", out["EPOCH"][0] + ), "the epoch takes the M/D/YYYY, h:MM AM/PM form (1969 ≠ the fixed year)" + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_sources_js_synced_settle_string_qualifies_the_stamp() -> None: + """The ``applySyncSuccess`` settle string (the pinned shape + ``time ? `Synced ${time}` : "Synced"`` — the qualifier lives + INSIDE the formatter, the shape is unchanged) under node with the + fixed clock: a same-day ``finished_at`` settles as + ``Synced 16:06`` (byte-identical to pre-phase), a non-same-day + one settles as ``Synced 10/2, 3:14 PM`` (the date-qualified + badge — the phase's ask), and an unparseable/missing ISO settles + as the bare ``Synced`` (the existing fallback). The ``null`` edge + pins identically to the boundary test (the epoch — the pre-phase + rendered ``Synced 00:00`` there).""" + fn = _body(_text(SOURCES_JS), "fmtSyncTime") + settle = ( + "const settle = (iso) => {\n" + " const t = fmtSyncTime(iso);\n" + " return t ? `Synced ${t}` : \"Synced\";\n" + "};\n" + ) + iso_literals = [ + '"2026-10-03T16:06:00"', # same day + '"2026-10-02T15:14:00"', # yesterday + '"2025-10-03T21:12:00"', # last year + '"garbage"', # unparseable + "undefined", # missing ISO + ] + driver_expr = ( + settle + + "const CASES = [" + ", ".join(iso_literals) + "];\n" + + "const SETTLES = CASES.map(settle);\n" + + "const EPOCH = [settle(null), settle(new RealDate(0).toISOString())];\n" + + "console.log(JSON.stringify({ SETTLES, EPOCH }));" + ) + out = json.loads(_stamp_driver(fn, driver_expr)) + assert out["SETTLES"] == [ + "Synced 16:06", # same day — byte-identical to pre-phase + "Synced 10/2, 3:14 PM", # yesterday — date-qualified + "Synced 10/3/2025, 9:12 PM", # last year — date + year + "Synced", # garbage — the bare fallback + "Synced", # missing ISO — the bare fallback + ], out + assert out["EPOCH"][0] == out["EPOCH"][1], ( + "settle(null) and the epoch ISO settle identically (the guard" + " unchanged)" + ) + assert out["EPOCH"][0].startswith("Synced "), ( + "the epoch settles with a stamp (new Date(null) is parseable" + " — the pre-phase rendered 'Synced 00:00' there)" + ) + + +# Phase 154 (task 03) — the qualifier's BYTE-IDENTITY: the pre-phase +# fmtSyncTime, pinned VERBATIM from git history (the phase-154 +# pre-state — the time-only formatter). Only the function NAME is +# renamed (so the real + the reference run side by side under node); +# the body is the old code byte-for-byte — the old strings (the +# `HH:MM` return + the `""` guard) live in this constant, so the +# byte-identity below is assertable, not assumed. The reference is +# clock-free (it reads no `now` — only the ISO's local time), but +# the same fixed-clock driver (the task-01 boundary set's clock) is +# used, so the real function's same-day comparison runs on the fixed +# now exactly as in the boundary test. +PRE_PHASE_FMT_SYNC_TIME_JS = """ +function prePhaseSyncTime(iso) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} +""" + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_sources_js_fmt_sync_time_same_day_is_byte_identical_to_pre_phase() -> None: + """Phase 154 (task 03): the REAL sources.js ``fmtSyncTime`` and + the VERBATIM pre-phase formatter (pinned above) run side by side + under node with the fixed clock (Oct 3 2026 18:30 local — the + task-01 boundary set's clock): + + * the pinned reference's own output is asserted against the + verbatim old strings FIRST (a self-check on the constant — a + typo in the pinned function fails here, not silently); + * EVERY same-day ISO renders BYTE-IDENTICALLY to the pre-phase + output (the old ``HH:MM`` — the audit's ``16:06`` case, the + midnight / end-of-day edges), and its settle string is + byte-identical to the pre-phase settle (``"Synced " + HH:MM`` + — a same-day badge never moved); + * EVERY unparseable/missing ISO (``garbage`` / ``""`` / + ``undefined``) renders byte-identically to the pre-phase + ``""`` (the guard unchanged); + * the NON-same-day ISOs (yesterday + last year + the + ``null``/epoch edge — the epoch's local time is a different + day AND a different year, so the qualified form applies; its + value is TZ-dependent, so it is pinned by SHAPE: the + ``M/D/YYYY, h:MM AM/PM`` family vs the old ``HH:MM``) are the + pinned DIVERGENT set — the qualified form is byte-different + from the pre-phase time-only string (the phase's change lives + exactly there). + + The wall clock never appears in an assertion.""" + # Source-level self-check: the pinned constant carries the old + # strings verbatim (the `HH:MM` return + the parse guard). + assert ( + "return `${pad(d.getHours())}:${pad(d.getMinutes())}`;" + in PRE_PHASE_FMT_SYNC_TIME_JS + ), "the old HH:MM return is pinned verbatim" + assert ( + 'if (Number.isNaN(d.getTime())) return "";' in PRE_PHASE_FMT_SYNC_TIME_JS + ), "the old parse guard is pinned verbatim" + + fn = _body(_text(SOURCES_JS), "fmtSyncTime") + # (js literal, category) — category: same / fallback / diverge. + cases = [ + ('"2026-10-03T16:06:00"', "same"), # the audit case + ('"2026-10-03T00:05:00"', "same"), # midnight edge + ('"2026-10-03T23:59:00"', "same"), # end-of-day edge + ('"garbage"', "fallback"), + ('""', "fallback"), # empty ISO + ("undefined", "fallback"), # missing ISO (the app-restart case) + ("null", "diverge"), # the epoch edge — shape-pinned (TZ-dependent) + ('"2026-10-02T15:14:00"', "diverge"), # yesterday + ('"2026-10-02T00:05:00"', "diverge"), + ('"2026-10-02T12:07:00"', "diverge"), + ('"2026-10-02T23:58:00"', "diverge"), + ('"2025-10-03T21:12:00"', "diverge"), # last year + ('"2025-01-03T09:12:00"', "diverge"), + ] + # The verbatim old strings (the pre-phase output — pinned here so + # the identity is assertable); the null/epoch value is + # TZ-dependent, so its old string is pinned by shape, not value. + old = { + '"2026-10-03T16:06:00"': "16:06", + '"2026-10-03T00:05:00"': "00:05", + '"2026-10-03T23:59:00"': "23:59", + '"garbage"': "", + '""': "", + "undefined": "", + } + driver_expr = ( + "const CASES = [" + ", ".join(lit for lit, _ in cases) + "];\n" + "const PRE = CASES.map((c) => prePhaseSyncTime(c));\n" + "const REAL = CASES.map((c) => fmtSyncTime(c));\n" + "const settle = (f) => (iso) => {\n" + " const t = f(iso);\n" + " return t ? `Synced ${t}` : \"Synced\";\n" + "};\n" + "const PRE_SETTLE = CASES.map((c) => settle(prePhaseSyncTime)(c));\n" + "const REAL_SETTLE = CASES.map((c) => settle(fmtSyncTime)(c));\n" + "console.log(JSON.stringify({ PRE, REAL, PRE_SETTLE, REAL_SETTLE }));" + ) + out = json.loads( + _stamp_driver(fn + "\n" + PRE_PHASE_FMT_SYNC_TIME_JS, driver_expr) + ) + # 1. the pinned reference reproduces the verbatim old strings + # (self-check on the constant — assertable, not assumed). + for i, (lit, _cat) in enumerate(cases): + if lit in old: + assert out["PRE"][i] == old[lit], ( + f"the pre-phase reference for {lit} must be the verbatim old" + f" string {old[lit]!r} — got {out['PRE'][i]!r} (a typo in the" + " pinned constant?" + ) + null_i = [lit for lit, _ in cases].index("null") + assert re.fullmatch(r"\d{2}:\d{2}", out["PRE"][null_i]), ( + "the null/epoch edge takes the old HH:MM shape on the reference" + " side (the pre-phase is time-only — the value is TZ-dependent," + " the shape is not)" + ) + assert out["PRE_SETTLE"][null_i] == "Synced " + out["PRE"][null_i] # old settle + assert re.fullmatch( + r"\d{1,2}/\d{1,2}/\d{4}, \d{1,2}:\d{2} (AM|PM)", out["REAL"][null_i] + ), ( + "the null/epoch edge takes the M/D/YYYY, h:MM AM/PM shape on the" + " real side (the epoch is a different day + year — the qualified" + " form; the value is TZ-dependent, the shape is not)" + ) + assert out["REAL_SETTLE"][null_i].startswith("Synced ") + # 2. the audit case's settle is byte-identical to pre-phase. + audit_i = [lit for lit, _ in cases].index('"2026-10-03T16:06:00"') + assert out["REAL_SETTLE"][audit_i] == "Synced 16:06", ( + "the same-day settle is byte-identical to the pre-phase string" + ) + # 3. same-day + fallbacks: byte-identical to the reference, settle + # included; the divergent set: byte-different (the qualified form). + for i, (lit, cat) in enumerate(cases): + if cat == "diverge": + assert out["REAL"][i] != out["PRE"][i], ( + f"{lit} must DIFFER from the pre-phase form (the qualified" + f" branch) — got {out['REAL'][i]!r} == {out['PRE'][i]!r}" + ) + assert out["REAL_SETTLE"][i] != out["PRE_SETTLE"][i] + else: + assert out["REAL"][i] == out["PRE"][i], ( + f"{lit} must be byte-identical to the pre-phase output" + f" (category {cat}) — got {out['REAL'][i]!r} vs" + f" {out['PRE'][i]!r}" + ) + assert out["REAL_SETTLE"][i] == out["PRE_SETTLE"][i] def test_sources_js_failed_error_is_sanitized() -> None: