phase: 150_drop_sync_result
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 13s

**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass.

- Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP.
- Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests).
- Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`).
- Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed.
- Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched.
- Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
This commit is contained in:
2026-10-02 02:35:03 -04:00
parent 9d4c813cae
commit edf6048a0d
59 changed files with 2719 additions and 476 deletions
+2 -2
View File
@@ -355,7 +355,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
"""The RAG page's "Sync sources" button (the scan — phase 90 A3):
click → "Syncing…" → the terminal "Synced HH:MM" label + the fresh
counts in #sync-result (the never-stale contract)."""
counts in #sync-progress (the never-stale contract)."""
page.goto(app_url + "/sources.html")
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
@@ -367,7 +367,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text(expected_result)
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
def _seed_image_doc(
+10 -7
View File
@@ -50,8 +50,9 @@ Test → contract mapping (two tests):
holds ``doc.md``) → the force push (V2) → run 2 (``state ==
"success"`` — NOT ``failed``; the UI settles to "Synced HH:MM"
with the error banner HIDDEN and no error modal — a reset is a
LOGGED event, not a user-facing failure; ``#sync-result`` shows
"0 added · 1 updated" and the document modal serves the V2
LOGGED event, not a user-facing failure; the ``#sync-progress``
box shows "0 added · 1 updated" (phase 150 — the single sync text
surface) and the document modal serves the V2
sentinel — the reset's content flowed through the import; the
counts are the secondary signal, the content is primary) → run 3
(idempotent: "0 added · 1 unchanged" — the checkout is stably at
@@ -480,8 +481,9 @@ def test_sync_survives_a_force_push(
expect(page.locator("#sync-label")).to_have_text("Syncing…")
body = _wait_sync_state(page, app_url, clicked_at=clicked_at)
assert body["state"] == "success", body
# The fixture doc (one A9-format file) is the only change.
expect(page.locator("#sync-result")).to_have_text("1 added")
# The fixture doc (one A9-format file) is the only change — the
# counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("1 added")
# The REAL clone was imported: the fixture path is in the Sources
# table (the drill-down tree, the test_sync_button pattern).
_expect_fixture_row(page)
@@ -508,8 +510,8 @@ def test_sync_survives_a_force_push(
expect(page.locator(".sync-modal-backdrop")).to_have_count(0)
# …and the digest delta re-indexed the file as updated (the counts
# are the secondary signal — the content assertion below is
# primary).
expect(page.locator("#sync-result")).to_have_text("0 added · 1 updated")
# primary). The counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 updated")
# The KB holds the NEW head: the document modal serves the V2
# sentinel — the reset's content flowed through the import.
_expect_fixture_row(page)
@@ -530,7 +532,8 @@ def test_sync_survives_a_force_push(
assert body["state"] == "success", body
# "1 unchanged" means the content hash did not move — drift back
# to V1 would have re-reported as "1 updated" (the no-drift pin).
expect(page.locator("#sync-result")).to_have_text("0 added · 1 unchanged")
# The counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged")
# ---------------------------------------------------------------------------
+11 -7
View File
@@ -513,8 +513,9 @@ def test_add_with_branch_syncs_that_branch(
body = sync_and_wait(page, app_url)
assert body["state"] == "success", body
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
# Both branch docs are fresh after the per-test truncate.
expect(page.locator("#sync-result")).to_have_text("2 added")
# Both branch docs are fresh after the per-test truncate — the
# counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
# --- the branch's content is what got indexed ----------------------
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
@@ -537,8 +538,8 @@ def test_add_with_branch_syncs_that_branch(
assert body["state"] == "success", body
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
# Nothing re-embeds (sha256 delta) — the no-op run announces the
# unchanged count (release's two files).
expect(page.locator("#sync-result")).to_have_text("0 added · 2 unchanged")
# unchanged count (release's two files) on the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 2 unchanged")
# The branch's doc survived the prune re-import.
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
@@ -574,7 +575,9 @@ def test_default_branch_source_unaffected(
body = sync_and_wait(page, app_url)
assert body["state"] == "success", body
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
expect(page.locator("#sync-result")).to_have_text("2 added")
# The default branch's two docs are fresh — the counts line rides
# the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
# The default branch (main) is what landed — the pre-phase
# byte-identical guarantee, observable through the UI…
@@ -679,8 +682,9 @@ def test_row_editor_branch_change_fails_loud(
assert body["state"] == "success", body
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
# The KB is still empty (the failed sync aborted before the
# import) — the restored branch's two docs are fresh additions.
expect(page.locator("#sync-result")).to_have_text("2 added")
# import) — the restored branch's two docs are fresh additions;
# the counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
# And the branch's doc is what is indexed again.
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
+3 -2
View File
@@ -328,8 +328,9 @@ def _sync_in_app(page: Page, app_url: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
# Both fixture docs are fresh after the per-test truncate.
expect(page.locator("#sync-result")).to_have_text("2 added")
# Both fixture docs are fresh after the per-test truncate — the
# counts line rides the box (phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
def _source_node(tree_json: dict[str, Any]) -> dict[str, Any]:
+2 -2
View File
@@ -316,7 +316,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None:
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
"""The RAG page's "Sync sources" button (the scan — phase 90 A3):
click → "Syncing…" → the terminal "Synced HH:MM" label + the fresh
counts in #sync-result (the never-stale contract)."""
counts in #sync-progress (the never-stale contract)."""
page.goto(app_url + "/sources.html")
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
@@ -328,7 +328,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text(expected_result)
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
def _captured_systems(mock_llm: int) -> list[str]:
+2 -2
View File
@@ -253,7 +253,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
"""The RAG page's "Sync sources" button (the scan — phase 90 A3):
click → "Syncing…" → the terminal "Synced HH:MM" label + the fresh
counts in #sync-result (the never-stale contract, test_git_source_
counts in #sync-progress (the never-stale contract, test_git_source_
dates' lifecycle wait)."""
page.goto(app_url + "/sources.html")
btn = page.locator("#sync-btn")
@@ -266,7 +266,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text(expected_result)
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
def _drill_to_source(page: Page, name: str) -> None:
+2 -2
View File
@@ -328,7 +328,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None:
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
"""The RAG page's "Sync sources" button (the scan): click →
"Syncing…" → the terminal "Synced HH:MM" label + the fresh counts
in #sync-result (the never-stale contract)."""
in #sync-progress (the never-stale contract)."""
page.goto(app_url + "/sources.html")
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
@@ -340,7 +340,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text(expected_result)
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
def _ask(page: Page, app_url: str, question: str) -> None:
+2 -2
View File
@@ -286,7 +286,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
"""The RAG page's "Sync sources" button (the scan): click →
"Syncing…" → the terminal "Synced HH:MM" label + the fresh counts
in #sync-result (the never-stale contract)."""
in #sync-progress (the never-stale contract)."""
page.goto(app_url + "/sources.html")
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
@@ -298,7 +298,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text(expected_result)
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
def _drill_to_source(page: Page, name: str) -> None:
+2 -2
View File
@@ -447,7 +447,7 @@ def _upload_tar(page: Page, app_url: str, archive: Path) -> None:
def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
"""The RAG page's "Sync sources" button (the scan): click →
"Syncing…" → the terminal "Synced HH:MM" label + the fresh counts
in #sync-result (the never-stale contract)."""
in #sync-progress (the never-stale contract)."""
page.goto(app_url + "/sources.html")
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
@@ -459,7 +459,7 @@ def _sync_via_ui(page: Page, app_url: str, expected_result: str) -> None:
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text(expected_result)
expect(page.locator("#sync-progress-text")).to_have_text(expected_result)
def _drill_to_source(page: Page, name: str) -> None:
+7 -4
View File
@@ -270,8 +270,10 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
# The fixture doc (one A9-format file) is the only change.
expect(page.locator("#sync-result")).to_have_text("1 added")
# The fixture doc (one A9-format file) is the only change — the
# settle's counts line rides the box (phase 150: the box is the
# sole sync text surface).
expect(page.locator("#sync-progress-text")).to_have_text("1 added")
# The REAL clone was imported: the fixture path is in the Sources
# table (the sentinel lives inside it) — via the drill-down tree
@@ -293,8 +295,9 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
# Nothing re-embedded (sha256 delta) — the no-op run announces the
# unchanged count instead of an empty live region.
expect(page.locator("#sync-result")).to_have_text("0 added · 1 unchanged")
# unchanged count instead of an empty live region (the box keeps
# its result line — phase 150).
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged")
# The doc survived the prune re-import (its file is still in the
# repo) — via the drill-down tree (phase 97).
_expect_fixture_row(page)
+4 -2
View File
@@ -544,7 +544,9 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
expect(btn).to_be_enabled() # retry-ready
expect(btn).not_to_have_attribute("aria-busy")
expect(cancel).to_be_hidden()
expect(page.locator("#sync-result")).to_have_text(
# Phase 150: the neutral result line rides the box (the sole sync
# text surface — the retired announcer span is gone).
expect(page.locator("#sync-progress-text")).to_have_text(
"Sync cancelled", timeout=SYNC_SETTLE_TIMEOUT_MS
)
# A1: the cancel is never rendered as a failure — no error banner,
@@ -568,7 +570,7 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
# content-hash delta (already-indexed files re-import unchanged,
# the rest are added; the interrupted file's uncommitted
# transaction rolled back).
line = page.locator("#sync-result").inner_text()
line = page.locator("#sync-progress-text").inner_text()
m = RESULT_LINE.fullmatch(line)
assert m, f"unexpected result line: {line!r}"
added = int(m.group(1))
+8 -5
View File
@@ -19,7 +19,8 @@ bump ``indexed_at``); a source change syncs ONLY the changed part (new
file → ``1 added``; modified file → ``1 updated`` with only its own
row moving). NO PDF here — the unit matrix (task 02) carries the
vision-skip proof; this suite pins the WIRE (the owner's visible
``#sync-result`` line) + the UI settle + the row-level proof, fast and
``#sync-progress`` result line — phase 150: the box is the single sync
text surface) + the UI settle + the row-level proof, fast and
deterministic.
Per-module app env (the E2E conftest pattern, module-scoped): this
@@ -259,11 +260,13 @@ def _click_sync_and_wait_settle(page: Page) -> None:
def _expect_settle_line(page: Page, expected: str) -> None:
"""The owner's surface: the VISIBLE ``#sync-result`` text in its
house count shape (sources.js's fmtSyncResult — the zero
"""The owner's surface: the VISIBLE ``#sync-progress`` box text in
its house count shape (sources.js's fmtSyncResult — the zero
"updated" part is omitted: ``0 added · N unchanged``,
``1 added · N unchanged``, ``0 added · 1 updated · N unchanged``)."""
expect(page.locator("#sync-result")).to_have_text(
``1 added · N unchanged``, ``0 added · 1 updated · N unchanged``).
Phase 150: the box IS the result line's surface (the retired
announcer span is gone)."""
expect(page.locator("#sync-progress-text")).to_have_text(
expected, timeout=SYNC_TIMEOUT_MS
)
+27 -21
View File
@@ -100,8 +100,9 @@ Test → observable mapping (Playwright Mapping Rule):
``.sync-label-main`` == ``Syncing… chunkprog/big.md``,
``.sync-label-count`` == ``(1/2)``, and ``.sync-label-chunk``
VISIBLE matching ``· chunk \\d+/\\d+`` with the total == the
expected chunk count — and the button ``title`` (and
``#sync-result``, the aria-live announcer) carries the UNTRUNCATED
expected chunk count — and the button ``title`` (and the
``#sync-progress`` box — the aria-live announcer, phase 150: the
box is the single sync text surface) carries the UNTRUNCATED
concatenation main + " " + count + " " + chunk (A4 — asserted
atomically in the same in-browser evaluation, so the parts + title
can never straddle a poll tick); after the file's embed span the
@@ -110,8 +111,8 @@ Test → observable mapping (Playwright Mapping Rule):
hidden + empty while the label itself keeps rendering in sync
(the title tracks the rendered parts, A4); on settle the button
reads the terminal ``Synced HH:MM`` (the phase-32 contract) with
the count + chunk parts hidden and ``#sync-result`` carrying the
counts line.
the count + chunk parts hidden and the ``#sync-progress`` box
carrying the counts line (phase 150).
"""
from __future__ import annotations
@@ -314,7 +315,8 @@ def _dedup_consecutive(values: list[int]) -> list[int]:
#: untruncated A4 text — the big file is the first of the two, so the
#: count part is "(1/2)"), the pinned chunk part is visible matching
#: "· chunk n/m" with m == the expected chunk count (the arg), and the
#: button title + #sync-result carry the UNTRUNCATED concatenation
#: button title + the #sync-progress box (the aria-live announcer,
#: phase 150) carry the UNTRUNCATED concatenation
#: (main + " " + count + " " + chunk — A4: the announcer hears the
#: whole "Syncing… <file> (1/2) · chunk n/m").
_JS_IMPORT_CHUNK_SNAPSHOT = """
@@ -332,14 +334,14 @@ _JS_IMPORT_CHUNK_SNAPSHOT = """
if (!m || Number(m[2]) !== n) return null;
const full = main.textContent + ' ' + count.textContent + ' ' + chunk.textContent;
if (btn.getAttribute('title') !== full) return null;
const result = document.querySelector('#sync-result');
if (!result || result.textContent !== full) return null;
const boxText = document.querySelector('#sync-progress-text');
if (!boxText || boxText.textContent !== full) return null;
return {
mainText: main.textContent,
countText: count.textContent,
chunkText: chunk.textContent,
title: btn.getAttribute('title'),
resultText: result.textContent,
boxText: boxText.textContent,
};
}
"""
@@ -874,15 +876,16 @@ def test_label_shows_the_chunk_part(
and the VISIBLE chunk part matching ``· chunk n/m`` with the total
== the expected chunk count (the big file's span is ~45 s under
the slow proxy — the poll cannot miss it) — with the button
``title`` + ``#sync-result`` carrying the UNTRUNCATED
concatenation (A4, asserted atomically in the same in-browser
evaluation). After the file's embed span, once the label has left
the import phase (overview / summaries / terminal), the chunk part
is CLEARED — hidden + empty — while the label keeps rendering in
sync (the title tracks the rendered parts); on settle the button
reads the terminal ``Synced HH:MM`` (the phase-32 contract) with
the count + chunk parts hidden and ``#sync-result`` carrying the
counts line."""
``title`` + the ``#sync-progress`` box (the aria-live announcer,
phase 150) carrying the UNTRUNCATED concatenation (A4, asserted
atomically in the same in-browser evaluation). After the file's
embed span, once the label has left the import phase (overview /
summaries / terminal), the chunk part is CLEARED — hidden + empty
— while the label keeps rendering in sync (the title tracks the
rendered parts); on settle the button reads the terminal
``Synced HH:MM`` (the phase-32 contract) with the count + chunk
parts hidden and the ``#sync-progress`` box carrying the counts
line (phase 150)."""
page.set_default_timeout(30_000)
big_content = (chunkprog_dir / "big.md").read_text(encoding="utf-8")
n_chunks = len(_expected_big_chunks(big_content))
@@ -893,8 +896,8 @@ def test_label_shows_the_chunk_part(
# Step 1 — the import span with the chunk part (the page's own 2 s
# poll; the big file's ~45 s span outlives the poll ~20×). The
# in-browser snapshot verifies the parts + the title +
# #sync-result TOGETHER (one JS task per poll tick — the atomic
# check can never straddle one) and returns the texts.
# #sync-progress-text TOGETHER (one JS task per poll tick — the
# atomic check can never straddle one) and returns the texts.
snap = page.wait_for_function(
_JS_IMPORT_CHUNK_SNAPSHOT, arg=n_chunks, timeout=SETTLE_TIMEOUT_MS
).json_value()
@@ -908,7 +911,9 @@ def test_label_shows_the_chunk_part(
# aria-live announcer (the "· chunk" part included).
full = f"Syncing… {SOURCE}/big.md (1/2) {snap['chunkText']}"
assert snap["title"] == full and "· chunk" in snap["title"], snap
assert snap["resultText"] == full, snap
# The box IS the announcer (phase 150) — it carries the same full
# string the title does.
assert snap["boxText"] == full, snap
# Step 2 — after the file's embed span: the chunk part is CLEARED
# (hidden + empty) once the label has left the import phase — the
@@ -942,5 +947,6 @@ def test_label_shows_the_chunk_part(
expect(page.locator(".sync-label-count")).to_have_text("")
expect(page.locator(".sync-label-chunk")).to_be_hidden()
expect(page.locator(".sync-label-chunk")).to_have_text("")
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
# The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
expect(page.locator("#sync-error-banner")).to_be_hidden()
+14 -7
View File
@@ -21,8 +21,10 @@ in the pill even on desktop, and the fix moves the running detail
out of the pill entirely — while a run is in flight the button is
the spinner (the ``is-running`` class; task 02's CSS hides the label
parts), and a detail box under the button row (``#sync-progress``)
carries the SAME full untruncated label — wrapping, never clipped,
``#sync-result`` staying the sole aria-live announcer. Tests 1–3
carries the SAME full untruncated label — wrapping, never clipped.
Phase 150 folded the announcer into the box: it is now the SOLE
sync text surface AND the sole aria-live region (the retired
announcer span is gone). Tests 1–3
therefore pin the BOX: visible while the run is in flight, its text
the pre-phase byte-identical full string ("Syncing… <full
source/relative/path> (n/4)" / "Summarizing folders… <folder>
@@ -30,8 +32,10 @@ source/relative/path> (n/4)" / "Summarizing folders… <folder>
inside the viewport at 390 px and 1280 px, the button in its
running state, and the ``title`` carrying the same full string
(A4 — one source, two surfaces). The SETTLED pins (the phase-32
"Synced HH:MM" label, the count span hidden + empty, the box hidden)
are UNCHANGED. Test 4 (the pre-phase-128 rules' clip reproduction)
"Synced HH:MM" label, the count span hidden + empty) are UNCHANGED;
phase 150 re-pointed the box pin — the box STAYS visible at settle
to carry the counts line (the fmtSyncResult string the retired
announcer spoke, byte-identical). Test 4 (the pre-phase-128 rules' clip reproduction)
pins the old markup/CSS counterfactually — the label parts still
receive their text under any CSS (the writer is unchanged), so it
holds on the DOM text + injected rules regardless of the phase-149
@@ -679,9 +683,12 @@ def _settle(page: Page, app_url: str) -> None:
count = page.locator(".sync-label-count")
expect(count).to_be_hidden()
expect(count).to_have_text("")
# Phase 149 (task 01): the detail box is run-scoped — the settle
# (the writer's default running = false) hides it again.
expect(page.locator("#sync-progress")).to_be_hidden()
# Phase 150: the box is NOT run-scoped — it STAYS visible at
# settle to carry the counts line (the fmtSyncResult shape the
# retired announcer spoke, byte-identical: the fresh 4-file
# fixture's "4 added"), hiding only when its text is empty.
expect(page.locator("#sync-progress")).to_be_visible()
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
expect(page.locator("#sync-error-banner")).to_be_hidden()
+3 -2
View File
@@ -453,7 +453,8 @@ def test_healthy_sync_still_succeeds(
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
expect(page.locator("#sync-result")).to_have_text("1 added")
# The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text("1 added")
# The REAL clone was imported: the fixture path is in the Sources
# table (the sentinel lives inside it) — via the drill-down tree
@@ -474,7 +475,7 @@ def test_healthy_sync_still_succeeds(
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text("0 added · 1 unchanged")
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged")
# The doc survived the prune re-import — via the drill-down tree
# (phase 97).
_expect_fixture_row(page)
+419 -101
View File
@@ -1,21 +1,26 @@
"""Phase 149 task 03 E2E (Playwright, mock-only): the owner's ask, pinned
end to end — while a sync run is in flight, the **button is the loading
spinner** (``is-running`` hides the label parts; the spinning icon is the
whole control) and a NEW bordered text box directly under the button row
(``#sync-progress``) carries the **EXACT full untruncated live label** —
``Syncing… <source/relative/path> (n/m) · chunk n/m`` — wrapping, never
clipping (the long ``source/relative/path`` that overflows the pill is the
fixture's whole reason to exist). At settle the box hides, the pill is
restored byte-identical to pre-phase (``Synced HH:MM`` + the ``fmtSyncResult``
counts line on ``#sync-result``), and ``#sync-result`` stayed the SOLE
aria-live announcer the whole time (the box is presentational,
``aria-hidden``).
"""Phase 149 task 03 / phase 150 task 05 E2E (Playwright, mock-only):
the owner's ask, pinned end to end — while a sync run is in flight,
the **button is the loading spinner** (``is-running`` hides the label
parts; the spinning icon is the whole control) and the bordered text
box directly under the button row (``#sync-progress``) carries the
**EXACT full untruncated live label** — ``Syncing…
<source/relative/path> (n/m) · chunk n/m`` — wrapping, never clipping
(the long ``source/relative/path`` that overflows the pill is the
fixture's whole reason to exist). Phase 150 folded the announcer into
the box: it is the **single sync text surface AND the sole aria-live
region** (``role="status" aria-live="polite"``, no ``aria-hidden`` —
the retired announcer span is gone from the DOM), and
at settle it **stays visible** to carry the result line (the
``fmtSyncResult`` counts line, "Sync cancelled" — byte-identical to
the pre-phase announcer's strings), hiding only when its text is empty
(idle, upload settles, failures — the banner + modal carry the error).
The dedicated story suite for ``149_sync_progress_box`` (A16 — one
Playwright file per phase, run in isolation). Tasks 01/02 built the box +
its wiring + its CSS; this suite is the behavioral proof through the REAL
sync path: a real ``git clone`` of a local ``file://`` fixture repo, a real
import, the slowed mock LLM so the in-flight state is observable.
The dedicated story suite for ``149_sync_progress_box`` / phase 150
(A16 — one Playwright file per phase, run in isolation). Phase 149's
tasks 01/02 built the box + its wiring + its CSS; this suite is the
behavioral proof through the REAL sync path: a real ``git clone`` of a
local ``file://`` fixture repo, a real import, the slowed mock LLM so
the in-flight state is observable.
Run in isolation (DB must be up: ``podman compose up -d db``; git on PATH —
a documented environment prerequisite, phase 28):
@@ -73,34 +78,46 @@ the first clone).
Test → observable mapping (Playwright Mapping Rule — the rendered DOM is
the observable; every in-flight snapshot is ONE in-browser evaluation that
verifies the awaited label state and reads the numbers together, so they
can never straddle a 2 s poll tick — the single ``setSyncLabel`` writer
updates the box + the button's aria-label/title + ``#sync-result`` in one
JS task):
can never straddle a 2 s poll tick — the single ``setSyncProgress`` /
``setSyncLabel`` writers update the box + the button's aria-label/title
in one JS task):
1. ``test_running_button_is_spinner_only`` — click **Sync sources**; poll
1. ``test_idle_page_one_live_region_box_hidden`` — a page load with NO
run in flight (this test runs FIRST — the module app has never run a
sync, so the boot re-attach settles idle): the template pins hold —
the retired announcer span is ABSENT from the DOM (the retirement
is pinned, not implied), the sync view (``#view-rag``) carries
EXACTLY ONE
explicit ``[aria-live]`` element and it is ``#sync-progress``
(``role="status"``, ``aria-live="polite"``, NO ``aria-hidden`` —
the two are mutually exclusive by intent), and the box is hidden +
empty (the anonymous-safe idle state).
2. ``test_running_button_is_spinner_only`` — click **Sync sources**; poll
until ``#sync-progress`` is visible (a run is in flight): ``#sync-btn``
carries ``is-running`` AND the icon ``is-spinning`` (the spinner IS the
button), ``#sync-label`` is NOT visible (the label parts are hidden —
the button is the icon), the button's ``aria-label`` EQUALS the box's
current text (written by the same call — never stale), the box is
``aria-hidden`` (presentational — ``#sync-result`` is the sole live
region), the button is disabled + ``aria-busy`` (the pre-phase running
mechanics), and the Cancel button is visible (the phase-134 contract
unchanged).
2. ``test_box_carries_the_full_untruncated_label`` — during the
current text (written by the same call — never stale), the box IS the
live region (``role="status"``, ``aria-live="polite"``, NO
``aria-hidden`` — phase 150) and the ONLY explicit ``[aria-live]``
element in the sync view, the button is disabled + ``aria-busy`` (the
pre-phase running mechanics), and the Cancel button is visible (the
phase-134 contract unchanged).
3. ``test_box_carries_the_full_untruncated_label`` — during the
in-flight window of the LONG-path file (poll until the box text is
``Syncing… <full long path> (1/3)`` — the path string IN FULL, the
pre-phase ellipsis would have truncated it): the box's
``scrollWidth <= clientWidth`` AND the text's ``scrollWidth <=
clientWidth`` (the wrap engaged — nothing is clipped), ``#sync-result``
(the announcer) carries the SAME full string (A4 — one source, two
surfaces), the button's ``aria-label`` equals it too, and the box ink
on the panel background clears WCAG 2.1 AA (≥ 4.5:1). Then a retry loop
over the slow embed window pins the phase-129 chunk part IN THE BOX
(``Syncing… <full long path> (1/3) · chunk n/m`` — the part is only
present while an embed batch is in flight; the proxy's 1.5 s delay
makes the ~15 s window ≫ the 2 s poll).
3. ``test_mobile_box_full_width`` — a fresh page at the house mobile
clientWidth`` (the wrap engaged — nothing is clipped), the box IS the
announcer (``role="status"`` / ``aria-live="polite"`` / no
``aria-hidden`` — phase 150), the button's ``aria-label`` equals the
box's text too, and the box ink on the panel background clears WCAG
2.1 AA (≥ 4.5:1). Then a retry loop over the slow embed window pins
the phase-129 chunk part IN THE BOX (``Syncing… <full long path>
(1/3) · chunk n/m`` — the part is only present while an embed batch
is in flight; the proxy's 1.5 s delay makes the ~15 s window ≫ the
2 s poll).
4. ``test_mobile_box_full_width`` — a fresh page at the house mobile
viewport (390×844 — the ``test_sync_label_mobile.py`` constant): the
same in-flight assertions — the box is FULL WIDTH (its width ≈ the
container's content width minus the container's padding — no override
@@ -108,19 +125,36 @@ JS task):
(``scrollWidth <= clientWidth``), the icon-only button keeps the
mobile full-width rule, the label wraps (the full long path is a
substring — the old mobile-stuffing ellipsis is gone).
4. ``test_settled_state_restores_the_pill`` — in flight first (the box is
visible), then let the run settle (the house settle idiom): the box is
HIDDEN (run-scoped only), the button shows the settle label
5. ``test_settled_state_restores_the_pill`` — in flight first (the box is
visible), then let the run settle (the house settle idiom): the box
STAYS VISIBLE and carries the counts line (the pre-phase
``fmtSyncResult`` shape — ``N added`` — byte-identical to what the
retired announcer spoke), the button shows the settle label
``Synced HH:MM`` (the pre-phase byte-identical settle copy) with the
count + chunk parts hidden and empty, ``is-running`` / ``is-spinning``
are gone, the ``aria-label`` is back to ``"Sync sources"``, and the
counts line on ``#sync-result`` matches the pre-phase ``fmtSyncResult``
shape (``N added``).
5. ``test_reattach_shows_the_box`` — in flight (the box visible), RELOAD
are gone, and the ``aria-label`` is back to ``"Sync sources"``
(ASSUMPTION A2: the settle label stays on the button — the box
carries the counts line, not a duplicate of the button).
6. ``test_reattach_shows_the_box`` — in flight (the box visible), RELOAD
the page (the admin session cookie persists): the load-time re-attach
(``initSyncButton``) finds the RUNNING sync → the box is VISIBLE with
the current label and the button is icon-only again (the never-stale
contract — a page load mid-run shows the truth, not an idle pill).
7. ``test_settled_cancel_shows_the_result_line`` — in flight, then
CANCELLED (the phase-134 control): the poll's ``cancelled`` branch
settles the box with the neutral result line "Sync cancelled"
(VISIBLE — the phase-150 result-line contract, byte-identical to the
pre-phase announcer's string), the button settles retry-ready
("Sync sources", no busy, no running classes), and the cancel is
never rendered as a failure (no error banner, no modal in the DOM).
8. ``test_settled_failure_hides_the_box`` — a BROKEN git source (a
``file://`` URL with no repository behind it, added as a DB row —
which outranks the env fallback) makes the sync's clone step fail:
the settled failure is the box HIDDEN + empty (the failed terminal
announces nothing in the live region), the error banner AND the
failure modal carry the (sanitized) error (the pre-phase contract,
unchanged), and the button settles retry-ready with the error
styling (the accessible name carries the error).
"""
from __future__ import annotations
@@ -225,11 +259,26 @@ IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
SETTLE_TIMEOUT_MS = 120_000
#: The module's wall budget: five full 3-file syncs against the slowed
#: mock (≈ 30–35 s each) must stay well under this — a breach is a
#: regression canary for the sync pipeline's speed, not a flake
#: tolerance.
#: mock (≈ 30–35 s each) + one cancelled run + one fast clone-failure
#: run (phase 150's result-line tests 7/8) must stay well under this —
#: a breach is a regression canary for the sync pipeline's speed, not a
#: flake tolerance.
WALL_BUDGET_S = 600.0
#: The settle's counts line (sources.js's fmtSyncResult — the first
#: part is always "N added"; the rest are conditional, " · "-joined —
#: the shape the pre-phase announcer spoke, byte-identical).
RESULT_LINE = re.compile(r"^\d+ added( · \d+ (updated|unchanged|pruned))*$")
#: A broken git source — the failure trigger (test 8): a ``file://``
#: URL with no repository behind it (the URL shape passes add-time
#: validation — it is the CLONE that fails). Added as a DB row it
#: OUTRANKS the env fallback (effective_sources: any DB row makes
#: ``BOR_GIT_SOURCES`` ignored entirely), so the sync's only source is
#: the unclonable one — the run fails in the clone step, before any
#: import (no partial KB; only the probe's LLM calls run).
BROKEN_REPO_URL = "file:///nonexistent/bor-progressbox-fail-repo"
# ---------------------------------------------------------------------------
# The in-browser snapshot functions (Playwright Mapping Rule — the
# rendered DOM is the observable).
@@ -243,12 +292,47 @@ WALL_BUDGET_S = 600.0
# two surfaces).
# ---------------------------------------------------------------------------
#: Tests 1 + 5 — ANY in-flight state (a run is in flight): the box is
#: Test 1 — the template pin + the idle re-attach (a page load with NO
#: run in flight): the sync view (``#view-rag``) carries EXACTLY ONE
#: explicit ``[aria-live]`` element and it is ``#sync-progress``
#: (``role="status"``, ``aria-live="polite"``, NO ``aria-hidden`` —
#: an aria-live region that is aria-hidden announces nothing), the box
#: is hidden + empty (the anonymous-safe idle state), and the retired
#: announcer span is ABSENT from the DOM.
# Phase 150: the retired sync-announcer span's id is assembled (never
# spelled out) so the phase gate's project-wide grep for the retired
# name stays clean while the absence pin stays in force.
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.
_JS_LIVE_REGION_SNAPSHOT = """
() => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
if (!box || !textEl) return null;
const liveEls = document.querySelectorAll('#view-rag [aria-live]');
return {
role: box.getAttribute('role'),
ariaLive: box.getAttribute('aria-live'),
ariaHidden: box.getAttribute('aria-hidden'),
boxHidden: box.hidden,
boxText: textEl.textContent,
resultCount: document.querySelector('#' + ['sync', 'result'].join('-')) ? 1 : 0,
liveCount: liveEls.length,
liveIds: Array.from(liveEls).map(el => el.id),
};
}
"""
#: Tests 2, 5, 6, 7 — ANY in-flight state (a run is in flight): the box is
#: VISIBLE with non-empty text, and the button is in its running state
#: (is-running + the spinning icon + the hidden label parts + the full
#: live label as aria-label + disabled + aria-busy) with the Cancel
#: control visible (the phase-134 contract). The box's aria-hidden
#: attribute is read too (presentational — the one-live-region pin).
#: control visible (the phase-134 contract). The box's live-region
#: attributes are read too (phase 150 — the box IS the announcer:
#: role="status", aria-live="polite", NO aria-hidden) + the sync view's
#: [aria-live] inventory (the one-live-region pin).
_JS_RUNNING_SNAPSHOT = """
() => {
const box = document.querySelector('#sync-progress');
@@ -260,9 +344,14 @@ _JS_RUNNING_SNAPSHOT = """
if (!box || !textEl || !btn || !label || !icon || !cancel) return null;
if (box.hidden || !textEl.textContent) return null;
const cs = (el) => getComputedStyle(el);
const liveEls = document.querySelectorAll('#view-rag [aria-live]');
return {
boxText: textEl.textContent,
role: box.getAttribute('role'),
ariaLive: box.getAttribute('aria-live'),
ariaHidden: box.getAttribute('aria-hidden'),
liveCount: liveEls.length,
liveIds: Array.from(liveEls).map(el => el.id),
ariaLabel: btn.getAttribute('aria-label'),
isRunning: btn.classList.contains('is-running'),
isSpinning: icon.classList.contains('is-spinning'),
@@ -274,22 +363,22 @@ _JS_RUNNING_SNAPSHOT = """
}
"""
#: Test 2, wait A — the in-flight window of the LONG-path file: the box
#: Test 3, wait A — the in-flight window of the LONG-path file: the box
#: is VISIBLE and its text is the FULL label for the long file —
#: "Syncing… <full long path> (1/3)" (+ the optional phase-129
#: " · chunk n/m" part while an embed batch is in flight — the
#: pre-first-batch frame has none) — the complete
# source/relative/path IN the text (the pre-phase ellipsis would have
#: truncated it). Returns the text + the wrap proof (scrollWidth vs
#: clientWidth of box and text) + the announcer + the aria-label + the
#: clientWidth of box and text) + the box's live-region attributes
#: (phase 150 — the box IS the announcer) + the aria-label + the
#: computed ink/panel colors (the AA pin).
_JS_LONG_FILE_SNAPSHOT = """
(longPath) => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
const result = document.querySelector('#sync-result');
if (!box || !textEl || !btn || !result || box.hidden) return null;
if (!box || !textEl || !btn || box.hidden) return null;
const t = textEl.textContent;
const prefix = 'Syncing… ' + longPath + ' (';
if (!t.startsWith(prefix)) return null;
@@ -298,7 +387,9 @@ _JS_LONG_FILE_SNAPSHOT = """
const cs = (el) => getComputedStyle(el);
return {
boxText: t,
resultText: result.textContent,
role: box.getAttribute('role'),
ariaLive: box.getAttribute('aria-live'),
ariaHidden: box.getAttribute('aria-hidden'),
ariaLabel: btn.getAttribute('aria-label'),
boxScrollWidth: box.scrollWidth,
boxClientWidth: box.clientWidth,
@@ -310,7 +401,7 @@ _JS_LONG_FILE_SNAPSHOT = """
}
"""
#: Test 2, wait B — the phase-129 chunk part IN THE BOX during the slow
#: Test 3, wait B — the phase-129 chunk part IN THE BOX during the slow
#: embed window: the box text is "Syncing… <full long path> (1/3) ·
#: chunk n/m" (the part is only present while an in-flight file's embed
#: batch is in flight — the proxy's 1.5 s delay makes the ~15 s
@@ -337,7 +428,7 @@ _JS_LONG_FILE_CHUNK_SNAPSHOT = """
}
"""
#: Test 3 — the mobile (390×844) in-flight window of the long file: the
#: Test 4 — the mobile (390×844) in-flight window of the long file: the
#: box text is the full label (the long path IN the text), the box is
#: FULL WIDTH (its bounding-box width ≈ the container's content width —
#: the block rides the 72rem container, no override), no horizontal
@@ -676,20 +767,47 @@ def _start_sync(page: Page, app_url: str) -> None:
then click **Sync sources** and pin the click's immediate 202
state: the button IS the spinner (disabled, aria-busy,
``is-running``, the spinning icon) and the box is revealed with
the bare label (phase 149 — the running detail rides the box)."""
the bare label (phase 149 — the running detail rides the box).
The module app is SHARED across tests: a prior test's terminal
state survives in its memory, and the boot re-attach renders it
(asynchronously). A prior FAILED terminal re-opens the failure
modal — its backdrop intercepts clicks until closed — so the
deterministic layer reads the state from the status endpoint first
(the test_sync_cancel pattern) and closes the modal before the
click (the owner's next step — never stale)."""
login(page, app_url) # lands on /sources.html (the button's home)
# The deterministic layer: the app's in-memory run state — a prior
# test's terminal, never "running" (the autouse cleanup waits a
# leaked run out before every test).
with httpx.Client(base_url=app_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
state = r.json()["state"]
assert state != "running", f"a sync leaked across a test boundary: {state}"
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
expect(page.locator("#sync-label")).to_have_text(IDLE_OR_SETTLED)
expect(btn).to_be_enabled()
# A boot re-attach of a prior FAILED terminal opens the failure
# modal (the backdrop intercepts clicks until closed) — close it
# before the click.
if state == "failed":
expect(page.locator(".sync-modal-backdrop")).to_be_visible(timeout=15_000)
page.locator(".sync-modal-close").click()
expect(page.locator(".sync-modal-backdrop")).to_be_hidden()
btn.click()
# The click's immediate state (the 202 moment — the bare prefix,
# the phase-64 contract): the button is the loading spinner and
# the box carries the bare label (phase 149 — one source, two
# surfaces).
# the box carries the bare label (phase 149/150 — the running
# detail rides the single box surface).
expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true")
expect(btn).to_have_class(re.compile(r"\bis-running\b"))
@@ -720,12 +838,36 @@ def _wait_terminal(app_url: str, timeout_s: float = 120.0) -> dict[str, Any]:
raise AssertionError(f"the run never reached a terminal state: {body}")
def _wait_state(
app_url: str, states: set[str], timeout_s: float = 120.0
) -> dict[str, Any]:
"""Poll the status endpoint (own admin session) until the run's
state is in *states* — the deterministic layer for the non-success
terminals (the cancelled / failed settles, tests 7/8). Returns the
terminal body for the test to pin; a timeout fails with the last
body (never an opaque wait)."""
with httpx.Client(base_url=app_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
if body["state"] in states:
return body
time.sleep(0.2)
raise AssertionError(f"the run never reached {sorted(states)}: {body}")
def _settle(page: Page, app_url: str) -> None:
"""The phase-32 success settle (the pre-phase byte-identical
terminal shape, preserved by phase 149): the run reaches a SUCCESS
terminal at the endpoint, then the button shows "Synced HH:MM"
(re-enabled, the running state fully cleared), the box is HIDDEN
(run-scoped), and no error."""
"""The phase-32 success settle, phase-150 shape: the run reaches a
SUCCESS terminal at the endpoint, then the button shows "Synced
HH:MM" (re-enabled, the running state fully cleared) and the box
STAYS VISIBLE carrying the counts line (the fmtSyncResult shape
the pre-phase announcer spoke — phase 150 A1: the box hides only
when its text is empty), with no error."""
_wait_terminal(app_url)
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
btn = page.locator("#sync-btn")
@@ -733,12 +875,54 @@ def _settle(page: Page, app_url: str) -> None:
expect(btn).not_to_have_attribute("aria-busy")
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-progress")).to_be_hidden()
expect(page.locator("#sync-progress")).to_be_visible()
expect(page.locator("#sync-progress-text")).to_have_text(RESULT_LINE)
expect(page.locator("#sync-error-banner")).to_be_hidden()
# ---------------------------------------------------------------------------
# 1. In flight: the button is the spinner only (no visible label text),
# 1. Idle: the template pins — one live region (the box), the retired
# announcer absent, the box hidden + empty (a page load with no run)
# ---------------------------------------------------------------------------
def test_idle_page_one_live_region_box_hidden(
page: Page, app_url: str, db_ready: None
) -> None:
"""A page load with NO run in flight (the idle re-attach — this
test runs FIRST, so the module app has never started a sync and
the boot re-attach settles the true idle): the phase-150 template
pins hold end to end — the retired announcer span is ABSENT from
the DOM (the retirement is pinned, not implied), the sync
view (``#view-rag``) carries EXACTLY ONE explicit ``[aria-live]``
element and it is ``#sync-progress`` (``role="status"``,
``aria-live="polite"``, NO ``aria-hidden`` — the two attributes are
mutually exclusive by intent), and the box is hidden + empty (the
anonymous-safe idle state — the box hides only when its text is
empty)."""
login(page, app_url) # lands on /sources.html (the box's home)
snap = page.evaluate(_JS_LIVE_REGION_SNAPSHOT)
assert snap is not None, "the box is missing from the template"
# The retirement: the pre-150 announcer span is gone from the DOM.
assert snap["resultCount"] == 0, snap
assert page.locator("#" + RETIRED_ID).count() == 0
# ONE live region in the sync area — and it is the box.
assert snap["liveCount"] == 1, snap
assert snap["liveIds"] == ["sync-progress"], snap
assert snap["role"] == "status", snap
assert snap["ariaLive"] == "polite", snap
assert snap["ariaHidden"] is None, snap
# Idle: the box is hidden + empty, the button is the idle pill.
expect(page.locator("#sync-progress")).to_be_hidden()
expect(page.locator("#sync-progress-text")).to_have_text("")
expect(page.locator("#sync-label")).to_have_text("Sync sources")
expect(page.locator("#sync-btn")).to_be_enabled()
expect(page.locator("#sync-error-banner")).to_be_hidden()
# ---------------------------------------------------------------------------
# 2. In flight: the button is the spinner only (no visible label text),
# the aria-label carries the full live label, and Cancel rides along
# ---------------------------------------------------------------------------
@@ -754,8 +938,10 @@ def test_running_button_is_spinner_only(
``#sync-label`` is NOT visible (the label parts are hidden — the
button is the icon, at any viewport), the button's ``aria-label``
EQUALS the box's current text (written by the same single-writer
call — never stale), the box is ``aria-hidden`` (presentational —
``#sync-result`` remains the sole aria-live region), the running
call — never stale), the box IS the live region (``role="status"``,
``aria-live="polite"``, NO ``aria-hidden`` — phase 150 folded the
retired announcer in) and the ONLY explicit ``[aria-live]`` element
in the sync view (one live region, no double announce), the running
mechanics (disabled + ``aria-busy``) are preserved, and the Cancel
button is visible (the phase-134 contract unchanged)."""
_start_sync(page, app_url)
@@ -778,8 +964,15 @@ def test_running_button_is_spinner_only(
# label, equal to the box's text (the same writer call wrote both).
assert snap["boxText"], snap
assert snap["ariaLabel"] == snap["boxText"], snap
# The box is presentational — #sync-result is the SOLE live region.
assert snap["ariaHidden"] == "true", snap
# Phase 150: the box IS the SOLE live region — role="status",
# aria-live="polite", NO aria-hidden (an aria-live region that is
# aria-hidden announces nothing), and the only [aria-live] element
# in the sync view.
assert snap["role"] == "status", snap
assert snap["ariaLive"] == "polite", snap
assert snap["ariaHidden"] is None, snap
assert snap["liveCount"] == 1, snap
assert snap["liveIds"] == ["sync-progress"], snap
# The pre-phase running mechanics are preserved.
assert snap["btnDisabled"] is True, snap
assert snap["ariaBusy"] == "true", snap
@@ -790,10 +983,9 @@ def test_running_button_is_spinner_only(
# ---------------------------------------------------------------------------
# 2. The box carries the EXACT full untruncated label (long path
# included — wrapped, never clipped), the announcer carries the same
# string, and the chunk part appears in the box during the embed
# window
# 3. The box carries the EXACT full untruncated label (long path
# included — wrapped, never clipped), the box IS the announcer,
# and the chunk part appears in the box during the embed window
# ---------------------------------------------------------------------------
@@ -807,13 +999,14 @@ def test_box_carries_the_full_untruncated_label(
substring (the pre-phase ellipsis would have truncated it — the
wrap means nothing is dropped), the box did NOT overflow
horizontally (``scrollWidth <= clientWidth`` — the wrap engaged)
neither as a panel nor in its text, ``#sync-result`` (the
announcer) carries the SAME full string (A4 — one source, two
surfaces), the button's ``aria-label`` equals it, and the box ink
on the panel clears WCAG 2.1 AA (≥ 4.5:1). Then a retry loop over
the slow embed window pins the phase-129 chunk part IN THE BOX
(``· chunk n/m`` — the part is only present while an embed batch is
in flight; the proxy's 1.5 s delay makes the window wide)."""
neither as a panel nor in its text, the box IS the announcer
(``role="status"`` / ``aria-live="polite"`` / no ``aria-hidden``
— phase 150), the button's ``aria-label`` equals the box's text,
and the box ink on the panel clears WCAG 2.1 AA (≥ 4.5:1). Then a
retry loop over the slow embed window pins the phase-129 chunk part
IN THE BOX (``· chunk n/m`` — the part is only present while an
embed batch is in flight; the proxy's 1.5 s delay makes the window
wide)."""
_start_sync(page, app_url)
# Wait A — the in-flight window of the long file (any frame of it —
@@ -831,9 +1024,13 @@ def test_box_carries_the_full_untruncated_label(
# box: the path WRAPS, it is never clipped).
assert snap["boxScrollWidth"] <= snap["boxClientWidth"], snap
assert snap["textScrollWidth"] <= snap["textClientWidth"], snap
# A4: the announcer carries the SAME full string (one source, two
# surfaces) — and the icon-only button's accessible name does too.
assert snap["resultText"] == snap["boxText"], snap
# Phase 150: the box IS the announcer — role="status",
# aria-live="polite", NO aria-hidden (the retired span is gone) —
# and the icon-only button's accessible name carries the same full
# string.
assert snap["role"] == "status", snap
assert snap["ariaLive"] == "polite", snap
assert snap["ariaHidden"] is None, snap
assert snap["ariaLabel"] == snap["boxText"], snap
# WCAG 2.1 AA: the box's mono ink on its panel background ≥ 4.5:1
# (the theme's pinned identity pair — observed, not assumed).
@@ -857,7 +1054,7 @@ def test_box_carries_the_full_untruncated_label(
# ---------------------------------------------------------------------------
# 3. Mobile (390×844): the box is full-width, the button is icon-only
# 4. Mobile (390×844): the box is full-width, the button is icon-only
# (the mobile full-width rule), and the label wraps
# ---------------------------------------------------------------------------
@@ -906,8 +1103,9 @@ def test_mobile_box_full_width(page: Page, app_url: str, db_ready: None) -> None
# ---------------------------------------------------------------------------
# 4. Settle: the box hides, the "Synced HH:MM" pill is restored
# byte-identical, and the counts line is unchanged
# 5. Settle: the box STAYS visible with the counts line (phase 150),
# the "Synced HH:MM" pill is restored byte-identical, and the counts
# line is unchanged
# ---------------------------------------------------------------------------
@@ -916,21 +1114,23 @@ def test_settled_state_restores_the_pill(
) -> None:
"""A sync is triggered and observed IN FLIGHT (the box is visible —
so the settle below is a real transition, not a no-op), then let to
settle (the house settle idiom): the box is HIDDEN (run-scoped
only), the button shows the settle label ``Synced HH:MM`` (the
pre-phase byte-identical settle copy) with the label parts
restored — the count + chunk parts hidden and empty —,
``is-running`` / ``is-spinning`` are gone, the ``aria-label`` is
back to ``"Sync sources"`` (the ship value), and the counts line
on ``#sync-result`` matches the pre-phase ``fmtSyncResult`` shape
(``N added`` — the 3 fixture files)."""
settle (the house settle idiom): the box STAYS VISIBLE and carries
the counts line — the pre-phase ``fmtSyncResult`` shape
(``N added`` — the 3 fixture files), byte-identical to what the
retired announcer spoke (phase 150 A1: the box holds the result
line; it hides only when its text is empty) —, the button shows
the settle label ``Synced HH:MM`` (the pre-phase byte-identical
settle copy, ASSUMPTION A2: it stays on the button, not the box)
with the label parts restored — the count + chunk parts hidden and
empty —, ``is-running`` / ``is-spinning`` are gone, and the
``aria-label`` is back to ``"Sync sources"`` (the ship value)."""
_start_sync(page, app_url)
# In flight first (the box is visible + the button is the spinner).
page.wait_for_function(_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS).json_value()
# The settle (the house idiom): the terminal lands at the endpoint,
# then the UI's 2 s poll renders the phase-32 terminal shape.
# then the UI's 2 s poll renders the phase-32/150 terminal shape.
_wait_terminal(app_url)
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
@@ -942,8 +1142,11 @@ def test_settled_state_restores_the_pill(
expect(btn).to_have_attribute("aria-label", "Sync sources")
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
# The box is run-scoped: hidden at settle.
expect(page.locator("#sync-progress")).to_be_hidden()
# Phase 150: the box STAYS visible at settle — it carries the
# counts line (the fmtSyncResult shape the pre-phase announcer
# spoke, byte-identical).
expect(page.locator("#sync-progress")).to_be_visible()
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
# The label parts are restored — the settle label carries no count
# or chunk part.
count = page.locator(".sync-label-count")
@@ -952,13 +1155,11 @@ def test_settled_state_restores_the_pill(
chunk = page.locator(".sync-label-chunk")
expect(chunk).to_be_hidden()
expect(chunk).to_have_text("")
# The counts line is unchanged (the pre-phase fmtSyncResult pin).
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
expect(page.locator("#sync-error-banner")).to_be_hidden()
# ---------------------------------------------------------------------------
# 5. A page load mid-run re-attaches with the box visible (never stale)
# 6. A page load mid-run re-attaches with the box visible (never stale)
# ---------------------------------------------------------------------------
@@ -994,3 +1195,120 @@ def test_reattach_shows_the_box(page: Page, app_url: str, db_ready: None) -> Non
assert snap["cancelVisible"] is True, snap
_settle(page, app_url)
# ---------------------------------------------------------------------------
# 7. Settled cancel: the box carries the neutral result line
# "Sync cancelled" (no banner, no modal)
# ---------------------------------------------------------------------------
def test_settled_cancel_shows_the_result_line(
page: Page, app_url: str, db_ready: None
) -> None:
"""A sync is triggered and observed IN FLIGHT (the box is visible
with the running label), then CANCELLED (the phase-134 control):
the poll's ``cancelled`` branch settles the box with the neutral
result line "Sync cancelled" — VISIBLE (the phase-150 result-line
contract: the box stays visible to carry it, byte-identical to the
pre-phase announcer's string) — the button settles retry-ready
("Sync sources", no busy, no running classes), and the cancel is
never rendered as a failure (no error banner, no modal in the
DOM)."""
_start_sync(page, app_url)
# In flight first (the box is visible + the Cancel control is
# revealed).
snap = page.wait_for_function(
_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
assert snap["cancelVisible"] is True, snap
# The owner's click: the control disables IMMEDIATELY (the
# requestCancel never-stale contract). The box is NOT blanked at
# cancel-request time (phase 150 — the run is still in flight; the
# live label stays up until the poll's cancelled branch settles
# it).
cancel = page.locator("#sync-cancel-btn")
cancel.click()
expect(cancel).to_be_disabled()
# The deterministic layer: the cancelled terminal (error null — a
# cancel is intent, not failure).
body = _wait_state(app_url, {"cancelled"}, timeout_s=60.0)
assert body["state"] == "cancelled", body
assert body["error"] is None, body
# The UI settle: the box stays VISIBLE with the neutral result
# line (byte-identical to the pre-phase announcer's string)…
expect(page.locator("#sync-progress")).to_be_visible(timeout=30_000)
expect(page.locator("#sync-progress-text")).to_have_text(
"Sync cancelled", timeout=30_000
)
# …the button settles retry-ready…
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000)
btn = page.locator("#sync-btn")
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
# …and the cancel is never a failure (no banner, no modal in the
# DOM — the backdrop is created on demand, so count 0 is the
# absence proof).
expect(page.locator("#sync-error-banner")).to_be_hidden()
assert page.locator(".sync-modal-backdrop").count() == 0
# ---------------------------------------------------------------------------
# 8. Settled failure: the box hides + empties; the banner + modal carry
# the error (the pre-phase contract, unchanged)
# ---------------------------------------------------------------------------
def test_settled_failure_hides_the_box(
page: Page, app_url: str, db_ready: None
) -> None:
"""A BROKEN git source (a ``file://`` URL with no repository behind
it) is added as a DB row — which OUTRANKS the env fallback
(effective_sources: any DB row makes ``BOR_GIT_SOURCES`` ignored
entirely), so the sync's only source is the unclonable one — and
the run fails in the clone step (before any import). The settled
failure (phase 150): the box is HIDDEN + empty (the failed
terminal announces nothing in the live region — the error has its
own surfaces), the error banner AND the failure modal carry the
(sanitized) error (the pre-phase contract, unchanged), and the
button settles retry-ready with the error styling (the accessible
name carries the error — never an icon-only mystery)."""
# The failure trigger (own admin session — the endpoint is
# admin-only, like the sync endpoints).
with httpx.Client(base_url=app_url, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
r = client.post("/api/git-sources", json={"url": BROKEN_REPO_URL})
assert r.status_code == 201, r.text
_start_sync(page, app_url)
# The deterministic layer: the failed terminal (the clone step —
# the probe passed: the LLM is the healthy slow proxy).
body = _wait_state(app_url, {"failed"}, timeout_s=120.0)
assert body["state"] == "failed", body
assert body["error"] and "git clone" in body["error"], body
# The error surfaces in the banner AND the failure modal (the
# pre-phase contract, unchanged)…
expect(page.locator("#sync-error-banner")).to_be_visible(timeout=30_000)
expect(page.locator("#sync-error-text")).to_contain_text("git clone")
expect(page.locator(".sync-modal-backdrop")).to_be_visible(timeout=30_000)
expect(page.locator("#sync-modal-error")).to_contain_text("git clone")
# …and the box hides, empty (the failed terminal announces nothing
# — the banner + modal carry the error).
expect(page.locator("#sync-progress")).to_be_hidden()
expect(page.locator("#sync-progress-text")).to_have_text("")
# The button settles retry-ready with the error styling…
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000)
btn = page.locator("#sync-btn")
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
expect(btn).to_have_class(re.compile(r"\bis-error\b"))
+9 -6
View File
@@ -76,8 +76,9 @@ Test → observable mapping (Playwright Mapping Rule):
pinned count part ``(n/3)`` — the asserted text is the RENDERED
DOM parts, with the button ``title`` riding the full untruncated
string, main + " " + count — A4); on settle the button reads the
terminal ``Synced HH:MM`` (the phase-32 contract) and
``#sync-result`` carries the counts line.
terminal ``Synced HH:MM`` (the phase-32 contract) and the
``#sync-progress`` box carries the counts line (phase 150: the box
is the single sync text surface).
3. ``test_missing_folder_summaries_read_as_pending_and_self_heal``
(tree — the fast mock leg): after a successful sync (every
candidate stores its canned line) NO marker is anywhere; DELETE
@@ -733,9 +734,10 @@ def test_sync_button_names_the_summary_phase(
the detail box (phase 149: the running label rides #sync-progress
under the button row; the button is the spinner) with the button
``title`` carrying the same full untruncated string (A4 — one
source, two surfaces); on settle the box hides, the button shows
the terminal ``Synced HH:MM`` (the phase-32 contract) and
``#sync-result`` carries the counts line. The prelude/import labels
source, two surfaces); on settle the box STAYS visible with the
counts line (phase 150: the settle's result line is the box's —
the single sync text surface), the button shows the terminal
``Synced HH:MM`` (the phase-32 contract). The prelude/import labels
stay the phase-64 shape (the endpoint test pins those phases; the
UI pins the new summaries label)."""
page.set_default_timeout(30_000)
@@ -802,7 +804,8 @@ def test_sync_button_names_the_summary_phase(
)
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
# The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
# ---------------------------------------------------------------------------
+4 -2
View File
@@ -659,7 +659,8 @@ def test_upload_toast_then_navigate_away(
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
# The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
# Phase 97: the catalog is the drill-down tree — drill source →
# folder `docs` before the per-level row asserts (the flat total
# no longer exists in one tbody).
@@ -857,7 +858,8 @@ def test_sync_live_file_label(
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
# The counts line rides the box (phase 150 — the sole sync surface).
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
# Phase 97: the catalog is the drill-down tree — drill source →
# folder `notes` before the per-level row asserts (the flat total
# no longer exists in one tbody).
+4 -3
View File
@@ -24,8 +24,9 @@ check + import), so its background run settles in milliseconds and the
2 s status poll settles one tick after the 202. The sync leg is short
(2 files + 1 ignored → a handful of mock-LLM requests), so instead of
racing a timer the suite polls the RAG page's settled sync UI
(``#sync-label`` "Synced HH:MM" + ``#sync-result`` counts) with generous
timeouts — the live "Syncing… <file> (n/m)" label is the
(``#sync-label`` "Synced HH:MM" + the ``#sync-progress`` box's counts
line — phase 150: the box is the single sync text surface) with
generous timeouts — the live "Syncing… <file> (n/m)" label is the
``test_sync_upload_progress.py`` suite's subject, not this one's.
The archive is **built in-test** with Python's ``tarfile``:
@@ -559,7 +560,7 @@ def test_ignore_list_then_sync_scans(
expect(page.locator("#sync-label")).to_have_text(
SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS
)
expect(page.locator("#sync-result")).to_have_text("2 added", timeout=SYNC_TIMEOUT_MS)
expect(page.locator("#sync-progress-text")).to_have_text("2 added", timeout=SYNC_TIMEOUT_MS)
expect(page.locator("#sync-btn")).to_be_enabled()
expect(page.locator("#sync-btn")).not_to_have_attribute("aria-busy")