phase: 150_drop_sync_result
**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:
+419
-101
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user