phase: 149_sync_progress_box
All verification complete. The `ink-soft`/`--surface` pair the box uses is already pinned AA by the existing theme E2E suites (8.2:1 dark, ~6.9:1 light). Everything is green — no fixes were needed.
**Phase 149 final verification report**
- Verified (no re-work needed): `#sync-progress` box wiring (single `setSyncLabel` writer with `running` flag; `is-running` class travels with the spin; aria-label owned by the writer), the panel/icon-only-button CSS, and the 5-test dedicated E2E; all 6 pre-existing sync E2Es' running pins migrated to the box, settled pins untouched.
- Dedicated E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed in 198.6 s** (isolation, slow-proxy mock; under the 600 s budget).
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed, app/ coverage 99%** (>90% gate).
- Migrated sync E2Es, each in isolation: `test_sync_button` 3 passed, `test_sync_cancel` 3, `test_sync_chunk_progress` 2, `test_sync_label_mobile` 4, `test_sync_summary_visibility` 3, `test_sync_upload_progress` 4 — all green.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Criteria: in-flight spinner-only button + full untruncated wrapped box (desktop & mobile 390×844, `scrollWidth ≤ clientWidth`) ✓; settle byte-identical ("Synced HH:MM" + `3 added`, box hidden, aria restored) ✓; `#sync-result` sole live region (box `aria-hidden`), mid-run reload re-attaches ✓; WCAG (AA contrast both themes via the pinned ink-soft/surface pair, focus-visible, reduced-motion kill intact at styles.css L2201) ✓; cancel/409/error/upload/status API unchanged (green suites above) ✓.
- Documentation: README.md reviewed in full (sync-states paragraph now matches the shipped UI; env-var table matches `app/config.py` and covers all `.env.example` vars; pages/commands verified) and AGENTS.md reviewed in full — **verified consistent, no changes needed**.
- Notable: no defects found in this or prior work; no live infrastructure touched.
- Next pending phase: none — `todo/` will be empty once the harness moves 149 (only 149 was pending).
This commit is contained in:
@@ -11,7 +11,11 @@ The story gate runs the **real** sync path end to end — a real
|
||||
network), a real import against the mock LLM, a real KB-overview
|
||||
regeneration — plus the admin-only visibility and the full button
|
||||
lifecycle (§7.4: "Syncing…" → "Synced HH:MM" + counts, the idempotent
|
||||
second run, the 409 double trigger).
|
||||
second run, the 409 double trigger). Phase 149 (task 01): the
|
||||
RUNNING-state pins (the 202's "Syncing…") ride the detail box
|
||||
(``#sync-progress`` — the running label left the pill; the button is
|
||||
the spinner), while every SETTLED pin (the idle "Sync sources", the
|
||||
"Synced HH:MM" label + counts line) is unchanged.
|
||||
|
||||
Per-module app env (the E2E conftest pattern, module-scoped): this
|
||||
story's app boots with ``BOR_GIT_SOURCES=file://<fixture repo>`` and
|
||||
@@ -246,11 +250,12 @@ def test_anonymous_sees_no_button(page: Page, app_url: str, db_ready: None) -> N
|
||||
|
||||
|
||||
def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC 2 + 4 — click → "Syncing…" (disabled) → "Synced HH:MM" + the
|
||||
counts, against the REAL pipeline (git clone of the ``file://``
|
||||
fixture → import with prune → KB overview regeneration, mock LLM).
|
||||
Then the idempotent second run: fast-forward pull + sha256 hash
|
||||
skip → "0 added · 1 unchanged"."""
|
||||
"""AC 2 + 4 — click → "Syncing…" (disabled; phase 149: the
|
||||
running label is in the detail box — the button is the spinner)
|
||||
→ "Synced HH:MM" + the counts, against the REAL pipeline (git
|
||||
clone of the ``file://`` fixture → import with prune → KB overview
|
||||
regeneration, mock LLM). Then the idempotent second run:
|
||||
fast-forward pull + sha256 hash skip → "0 added · 1 unchanged"."""
|
||||
login(page, app_url) # lands on /sources.html (the button's home)
|
||||
|
||||
btn = page.locator("#sync-btn")
|
||||
@@ -260,7 +265,9 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
||||
# --- run 1: clone + import + overview --------------------------------
|
||||
btn.click()
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
# Phase 149 (task 01): the running detail rides the box under the
|
||||
# button row (the button is the spinner) — the 202's bare label.
|
||||
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.
|
||||
@@ -282,7 +289,7 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
||||
# --- run 2: idempotent pull + hash skip -------------------------------
|
||||
btn.click()
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
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()
|
||||
# Nothing re-embedded (sha256 delta) — the no-op run announces the
|
||||
|
||||
@@ -497,10 +497,12 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
|
||||
btn.click()
|
||||
# The 202 enters the running state SYNCHRONOUSLY (the click handler
|
||||
#'s 202 branch, before the first 2 s poll tick): the main button
|
||||
# disables + "Syncing…", and the Cancel control reveals + enables
|
||||
# (enterSyncRunningState, kind "sync").
|
||||
# disables, the detail box reveals with "Syncing…" (phase 149 —
|
||||
# the running label rides the box; the button is the spinner),
|
||||
# and the Cancel control reveals + enables (enterSyncRunningState,
|
||||
# kind "sync").
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
expect(cancel).to_be_visible()
|
||||
expect(cancel).to_be_enabled()
|
||||
|
||||
@@ -553,7 +555,7 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
|
||||
# --- run 2: the re-sync is clean --------------------------------------
|
||||
btn.click()
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
expect(cancel).to_be_visible() # the fresh run is cancellable again
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
SYNCED_LABEL, timeout=RESYNC_TIMEOUT_MS
|
||||
|
||||
@@ -688,10 +688,12 @@ def _start_sync(page: Page, app_url: str, sync_dir: Path) -> None:
|
||||
# the phase-64 contract): disabled, aria-busy, spinning icon, and
|
||||
# the label's MAIN part alone ("Syncing…" — the 202 click carries
|
||||
# no status, so both pinned parts hide, phase 128/129 writers).
|
||||
# Phase 149 (task 01): the running detail rides the box — the 202's
|
||||
# bare label is in #sync-progress (the button is the spinner).
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_attribute("aria-busy", "true")
|
||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
expect(page.locator(".sync-label-count")).to_be_hidden()
|
||||
expect(page.locator(".sync-label-chunk")).to_be_hidden()
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
+295
-234
@@ -1,8 +1,7 @@
|
||||
"""Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync label
|
||||
is fully visible at a phone viewport — the file path ellipsizes to fit
|
||||
and the ``(n/m)`` count span sits in its own non-shrinking part that is
|
||||
NEVER clipped — in BOTH long spans (import + summaries), while desktop
|
||||
keeps the pre-phase strings and the ``min(16rem, 40vw)`` cap.
|
||||
"""Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync
|
||||
detail is fully visible at a phone viewport — in BOTH long spans
|
||||
(import + summaries) — while desktop keeps the pre-phase full
|
||||
strings.
|
||||
|
||||
The dedicated story suite for ``128_sync_label_mobile`` (A16 — one
|
||||
Playwright file per phase, run in isolation): the owner's report was
|
||||
@@ -10,11 +9,40 @@ that ``Syncing… name/of/file/here (2/3)`` "doesn't show up on mobile" —
|
||||
pre-phase, ``#sync-label`` was ONE inline-block span (whole label
|
||||
string, one ellipsis) whose mobile media query LIFTED the
|
||||
``min(16rem, 40vw)`` cap (``max-width: none``), so the ellipsized tail
|
||||
— where ``(n/m)`` lives — was clipped away on a phone. Phase 128 splits
|
||||
— where ``(n/m)`` lives — was clipped away on a phone. Phase 128 split
|
||||
the label into two parts (``.sync-label-main`, the shrinkable
|
||||
prefix + file/folder, + ``.sync-label-count`, the pinned position) so
|
||||
the count is never the ellipsis's sacrifice.
|
||||
|
||||
Phase 149 (task 01) RE-POINTED the running-state pins: the owner's
|
||||
follow-up was that the full live label (a long
|
||||
``source/relative/path`` + the counts + the chunk part) is clipped
|
||||
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
|
||||
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>
|
||||
(n/3)" + the phase-129 "· chunk n/m" while a batch is in flight),
|
||||
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)
|
||||
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
|
||||
box. The injected set ALSO neutralizes the phase-149 hide rule
|
||||
(``.sync-btn.is-running .sync-label { display: none }`` — task 02's
|
||||
specificity (0,2,0) would beat the injected (0,1,0) ``.sync-label``
|
||||
rule and leave the parts display:none, making the geometry vacuous);
|
||||
pre-phase, that rule simply did not exist, so the faithful
|
||||
pre-phase counterfactual restores the parts' inline-block display
|
||||
at matching specificity, last in the cascade.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov
|
||||
@@ -56,38 +84,34 @@ Test → observable mapping (Playwright Mapping Rule):
|
||||
1. ``test_mobile_label_fits_viewport_with_count_in_import`` — admin
|
||||
page on the sources view at viewport **390×844** (mobile); click
|
||||
**Sync sources**; while the import phase is running (the page's
|
||||
own 2 s poll): the rendered ``#sync-label``'s bounding box is
|
||||
INSIDE the viewport (left ≥ 0, right ≤ 390 — the bug's direct
|
||||
negation), and the ``.sync-label-count`` part is VISIBLE (non-null
|
||||
box) and NOT clipped — its right edge sits at or inside the label's
|
||||
right edge and the button's right edge, with text matching
|
||||
``\\(\\d+/\\d+\\)`` once a file is in flight; ``.sync-label-main``
|
||||
carries ``Syncing… <file>`` — the FULL source/relative/path
|
||||
(visually ellipsized, but the DOM text and the button ``title``
|
||||
are untruncated, A4). Phase 129: the label's THIRD pinned part —
|
||||
``.sync-label-chunk``, "· chunk n/m" (the in-flight file's
|
||||
embed-batch position, batch granularity) — renders while a file's
|
||||
embed batch is in flight (``chunks_total > 0``) and rides the
|
||||
untruncated ``title`` too; the snapshot accepts it (optional —
|
||||
the pre-first-batch frame has no chunk part yet; the phase-129
|
||||
dedicated suite pins it strictly).
|
||||
own 2 s poll): the detail box (``#sync-progress``) is VISIBLE and
|
||||
an atomic snapshot of: its text the FULL untruncated label
|
||||
``Syncing… <full source/relative/path> (n/4)`` (+ the phase-129
|
||||
"· chunk n/m" part while a batch is in flight — optional, the
|
||||
pre-first-batch frame has none) with the ``(n/4)`` count IN the
|
||||
text (never clipped — nothing is ellipsized away), the box's
|
||||
bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
|
||||
bug's direct negation), the button in its running state
|
||||
(``is-running`` — the spinner), and the button ``title``
|
||||
carrying the SAME full string (A4 — one source, two surfaces).
|
||||
The run then settles (the phase-32 terminal shape; the box hides).
|
||||
2. ``test_mobile_label_shows_summaries_count`` — same mobile
|
||||
viewport; through to the summaries phase: ``.sync-label-main``
|
||||
matches ``Summarizing folders…`` (the folder part optional — the
|
||||
phase's first poll may precede the first hook), the
|
||||
``.sync-label-count`` part matches ``\\(\\d+/3\\)`` and stays
|
||||
visible (same clip pins as test 1); on settle the button leaves
|
||||
the running label (the phase-32 terminal ``Synced HH:MM``) and the
|
||||
count span is hidden + empty.
|
||||
viewport; through to the summaries phase: the box text matches
|
||||
``Summarizing folders… [source/folder] (n/3)`` (the folder part
|
||||
optional — the phase's first poll may precede the first hook — the
|
||||
``(n/3)`` count always present, IN the text) with the same
|
||||
box-geometry pins as test 1 (inside the viewport), the ``title``
|
||||
carrying the same full string (A4); on settle the button leaves
|
||||
the running label (the phase-32 terminal ``Synced HH:MM``), the
|
||||
count span is hidden + empty, and the box hides.
|
||||
3. ``test_desktop_label_unchanged`` — viewport 1280×800; the same
|
||||
sync: during import the full rendered label text (main + count +
|
||||
optional phase-129 chunk concatenation) matches ``Syncing… <full
|
||||
source/relative/path> (n/m)`` (the pre-phase byte-identical
|
||||
string, the chunk part appended while a batch is in flight) and
|
||||
the label's width respects the ``min(16rem, 40vw)`` cap (bounding
|
||||
box width ≤ 256 px + slack — the shrinkable main part absorbs
|
||||
the extra pinned part); the ``title`` carries the same
|
||||
untruncated string (A4).
|
||||
sync: during import the box text matches ``Syncing… <full
|
||||
source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
|
||||
batch is in flight) — the pre-phase byte-identical FULL string
|
||||
(phase 149 moved it from the pill's parts to the box — the text
|
||||
is unchanged), the box inside the viewport, the button in its
|
||||
running state (``is-running``), and the ``title`` carrying the
|
||||
same untruncated string (A4).
|
||||
4. ``test_pre_phase_css_reproduces_the_clip`` — the pin check: the
|
||||
SAME mobile run with the pre-phase-128 ``.sync-label`` rules
|
||||
re-injected (CSSOM ``insertRule`` into the origin's own linked
|
||||
@@ -183,21 +207,26 @@ FILE_RE = r"synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\.md"
|
||||
#: strings, unit-pinned in tests/unit/test_frontend_sync_upload.py):
|
||||
#: main "Syncing… <full source/relative/path>" + count "(n/4)" —
|
||||
#: ``done`` starts at 1 (the importer counts the file as current
|
||||
#: BEFORE indexing it), so ``n`` ∈ 1..4.
|
||||
#: BEFORE indexing it), so ``n`` ∈ 1..4. (Test 4 — the pre-phase
|
||||
#: rules' clip reproduction — pins the PARTS' DOM text directly.)
|
||||
IMPORT_MAIN_RE = re.compile(rf"^Syncing… {FILE_RE}$")
|
||||
IMPORT_COUNT_RE = re.compile(r"^\([1-4]/4\)$")
|
||||
#: The pre-phase byte-identical FULL string (main + " " + count — the
|
||||
#: single span's text before phase 128; the A4 title keeps it), plus
|
||||
#: the OPTIONAL phase-129 chunk part (" · chunk n/m" — the in-flight
|
||||
#: file's embed-batch position, batch granularity) the title carries
|
||||
#: while a file's embed batch is in flight.
|
||||
#: file's embed-batch position, batch granularity). Phase 149 (task
|
||||
#: 01): the box (#sync-progress) carries this EXACT string while the
|
||||
#: import is in flight (tests 1 + 3) — the title carries it too
|
||||
#: (A4 — one source, two surfaces).
|
||||
IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)(?: · chunk \d+/\d+)?$")
|
||||
|
||||
#: The summaries label's PARTS (phase 98 task 02, unit-pinned): main
|
||||
#: "Summarizing folders… [source/folder]" (the folder part omitted
|
||||
#: while ``current_summary`` is null) + count "(n/3)".
|
||||
SUMMARY_MAIN_RE = re.compile(r"^Summarizing folders…(?: \S+)?$")
|
||||
SUMMARY_COUNT_RE = re.compile(r"^\(\d+/3\)$")
|
||||
#: The summaries label's FULL string (phase 98 task 02, unit-pinned):
|
||||
#: "Summarizing folders… [source/folder] (n/3)" (the folder part
|
||||
#: omitted while ``current_summary`` is null — the phase's first poll
|
||||
#: may precede the first hook — the (n/3) count always present). Phase
|
||||
#: 149 (task 01): the box carries this EXACT string while the
|
||||
#: summaries phase is in flight (test 2) — the title carries it too.
|
||||
SUMMARY_FULL_RE = re.compile(r"^Summarizing folders…(?: \S+)? \(\d+/3\)$")
|
||||
|
||||
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
||||
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
|
||||
@@ -209,17 +238,15 @@ SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
|
||||
IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
|
||||
|
||||
#: The viewports (the task's mapping): the phone (the reported bug)
|
||||
#: and the desktop (the unchanged-contract leg).
|
||||
#: and the desktop (the unchanged-contract leg). Phase 149 (task 01):
|
||||
#: the geometry pins now apply to the DETAIL BOX (a block in the
|
||||
#: 72rem container — it can only shrink toward the container's
|
||||
#: content width, never overflow the viewport); the old pill-label
|
||||
#: cap (min(16rem, 40vw)) applied to the ellipsized pill label, which
|
||||
#: is no longer the running surface (the button is the spinner).
|
||||
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
|
||||
DESKTOP_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
|
||||
|
||||
#: The desktop cap: ``min(16rem, 40vw)`` = 256 px at the 16 px :root
|
||||
#: (16rem) and the 1280 px viewport (40vw = 512 px) — the bounding
|
||||
#: box width may not exceed the cap (a small slack covers float +
|
||||
#: border rounding).
|
||||
DESKTOP_CAP_PX = 256
|
||||
CAP_SLACK_PX = 8
|
||||
|
||||
#: Generous settle budgets: a changed sync against the slowed LLM is
|
||||
#: ≈ 15–18 s; the UI's 2 s poll settles at most one tick after the
|
||||
#: terminal state lands.
|
||||
@@ -234,27 +261,127 @@ def _md(title: str, body: str) -> str:
|
||||
# The in-browser snapshot functions (Playwright Mapping Rule — the
|
||||
# rendered DOM is the observable).
|
||||
#
|
||||
# Each returns null while the awaited label state is absent, and an
|
||||
# ATOMIC geometry snapshot once it is present: the part texts, the
|
||||
# button title, and the getBoundingClientRect boxes of the label, the
|
||||
# count part, and the button — read in the SAME in-browser evaluation
|
||||
# that verified the label state, so the numbers can never straddle a
|
||||
# 2 s poll tick (the parts + the title are written together in one
|
||||
# JS task by setSyncLabel).
|
||||
# Phase 149 (task 01): the RUNNING detail rides the detail box —
|
||||
# #sync-progress (visible IFF a run is in flight) with the FULL
|
||||
# untruncated label in its .sync-progress-text span. Each snapshot
|
||||
# returns null while the awaited box state is absent, and an ATOMIC
|
||||
# snapshot once it is present: the box's text + the button's title +
|
||||
# is-running flag + the getBoundingClientRect boxes of the box and
|
||||
# the button — read in the SAME in-browser evaluation that verified
|
||||
# the box state, so the numbers can never straddle a 2 s poll tick
|
||||
# (the box text + the title are written together in one JS task by
|
||||
# the single setSyncLabel writer — A4: one source, two surfaces).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Test 1/3 — the in-flight IMPORT label: main carries "Syncing…
|
||||
#: <full source/relative/path>" (the untruncated A4 text — the visual
|
||||
#: ellipsis never touches textContent), the count part is VISIBLE
|
||||
#: (not ``hidden``) with the "(n/4)" position, and the button title
|
||||
#: carries the full untruncated label (main + " " + count, A4). Phase
|
||||
#: 129: the THIRD pinned part — .sync-label-chunk ("· chunk n/m", the
|
||||
#: in-flight file's embed-batch position) — rides the title too while
|
||||
#: a file's embed batch is in flight (chunks_total > 0): the title
|
||||
#: check accepts main + " " + count (+ " " + chunk when the part is
|
||||
#: rendered) — optional, since the pre-first-batch frame has no chunk
|
||||
#: part (the phase-129 dedicated suite pins the part strictly).
|
||||
_JS_IMPORT_SNAPSHOT = """
|
||||
#: Test 1/3 — the in-flight IMPORT detail: the box is VISIBLE and its
|
||||
#: text is the FULL untruncated label — "Syncing… <full
|
||||
#: source/relative/path> (n/4)" (+ the optional phase-129 " · chunk
|
||||
#: n/m" part while a file's embed batch is in flight — the
|
||||
#: pre-first-batch frame has none; the phase-129 dedicated suite pins
|
||||
#: it strictly) — the count is IN the text (never clipped — nothing
|
||||
#: is ellipsized away), the button is in its running state
|
||||
#: (is-running — the spinner), and the title carries the SAME full
|
||||
#: string (A4).
|
||||
_JS_IMPORT_BOX_SNAPSHOT = """
|
||||
() => {
|
||||
const box = document.querySelector('#sync-progress');
|
||||
const textEl = document.querySelector('#sync-progress-text');
|
||||
const btn = document.querySelector('#sync-btn');
|
||||
if (!box || !textEl || !btn || box.hidden) return null;
|
||||
const t = textEl.textContent;
|
||||
const reFull = new RegExp(
|
||||
"^Syncing… synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\\\\.md " +
|
||||
"\\\\([1-4]/4\\\\)(?: · chunk \\\\d+/\\\\d+)?$"
|
||||
);
|
||||
if (!reFull.test(t)) return null;
|
||||
const title = btn.getAttribute('title');
|
||||
if (title !== t) return null;
|
||||
const rect = (el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return { left: b.left, right: b.right, width: b.width, height: b.height };
|
||||
};
|
||||
return {
|
||||
boxText: t,
|
||||
title,
|
||||
isRunning: btn.classList.contains('is-running'),
|
||||
boxRect: rect(box),
|
||||
btnRect: rect(btn),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
#: Test 2 — the SUMMARIES detail: the box is VISIBLE and its text is
|
||||
#: the full "Summarizing folders… [source/folder] (n/3)" (the folder
|
||||
#: part optional — the phase's first poll may precede the first hook —
|
||||
#: the (n/3) count always present, IN the text), the button is in its
|
||||
#: running state, and the title carries the SAME full string (A4).
|
||||
_JS_SUMMARY_BOX_SNAPSHOT = """
|
||||
() => {
|
||||
const box = document.querySelector('#sync-progress');
|
||||
const textEl = document.querySelector('#sync-progress-text');
|
||||
const btn = document.querySelector('#sync-btn');
|
||||
if (!box || !textEl || !btn || box.hidden) return null;
|
||||
const t = textEl.textContent;
|
||||
const reFull = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
|
||||
if (!reFull.test(t)) return null;
|
||||
const title = btn.getAttribute('title');
|
||||
if (title !== t) return null;
|
||||
const rect = (el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return { left: b.left, right: b.right, width: b.width, height: b.height };
|
||||
};
|
||||
return {
|
||||
boxText: t,
|
||||
title,
|
||||
isRunning: btn.classList.contains('is-running'),
|
||||
boxRect: rect(box),
|
||||
btnRect: rect(btn),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
#: The pre-phase-128 ``.sync-label`` geometry, re-injected for test 4
|
||||
#: (the bug reproduction): the base rule's inline-block + single
|
||||
#: ellipsis with the mobile media query's lifted cap (``max-width:
|
||||
#: none``) — the label sizes to its FULL content and its ONE ellipsis
|
||||
#: owns the whole tail, so the count (now riding a child span inside
|
||||
#: that ellipsized box) is clipped away with it. The parts' own rules
|
||||
#: are neutralized (no flex row, no per-part ellipsis, the pre-phase
|
||||
#: non-mono count font). Phase 149 (task 02): the set ALSO
|
||||
#: neutralizes the phase-149 hide rule — ``.sync-btn.is-running
|
||||
#: .sync-label { display: none }`` (0,2,0) would beat the injected
|
||||
#: (0,1,0) ``.sync-label`` rule and leave the parts display:none, so
|
||||
#: the counterfactual restores the parts' pre-phase inline-block
|
||||
#: display at matching specificity (last in the cascade it wins);
|
||||
#: pre-phase, the hide rule simply did not exist. The rules are
|
||||
#: complete rule strings — they go in via CSSOM ``insertRule``
|
||||
#: (appended to the origin's own linked stylesheet, so the cascade
|
||||
#: position is "last wins" like an injected ``<style>`` tag — which
|
||||
#: the A20 CSP would block). The probe measured this override
|
||||
#: reproduces the owner's report at 390 px with the repo's fonts (the
|
||||
#: count's box fully past the label's clip edge and past the pill's
|
||||
#: right edge).
|
||||
_PRE_PHASE_SYNC_RULES = (
|
||||
".sync-label { display: inline-block; align-items: initial; gap: 0; "
|
||||
"max-width: none; min-width: 0; overflow: hidden; "
|
||||
"text-overflow: ellipsis; white-space: nowrap; }",
|
||||
".sync-label-main { min-width: 0; overflow: visible; text-overflow: clip; }",
|
||||
".sync-label-count { flex: 0 1 auto; font-family: inherit; }",
|
||||
".sync-btn.is-running .sync-label { display: inline-block; }",
|
||||
)
|
||||
|
||||
#: Test 4 ONLY — the PARTS-level in-flight IMPORT snapshot (the
|
||||
#: counterfactual clip reproduction): the writer still lands the label
|
||||
#: PARTS (main + count + optional chunk) in the DOM for every running
|
||||
#: label (phase 149's box reads the same writer output), so under the
|
||||
#: re-injected pre-phase-128 CSS the parts' texts + geometry are the
|
||||
#: observable — the count part's box runs past the label's clip edge
|
||||
#: (painted away) + off the pill's right edge (the bug). Reads the
|
||||
#: parts' textContent + hidden flags, the button title (A4), and the
|
||||
#: getBoundingClientRect boxes of the label, the count part, and the
|
||||
#: button — atomically (one in-browser evaluation, so the numbers
|
||||
#: never straddle a 2 s poll tick).
|
||||
_JS_PARTS_IMPORT_SNAPSHOT = """
|
||||
() => {
|
||||
const label = document.querySelector('#sync-label');
|
||||
const btn = document.querySelector('#sync-btn');
|
||||
@@ -287,61 +414,6 @@ _JS_IMPORT_SNAPSHOT = """
|
||||
}
|
||||
"""
|
||||
|
||||
#: Test 2 — the SUMMARIES label: main carries "Summarizing folders…
|
||||
#: [source/folder]" (the folder part optional — the phase's first
|
||||
#: poll may precede the first hook), the count part is visible with
|
||||
#: the "(n/3)" position, and the title carries the full untruncated
|
||||
#: label (A4).
|
||||
_JS_SUMMARY_SNAPSHOT = """
|
||||
() => {
|
||||
const label = document.querySelector('#sync-label');
|
||||
const btn = document.querySelector('#sync-btn');
|
||||
if (!label || !btn) return null;
|
||||
const main = label.querySelector('.sync-label-main');
|
||||
const count = label.querySelector('.sync-label-count');
|
||||
if (!main || !count || count.hidden) return null;
|
||||
const reMain = /^Summarizing folders…(?: \\S+)?$/;
|
||||
const reCount = /^\\(\\d+\\/3\\)$/;
|
||||
if (!reMain.test(main.textContent) || !reCount.test(count.textContent)) return null;
|
||||
const title = btn.getAttribute('title');
|
||||
if (title !== main.textContent + ' ' + count.textContent) return null;
|
||||
const rect = (el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return { left: b.left, right: b.right, width: b.width, height: b.height };
|
||||
};
|
||||
return {
|
||||
mainText: main.textContent,
|
||||
countText: count.textContent,
|
||||
title,
|
||||
labelRect: rect(label),
|
||||
countRect: rect(count),
|
||||
btnRect: rect(btn),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
#: The pre-phase-128 ``.sync-label`` geometry, re-injected for test 4
|
||||
#: (the bug reproduction): the base rule's inline-block + single
|
||||
#: ellipsis with the mobile media query's lifted cap (``max-width:
|
||||
#: none``) — the label sizes to its FULL content and its ONE ellipsis
|
||||
#: owns the whole tail, so the count (now riding a child span inside
|
||||
#: that ellipsized box) is clipped away with it. The parts' own rules
|
||||
#: are neutralized (no flex row, no per-part ellipsis, the pre-phase
|
||||
#: non-mono count font). The rules are complete rule strings — they
|
||||
#: go in via CSSOM ``insertRule`` (appended to the origin's own
|
||||
#: linked stylesheet, so the cascade position is "last wins" like an
|
||||
#: injected ``<style>`` tag — which the A20 CSP would block). The
|
||||
#: probe measured this override reproduces the owner's report at
|
||||
#: 390 px with the repo's fonts (the count's box fully past the
|
||||
#: label's clip edge and past the pill's right edge).
|
||||
_PRE_PHASE_SYNC_RULES = (
|
||||
".sync-label { display: inline-block; align-items: initial; gap: 0; "
|
||||
"max-width: none; min-width: 0; overflow: hidden; "
|
||||
"text-overflow: ellipsis; white-space: nowrap; }",
|
||||
".sync-label-main { min-width: 0; overflow: visible; text-overflow: clip; }",
|
||||
".sync-label-count { flex: 0 1 auto; font-family: inherit; }",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
@@ -563,12 +635,12 @@ def _start_sync(page: Page, app_url: str, sync_dir: Path) -> None:
|
||||
|
||||
# The click's immediate state (the 202 moment — the bare prefix,
|
||||
# the phase-64 contract): disabled, aria-busy, spinning icon, and
|
||||
# the label's MAIN part alone (the count part is hidden — its text
|
||||
# is empty, so the parts' concatenation is exactly "Syncing…").
|
||||
# the detail box revealed with the bare label (phase 149 — the
|
||||
# running detail rides the box; the button is the spinner).
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_attribute("aria-busy", "true")
|
||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
|
||||
@@ -607,34 +679,32 @@ 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()
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
|
||||
def _assert_count_not_clipped(snap: dict[str, Any], viewport_w: int) -> None:
|
||||
"""The phase 128 geometry contract for a rendered running label at
|
||||
the given viewport: the label box is INSIDE the viewport (the bug's
|
||||
direct negation), the count part is VISIBLE (a non-degenerate box),
|
||||
and the count is NOT clipped — its right edge sits at or inside
|
||||
the label's right edge (the label's own clip boundary) and the
|
||||
button's right edge (the pill). The 1 px slack covers float
|
||||
rounding only — the layout nests the boxes exactly."""
|
||||
label, count, btn = snap["labelRect"], snap["countRect"], snap["btnRect"]
|
||||
assert label["left"] >= 0, f"label runs off the left edge: {label}"
|
||||
assert label["right"] <= viewport_w, (
|
||||
f"label runs off the right edge of the {viewport_w}px viewport: {label}"
|
||||
def _assert_box_fits_viewport(snap: dict[str, Any], viewport_w: int) -> None:
|
||||
"""The phase 149 geometry contract for the rendered running detail
|
||||
at the given viewport: the detail box is VISIBLE (a non-degenerate
|
||||
box) and INSIDE the viewport (left ≥ 0, right ≤ the viewport
|
||||
width — the owner's clipped-detail report, directly negated). The
|
||||
box is a block in the 72rem centered container, so it can only
|
||||
shrink toward the container's content width — it never overflows
|
||||
the viewport — and its text is the FULL untruncated label (the
|
||||
"(n/m)" count is IN the text; the wrap that keeps long paths
|
||||
on-screen is task 02's overflow-wrap: anywhere, pinned by the
|
||||
phase-149 dedicated suite). The 1 px slack covers float rounding
|
||||
only."""
|
||||
box = snap["boxRect"]
|
||||
assert box["width"] > 0 and box["height"] > 0, (
|
||||
f"the detail box has no visible box: {box}"
|
||||
)
|
||||
assert count["width"] > 0 and count["height"] > 0, (
|
||||
f"the count part has no visible box: {count}"
|
||||
)
|
||||
assert count["left"] >= label["left"] - 1, (
|
||||
f"the count part sits outside the label: {label} {count}"
|
||||
)
|
||||
assert count["right"] <= label["right"] + 1, (
|
||||
f"the count part is clipped by the label (its box runs past the "
|
||||
f"label's clip edge): label={label} count={count}"
|
||||
)
|
||||
assert count["right"] <= btn["right"] + 1, (
|
||||
f"the count part runs off the pill: btn={btn} count={count}"
|
||||
assert box["left"] >= 0, f"the detail box runs off the left edge: {box}"
|
||||
assert box["right"] <= viewport_w + 1, (
|
||||
f"the detail box runs off the right edge of the {viewport_w}px "
|
||||
f"viewport: {box}"
|
||||
)
|
||||
|
||||
|
||||
@@ -650,47 +720,43 @@ def test_mobile_label_fits_viewport_with_count_in_import(
|
||||
"""A fresh admin page on the sources view at a PHONE viewport
|
||||
(390×844) clicks **Sync sources**; while the import phase is
|
||||
running (the page's own 2 s poll renders it — the span outlives
|
||||
the poll by ≥ 2× under the slow proxy), the rendered ``#sync-label``
|
||||
is an atomic snapshot of: the label box INSIDE the viewport (left ≥
|
||||
0, right ≤ 390 — the bug's direct negation), the ``.sync-label-count``
|
||||
part VISIBLE with its "(n/4)" position and its right edge at or
|
||||
inside the label's and the button's right edges (NEVER clipped —
|
||||
the pre-phase geometry fails exactly these pins, test 4
|
||||
reproduces it), and the ``.sync-label-main`` part carrying
|
||||
``Syncing… <full source/relative/path>`` — the file part may be
|
||||
VISUALLY ellipsized, but the DOM text is untruncated and the button
|
||||
``title`` carries the FULL label (main + " " + count, A4). The run
|
||||
then settles (the phase-32 terminal shape)."""
|
||||
the poll by ≥ 2× under the slow proxy), the DETAIL BOX
|
||||
(``#sync-progress`` — phase 149: the running detail left the
|
||||
pill; the button is the spinner) is an atomic snapshot of:
|
||||
VISIBLE with the FULL untruncated label ``Syncing… <full
|
||||
source/relative/path> (n/4)`` (+ the phase-129 "· chunk n/m" part
|
||||
while a batch is in flight — optional) with the ``(n/4)`` count IN
|
||||
the text (NEVER clipped — nothing is ellipsized away), the box's
|
||||
bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
|
||||
bug's direct negation), the button in its running state
|
||||
(``is-running``), and the button ``title`` carrying the SAME full
|
||||
string (A4 — one source, two surfaces). The run then settles (the
|
||||
phase-32 terminal shape; the box hides)."""
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
_start_sync(page, app_url, synclabel_dir)
|
||||
|
||||
# The page's own 2 s poll renders the in-flight import label; the
|
||||
# atomic in-browser snapshot pins its text + geometry together.
|
||||
# (The Playwright sync API wraps a non-primitive result in a
|
||||
# JSHandle — .json_value() unwraps the snapshot dict.)
|
||||
# The page's own 2 s poll renders the in-flight import detail in
|
||||
# the box; the atomic in-browser snapshot pins its text + geometry
|
||||
# together. (The Playwright sync API wraps a non-primitive result
|
||||
# in a JSHandle — .json_value() unwraps the snapshot dict.)
|
||||
snap = page.wait_for_function(
|
||||
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
_JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
# The parts (the phase-64 byte-identical strings, unit-pinned):
|
||||
# main = "Syncing… <full source/relative/path>" (untruncated — the
|
||||
# visual ellipsis never touches textContent), count = "(n/4)".
|
||||
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
||||
assert IMPORT_COUNT_RE.match(snap["countText"]), snap["countText"]
|
||||
# A4: the button title carries the UNTRUNCATED label — the full
|
||||
# path, not the ellipsized visual tail (the in-browser check
|
||||
# already required title == main + " " + count; pin it here too).
|
||||
# Phase 129: the pinned chunk part ("· chunk n/m") rides the title
|
||||
# while a file's embed batch is in flight (optional — the
|
||||
# pre-first-batch frame has none; the phase-129 dedicated suite
|
||||
# pins it strictly).
|
||||
full = snap["mainText"] + " " + snap["countText"]
|
||||
if snap["chunkText"]:
|
||||
full += " " + snap["chunkText"]
|
||||
assert snap["title"] == full, snap
|
||||
# The geometry contract (the fix): label inside the viewport, the
|
||||
# count visible and never clipped.
|
||||
_assert_count_not_clipped(snap, MOBILE_VIEWPORT["width"])
|
||||
# The FULL untruncated label — the complete
|
||||
# source/relative/path, the (n/4) count IN the text (never
|
||||
# clipped), the optional phase-129 chunk part.
|
||||
assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
|
||||
# A4: the button title carries the SAME untruncated label (the
|
||||
# in-browser check already required title == box text; pin it here
|
||||
# too — one source, two surfaces).
|
||||
assert snap["title"] == snap["boxText"], snap
|
||||
# Phase 149: the running button is the spinner (is-running rides
|
||||
# the spin — the detail is the box's, not the pill's).
|
||||
assert snap["isRunning"] is True, snap
|
||||
# The geometry contract (the fix): the box is visible and inside
|
||||
# the viewport.
|
||||
_assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
|
||||
|
||||
_settle(page, app_url)
|
||||
|
||||
@@ -707,24 +773,26 @@ def test_mobile_label_shows_summaries_count(
|
||||
"""Same phone viewport; the sync runs through to the SUMMARIES
|
||||
phase (the post-import span where the file count sits still — the
|
||||
owner's "the number pauses" leg): the page's own 2 s poll renders
|
||||
``.sync-label-main`` as ``Summarizing folders… [source/folder]``
|
||||
(the folder part optional — the phase's first poll may precede the
|
||||
first hook) with the VISIBLE ``.sync-label-count`` part matching
|
||||
``(n/3)`` (the 3 candidate folders) — never clipped (the same pins
|
||||
as test 1) — and the title carrying the full untruncated label
|
||||
(A4). On settle the button leaves the running label (the phase-32
|
||||
terminal ``Synced HH:MM``) and the count span is hidden + empty."""
|
||||
the DETAIL BOX (``#sync-progress`` — phase 149) with
|
||||
``Summarizing folders… [source/folder] (n/3)`` (the folder part
|
||||
optional — the phase's first poll may precede the first hook — the
|
||||
``(n/3)`` count of the 3 candidate folders always present, IN the
|
||||
text — never clipped) with the same box-geometry pins as test 1
|
||||
(inside the viewport), the button in its running state, and the
|
||||
title carrying the SAME full string (A4). On settle the button
|
||||
leaves the running label (the phase-32 terminal ``Synced HH:MM``),
|
||||
the count span is hidden + empty, and the box hides."""
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
_start_sync(page, app_url, synclabel_dir)
|
||||
|
||||
snap = page.wait_for_function(
|
||||
_JS_SUMMARY_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
_JS_SUMMARY_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
assert SUMMARY_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
||||
assert SUMMARY_COUNT_RE.match(snap["countText"]), snap["countText"]
|
||||
assert snap["title"] == snap["mainText"] + " " + snap["countText"], snap
|
||||
_assert_count_not_clipped(snap, MOBILE_VIEWPORT["width"])
|
||||
assert SUMMARY_FULL_RE.match(snap["boxText"]), snap["boxText"]
|
||||
assert snap["title"] == snap["boxText"], snap
|
||||
assert snap["isRunning"] is True, snap
|
||||
_assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
|
||||
|
||||
_settle(page, app_url)
|
||||
|
||||
@@ -738,43 +806,34 @@ def test_mobile_label_shows_summaries_count(
|
||||
def test_desktop_label_unchanged(
|
||||
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
|
||||
) -> None:
|
||||
"""A DESKTOP viewport (1280×800) keeps the pre-phase contract:
|
||||
during import the FULL rendered label text — the main + count
|
||||
parts' concatenation (main + " " + count, the CSS gap standing in
|
||||
for the old single span's space) — matches ``Syncing… <full
|
||||
source/relative/path> (n/m)``: the pre-phase byte-identical string
|
||||
(the parts rework changed the output SHAPE, not the text). The
|
||||
label's width respects the ``min(16rem, 40vw)`` cap — 256 px at
|
||||
this viewport (16 px :root) — with the count part pinned at the
|
||||
right edge and the main part ellipsized (the same cap/ellipsis the
|
||||
pre-phase label had), and the button ``title`` carries the same
|
||||
"""A DESKTOP viewport (1280×800) keeps the pre-phase contract —
|
||||
the TEXT: during import the detail box (``#sync-progress`` —
|
||||
phase 149 moved the running detail out of the pill to the box;
|
||||
the owner's report was that the full label was clipped in the
|
||||
pill even on desktop) carries ``Syncing… <full
|
||||
source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
|
||||
batch is in flight): the pre-phase byte-identical FULL string
|
||||
(phase 149 changed the SURFACE, not the text). The box is inside
|
||||
the viewport, the button is in its running state (``is-running``
|
||||
— the spinner), and the button ``title`` carries the same
|
||||
untruncated string (A4)."""
|
||||
page.set_viewport_size(DESKTOP_VIEWPORT)
|
||||
_start_sync(page, app_url, synclabel_dir)
|
||||
|
||||
snap = page.wait_for_function(
|
||||
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
_JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
# The pre-phase byte-identical FULL string (main + " " + count +
|
||||
# the phase-129 chunk part while a batch is in flight):
|
||||
# "Syncing… <full source/relative/path> (n/m)" (+ " · chunk n/m").
|
||||
full = snap["mainText"] + " " + snap["countText"]
|
||||
if snap["chunkText"]:
|
||||
full += " " + snap["chunkText"]
|
||||
assert IMPORT_FULL_RE.match(full), full
|
||||
assert snap["title"] == full, snap
|
||||
# The count part is visible and pinned inside the label (no
|
||||
# clipping on desktop either — the cap ellipsizes the MAIN part
|
||||
# only).
|
||||
count, label = snap["countRect"], snap["labelRect"]
|
||||
assert count["width"] > 0 and count["height"] > 0, count
|
||||
assert count["right"] <= label["right"] + 1, f"label={label} count={count}"
|
||||
# The cap: min(16rem, 40vw) = 256 px (16 px :root, 1280 px
|
||||
# viewport) — the bounding box width may not exceed it (+ slack).
|
||||
assert label["width"] <= DESKTOP_CAP_PX + CAP_SLACK_PX, (
|
||||
f"the label box exceeds the min(16rem, 40vw) cap: {label}"
|
||||
)
|
||||
# The pre-phase byte-identical FULL string ("Syncing… <full
|
||||
# source/relative/path> (n/m)" + " · chunk n/m" while a batch is
|
||||
# in flight) — now in the box, untruncated and wrapping.
|
||||
assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
|
||||
assert snap["title"] == snap["boxText"], snap
|
||||
# Phase 149: the running button is the spinner (is-running);
|
||||
# the count is IN the box's full text (the never-clipped pin —
|
||||
# nothing is ellipsized away on desktop either).
|
||||
assert snap["isRunning"] is True, snap
|
||||
_assert_box_fits_viewport(snap, DESKTOP_VIEWPORT["width"])
|
||||
|
||||
_settle(page, app_url)
|
||||
|
||||
@@ -791,9 +850,11 @@ def test_pre_phase_css_reproduces_the_clip(
|
||||
"""The same mobile run with the PRE-PHASE-128 ``.sync-label`` rules
|
||||
re-injected (CSSOM ``insertRule`` into the origin's own linked
|
||||
stylesheet — inline-block, the cap lifted, one ellipsis over the
|
||||
whole label, the count riding the ellipsized tail): the reported
|
||||
bug comes back geometrically — the count part's box extends past
|
||||
the label's clip edge (its right edge outside the label's right
|
||||
whole label, the count riding the ellipsized tail; the injected
|
||||
set also neutralizes the phase-149 ``is-running`` hide rule, which
|
||||
pre-phase did not exist): the reported bug comes back geometrically —
|
||||
the count part's box extends past the label's clip edge (its right
|
||||
edge outside the label's right
|
||||
edge, so the label's ``overflow: hidden`` paints it away) and past
|
||||
the pill's right edge (it runs off the button). This is the
|
||||
geometry tests 1–2's "count not clipped" pins reject — the suite's
|
||||
@@ -822,7 +883,7 @@ def test_pre_phase_css_reproduces_the_clip(
|
||||
)
|
||||
|
||||
snap = page.wait_for_function(
|
||||
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
_JS_PARTS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
||||
|
||||
@@ -0,0 +1,996 @@
|
||||
"""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``).
|
||||
|
||||
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.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``; git on PATH —
|
||||
a documented environment prerequisite, phase 28):
|
||||
|
||||
uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
|
||||
deterministic mock plus the phase-64 ``tests/e2e/slow_llm.py`` delay proxy
|
||||
(``SLOW_DELAY_S = "1.5"`` — the proxy delays EVERY LLM call, embeds
|
||||
included) in front of the mock: each in-flight state outlives the UI's
|
||||
2 s status poll by ≫ 1×, so every rendered label is observed
|
||||
deterministically, not as a race.
|
||||
|
||||
The suite runs ONE module app (the phase-79 full-inventory pattern — it
|
||||
binds its own port) over the shared E2E Postgres: the SLOW app with
|
||||
``BOR_GIT_SOURCES=file://<fixture repo>`` (the env fallback — the autouse
|
||||
truncate keeps the ``git_sources`` table empty, so the env list IS the
|
||||
effective source) + its own ``BOR_SOURCES_DIR`` for the checkout.
|
||||
|
||||
Timing fixture: a changed sync of the 3-file fixture is ≈ 2 model probes
|
||||
+ 3 files × (1 content-embed batch + 1 summary chat + 1 summary-chunk
|
||||
embed) + 1 overview + 7 folder summaries ≈ 19 LLM calls ≈ 30–35 s under
|
||||
the proxy. The LONG-path file is the FIRST of the three (the sorted walk —
|
||||
``notes/a-…`` < ``notes/short-…``), so its import span (7–8 embed batches
|
||||
× 1.5 s + the two summary calls ≈ 15 s) outlives the 2 s poll ~7× — the
|
||||
full-path + chunk-part windows the assertions observe are ≫ the poll
|
||||
period (deterministic, not races).
|
||||
|
||||
KB fixture — a real git repo (host temp dir via ``tmp_path_factory``; the
|
||||
app runs on the same host) with EXACTLY THREE markdown files:
|
||||
|
||||
* ``notes/a-rather-deeply-nested-folder/structure/for/the/sync/
|
||||
fixture-document.md`` — the LONG relative path (the clipping trigger the
|
||||
owner hit: a ``source/relative/path`` that overflows the pre-phase pill
|
||||
at desktop width), and a multi-chunk body (7 single-paragraph sections
|
||||
— one chunk apiece at the code-default 2000-char target, so the embed
|
||||
span is many 1.5 s token-budgeted batches: the wide in-flight window);
|
||||
* ``notes/short-one.md`` + ``notes/short-two.md`` — short paths, one chunk
|
||||
each (the sync has several files → the ``(n/3)`` count part and the
|
||||
per-file chunk restart are exercised too).
|
||||
|
||||
The source name is the checkout's directory basename (the ``repo_name``
|
||||
rule, phase 28): ``progressbox`` — so the in-flight label's file part is
|
||||
``progressbox/notes/a-rather-deeply-nested-folder/structure/for/the/sync/
|
||||
fixture-document.md``.
|
||||
|
||||
Autouse cleanup (the phase-96 pattern): before each test wait for no
|
||||
running sync on the app, then truncate the shared registry + KB tables
|
||||
(chunks/documents/query_log/steering_notes/kb_overview/git_sources/
|
||||
folder_summaries) — each test runs its own sync of the env-fallback source;
|
||||
after each test the same truncate. The checkout in ``BOR_SOURCES_DIR``
|
||||
survives across tests on purpose (the sync's pull is a fast no-op after
|
||||
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):
|
||||
|
||||
1. ``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
|
||||
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
|
||||
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
|
||||
needed, the block rides the 72rem container), no horizontal clipping
|
||||
(``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
|
||||
``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
|
||||
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).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, ViewportSize, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings as _Settings
|
||||
from app.db import SessionLocal
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
MOCK_PORT,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Phase 79 (task 04, full inventory): the conftest session app owns its
|
||||
# port in a combined run — this module's app binds its own port
|
||||
# instead (a same-port second uvicorn dies on bind and would drive the
|
||||
# wrong server). Env-overridable.
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCPROGRESS", "8166"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
#: The slow-LLM proxy's port (the conftest's mock LLM stays on
|
||||
#: MOCK_PORT — the proxy fronts it).
|
||||
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT_SYNCPROGRESS", "8908"))
|
||||
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
|
||||
|
||||
#: Per-LLM-request delay on the proxy (the phase-64/98 rationale,
|
||||
#: module docstring): the proxy delays EVERY call — embeds included —
|
||||
#: so the in-flight windows outlive the UI's 2 s poll by ≫ 1×.
|
||||
SLOW_DELAY_S = "1.5"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixture constants (deterministic, token-controlled)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: The fixture repo's directory name — the source name the import
|
||||
#: records for it (the ``repo_name`` basename rule, phase 28).
|
||||
SOURCE = "progressbox"
|
||||
|
||||
#: The LONG relative path — the clipping trigger the owner hit (a
|
||||
#: ``source/relative/path`` that overflows the pre-phase pill even on
|
||||
#: desktop) — and the two short-path siblings (the sync has several
|
||||
#: files: the ``(n/3)`` count part + the per-file chunk restart).
|
||||
LONG_REL = (
|
||||
"notes/a-rather-deeply-nested-folder/structure/for/the/sync/"
|
||||
"fixture-document.md"
|
||||
)
|
||||
SHORT_RELS = ("notes/short-one.md", "notes/short-two.md")
|
||||
N_FILES = 3
|
||||
|
||||
#: The in-flight label's file part — the status endpoint's
|
||||
#: ``current_file`` = ``<source>/<rel>`` (the sync's progress hook),
|
||||
#: verbatim. The long file is the FIRST of the three (the sorted walk —
|
||||
#: ``notes/a-…`` < ``notes/short-…``), so its count part is ``(1/3)``.
|
||||
LONG_PATH = f"{SOURCE}/{LONG_REL}"
|
||||
|
||||
#: The long file's shape (module docstring): an H1 preamble (one chunk)
|
||||
#: + 7 single-paragraph sections (~1500 chars each — one chunk apiece at
|
||||
#: the code-default 2000-char target) → ~8 chunks → ~8 token-budgeted
|
||||
#: embed batches × 1.5 s (the wide in-flight window the assertions
|
||||
#: observe — deterministic, not a race).
|
||||
N_LONG_SECTIONS = 7
|
||||
|
||||
SHORT_MD = (
|
||||
"# Short fixture {n}\n\nA tiny sync fixture note with a short path "
|
||||
"({n}).\n"
|
||||
)
|
||||
|
||||
#: The house mobile viewport (the ``test_sync_label_mobile.py``
|
||||
#: constant — the phone-width leg of the owner's ask).
|
||||
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
|
||||
|
||||
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
||||
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
|
||||
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
|
||||
#: The settled boot label — idle ("Sync sources") or the RE-ATTACHED
|
||||
#: terminal from a previous test's sync (the module app is shared, its
|
||||
#: in-memory sync state survives the test boundary, and the boot
|
||||
#: re-attach renders the last result).
|
||||
IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
|
||||
|
||||
#: Generous settle budgets: a changed sync against the slowed LLM is
|
||||
#: ≈ 30–35 s; the UI's 2 s poll settles at most one tick after the
|
||||
#: terminal state lands.
|
||||
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.
|
||||
WALL_BUDGET_S = 600.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The in-browser snapshot functions (Playwright Mapping Rule — the
|
||||
# rendered DOM is the observable).
|
||||
#
|
||||
# Each returns null while the awaited label state is absent, and an
|
||||
# ATOMIC snapshot once it is present: the box's text + the button's
|
||||
# aria-label / classes + the geometry — read in the SAME in-browser
|
||||
# evaluation that verified the state, so the numbers can never straddle
|
||||
# a 2 s poll tick (the single setSyncLabel writer updates the box, the
|
||||
# button, and the announcer together in one JS task — A4: one source,
|
||||
# two surfaces).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Tests 1 + 5 — 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).
|
||||
_JS_RUNNING_SNAPSHOT = """
|
||||
() => {
|
||||
const box = document.querySelector('#sync-progress');
|
||||
const textEl = document.querySelector('#sync-progress-text');
|
||||
const btn = document.querySelector('#sync-btn');
|
||||
const label = document.querySelector('#sync-label');
|
||||
const icon = btn ? btn.querySelector('.sync-icon') : null;
|
||||
const cancel = document.querySelector('#sync-cancel-btn');
|
||||
if (!box || !textEl || !btn || !label || !icon || !cancel) return null;
|
||||
if (box.hidden || !textEl.textContent) return null;
|
||||
const cs = (el) => getComputedStyle(el);
|
||||
return {
|
||||
boxText: textEl.textContent,
|
||||
ariaHidden: box.getAttribute('aria-hidden'),
|
||||
ariaLabel: btn.getAttribute('aria-label'),
|
||||
isRunning: btn.classList.contains('is-running'),
|
||||
isSpinning: icon.classList.contains('is-spinning'),
|
||||
labelHidden: cs(label).display === 'none',
|
||||
cancelVisible: !cancel.hidden && cs(cancel).display !== 'none',
|
||||
btnDisabled: btn.disabled,
|
||||
ariaBusy: btn.getAttribute('aria-busy'),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
#: Test 2, 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
|
||||
#: 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;
|
||||
const t = textEl.textContent;
|
||||
const prefix = 'Syncing… ' + longPath + ' (';
|
||||
if (!t.startsWith(prefix)) return null;
|
||||
const tail = t.slice(prefix.length);
|
||||
if (!/^\\d+\\/3\\)( · chunk \\d+\\/\\d+)?$/.test(tail)) return null;
|
||||
const cs = (el) => getComputedStyle(el);
|
||||
return {
|
||||
boxText: t,
|
||||
resultText: result.textContent,
|
||||
ariaLabel: btn.getAttribute('aria-label'),
|
||||
boxScrollWidth: box.scrollWidth,
|
||||
boxClientWidth: box.clientWidth,
|
||||
textScrollWidth: textEl.scrollWidth,
|
||||
textClientWidth: textEl.clientWidth,
|
||||
ink: cs(textEl).color,
|
||||
bg: cs(box).backgroundColor,
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
#: Test 2, 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
|
||||
#: long-file window ≫ the 2 s poll, so the retry loop resolves
|
||||
#: deterministically).
|
||||
_JS_LONG_FILE_CHUNK_SNAPSHOT = """
|
||||
(longPath) => {
|
||||
const box = document.querySelector('#sync-progress');
|
||||
const textEl = document.querySelector('#sync-progress-text');
|
||||
if (!box || !textEl || box.hidden) return null;
|
||||
const t = textEl.textContent;
|
||||
if (!t.startsWith('Syncing… ' + longPath + ' (1/3) · chunk ')) return null;
|
||||
const m = t.match(/· chunk (\\d+)\\/(\\d+)$/);
|
||||
if (!m) return null;
|
||||
return {
|
||||
boxText: t,
|
||||
chunkN: Number(m[1]),
|
||||
chunkM: Number(m[2]),
|
||||
boxScrollWidth: box.scrollWidth,
|
||||
boxClientWidth: box.clientWidth,
|
||||
textScrollWidth: textEl.scrollWidth,
|
||||
textClientWidth: textEl.clientWidth,
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
#: Test 3 — 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
|
||||
#: clipping (scrollWidth <= clientWidth), the icon-only button keeps the
|
||||
#: mobile full-width rule (its width:100% resolves against its
|
||||
#: containing block — the .page-head card's content width, NOT the
|
||||
#: container's), and the label parts are hidden.
|
||||
_JS_MOBILE_SNAPSHOT = """
|
||||
(longPath) => {
|
||||
const box = document.querySelector('#sync-progress');
|
||||
const textEl = document.querySelector('#sync-progress-text');
|
||||
const btn = document.querySelector('#sync-btn');
|
||||
const label = document.querySelector('#sync-label');
|
||||
const icon = btn ? btn.querySelector('.sync-icon') : null;
|
||||
const container = document.querySelector('#view-rag .container');
|
||||
const head = document.querySelector('#view-rag .page-head');
|
||||
if (!box || !textEl || !btn || !label || !icon || !container || !head) return null;
|
||||
if (box.hidden) return null;
|
||||
const t = textEl.textContent;
|
||||
if (!t.startsWith('Syncing… ' + longPath + ' (')) return null;
|
||||
const cs = (el) => getComputedStyle(el);
|
||||
const rect = (el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return { left: b.left, right: b.right, width: b.width, height: b.height };
|
||||
};
|
||||
// clientWidth already excludes the border — content = clientWidth
|
||||
// minus the horizontal padding only.
|
||||
const contentWidth = (el) => {
|
||||
const s = cs(el);
|
||||
return el.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight);
|
||||
};
|
||||
return {
|
||||
boxText: t,
|
||||
isRunning: btn.classList.contains('is-running'),
|
||||
isSpinning: icon.classList.contains('is-spinning'),
|
||||
labelHidden: cs(label).display === 'none',
|
||||
boxRect: rect(box),
|
||||
btnRect: rect(btn),
|
||||
containerContentWidth: contentWidth(container),
|
||||
headContentWidth: contentWidth(head),
|
||||
boxScrollWidth: box.scrollWidth,
|
||||
boxClientWidth: box.clientWidth,
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> None:
|
||||
"""Run git in *cwd*; a non-zero exit fails the fixture loudly."""
|
||||
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise AssertionError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
||||
|
||||
|
||||
def _commit(repo: Path, message: str) -> None:
|
||||
"""A fixture commit (never signed — the house ``commit.gpgsign``
|
||||
flag, the test_sync_button.py idiom)."""
|
||||
_git(
|
||||
repo,
|
||||
"-c", "user.email=e@x", "-c", "user.name=t",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "-qm", message,
|
||||
)
|
||||
|
||||
|
||||
def _long_md() -> str:
|
||||
"""The long-path fixture doc (see the module docstring): an H1
|
||||
preamble (one chunk) + 7 single-paragraph sections (~1500 chars
|
||||
each — one chunk apiece at the code-default 2000-char target — no
|
||||
randomness, so the chunk count is stable run to run)."""
|
||||
parts = ["# Long-path fixture document\n"]
|
||||
parts.append(
|
||||
"Preamble for the deeply nested sync fixture document. This "
|
||||
"paragraph sits under the H1 and chunks on its own.\n"
|
||||
)
|
||||
for k in range(1, N_LONG_SECTIONS + 1):
|
||||
parts.append(f"\n## Nesting {k}\n")
|
||||
base = (
|
||||
f"Nesting paragraph {k} of the long-path sync fixture carries "
|
||||
"deterministic filler for the chunker: alpha bravo charlie "
|
||||
"delta echo foxtrot golf hotel india juliet kilo lima "
|
||||
)
|
||||
parts.append((base * 30)[:1500] + "\n\n")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def progressbox_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""A real git repo the sync must clone over ``file://`` (see the
|
||||
module docstring): the long-path doc (the clipping trigger,
|
||||
multi-chunk body) + the two short-path docs. The directory NAME is
|
||||
the source name (the ``repo_name`` basename rule, phase 28).
|
||||
``tmp_path`` is function-scoped while the module-scoped app fixture
|
||||
needs the repo for the module's lifetime, so it is built under
|
||||
``tmp_path_factory`` via real ``git`` subprocess calls."""
|
||||
root = tmp_path_factory.mktemp("bor_syncprogress")
|
||||
repo = root / SOURCE
|
||||
long_file = repo / LONG_REL
|
||||
long_file.parent.mkdir(parents=True)
|
||||
long_file.write_text(_long_md(), encoding="utf-8")
|
||||
for i, rel in enumerate(SHORT_RELS, start=1):
|
||||
(repo / rel).write_text(SHORT_MD.format(n=i), encoding="utf-8")
|
||||
_git(repo, "init", "-q")
|
||||
_git(repo, "add", "-A")
|
||||
_commit(repo, "one")
|
||||
assert (repo / ".git").is_dir()
|
||||
assert len(list(repo.rglob("*.md"))) == N_FILES
|
||||
return repo
|
||||
|
||||
|
||||
def _app_env(
|
||||
base_url: str, repo: Path, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> dict[str, str]:
|
||||
"""The per-module app env (the conftest pattern): the ``base_url``
|
||||
picks the LLM (the slow proxy in front of the mock), and
|
||||
``BOR_GIT_SOURCES`` is this suite's own ``file://`` fixture repo —
|
||||
the sync's SUBJECT (the env fallback: the autouse truncate keeps the
|
||||
``git_sources`` table empty, so the env list is the effective
|
||||
source). The env var also overrides the owner's local (gitignored)
|
||||
``.env`` ``BOR_GIT_SOURCES`` (process env ranks above the file)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1" if USE_REAL_LLM else base_url
|
||||
)
|
||||
# Mock-calibrated gate values (conftest pattern): the floors ride
|
||||
# WITH the 0.30 threshold — a floor above it fails the startup
|
||||
# validator (phase 112/113).
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
# Phase 67: instant retry waits + the code-default budget (the
|
||||
# conftest leak-guard pattern).
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# The sync's subject (see the docstring) + its own checkout dir.
|
||||
env["BOR_GIT_SOURCES"] = f"file://{repo}"
|
||||
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
|
||||
# Leak guards (conftest pattern): an operator's local (gitignored)
|
||||
# .env cannot leak corpus-specific settings into the app under test.
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
_Settings.model_fields["suggestions"].default
|
||||
)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def slow_llm(mock_llm: int) -> Iterator[int]:
|
||||
"""The delay-injecting reverse proxy in front of the mock LLM
|
||||
(``tests/e2e/slow_llm.py``) — the timing fixture for the whole
|
||||
suite: it delays EVERY LLM call, embeds included, so the in-flight
|
||||
windows outlive the UI's 2 s poll by ≫ 1× (see ``SLOW_DELAY_S``)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
|
||||
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
|
||||
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{SLOW_URL}/v1/models")
|
||||
yield SLOW_PORT
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(
|
||||
mock_llm: int,
|
||||
slow_llm: int,
|
||||
progressbox_repo: Path,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[str]:
|
||||
"""The SLOW app under test (the whole suite):
|
||||
``BOR_LLM_BASE_URL`` is the slow proxy in front of the mock (the
|
||||
timing fixture), ``BOR_GIT_SOURCES`` the ``file://`` fixture repo
|
||||
(the sync's subject — see the module docstring), and
|
||||
``BOR_SOURCES_DIR`` its own checkout dir. ``progressbox_repo`` is a
|
||||
dependency only for the fixture ordering (the repo exists before
|
||||
the first test)."""
|
||||
env = _app_env(f"{SLOW_URL}/v1", progressbox_repo, tmp_path_factory)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB (the E2E isolation pattern): the E2E suites
|
||||
share one Postgres, so a leftover git_sources row would outrank
|
||||
this app's ``BOR_GIT_SOURCES`` env fallback (effective_sources
|
||||
prefers the DB list) and a leftover document would pollute the file
|
||||
counts the sync pins exactly."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, git_sources, folder_summaries"
|
||||
)
|
||||
)
|
||||
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
|
||||
|
||||
|
||||
def _wait_sync_settled(base_url: str, timeout_s: float = 180.0) -> None:
|
||||
"""No background sync may leak across tests (the run state lives in
|
||||
the app's memory, and a still-running run would keep importing into
|
||||
the NEXT test's truncated KB): wait for the app's sync status to be
|
||||
non-running BEFORE the truncate. Own admin session (the endpoint is
|
||||
admin-only) — usually a no-op: the tests settle only after their
|
||||
run's terminal state."""
|
||||
with httpx.Client(base_url=base_url, timeout=5.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
if body["state"] != "running":
|
||||
# Diagnostic: a FAILED terminal between tests is a real
|
||||
# anomaly for this suite (every sync here must succeed)
|
||||
# — surface the error text so a cascade is diagnosable
|
||||
# instead of showing up as a later test's blocked click.
|
||||
if body["state"] == "failed":
|
||||
print(f"sync progress box: terminal FAILED at a test "
|
||||
f"boundary: {body.get('error')}")
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
|
||||
"""Per-test KB isolation (the phase-96 cleanup pattern): the
|
||||
shared E2E Postgres is truncated before AND after every test —
|
||||
each test runs its own sync of the env-fallback source, so the KB
|
||||
rows of the fixture source are exactly this test's own. The
|
||||
pre-wait keeps a leaked run from the previous test (the module app
|
||||
is shared) from racing the truncate."""
|
||||
_wait_sync_settled(app_url)
|
||||
_truncate_all()
|
||||
yield
|
||||
_truncate_all()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _wall_time_guard() -> Iterator[None]:
|
||||
"""The module's wall-time budget: the whole module — app boot +
|
||||
FIVE full 3-file syncs against the slowed mock LLM — stays under
|
||||
``WALL_BUDGET_S``. A breach is a regression canary (the sync
|
||||
pipeline slowed), not a flake tolerance."""
|
||||
start = time.monotonic()
|
||||
yield
|
||||
elapsed = time.monotonic() - start
|
||||
assert elapsed < WALL_BUDGET_S, (
|
||||
f"the module took {elapsed:.1f}s — over the {WALL_BUDGET_S:.0f}s wall "
|
||||
"budget (the five small-fixture syncs against the slowed mock LLM "
|
||||
"are a speed canary)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_rgb(css_color: str) -> tuple[int, int, int]:
|
||||
"""A computed ``rgb(r, g, b)`` / ``rgba(…)`` color → the triple."""
|
||||
m = re.match(r"rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)", css_color)
|
||||
assert m, f"not a computable rgb color: {css_color!r}"
|
||||
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
|
||||
|
||||
def _wcag_luminance(rgb: tuple[float, float, float]) -> float:
|
||||
"""WCAG 2.1 relative luminance (the same math the theme suites run)."""
|
||||
|
||||
def lin(c: float) -> float:
|
||||
s = c / 255
|
||||
return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r, g, b = rgb
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
|
||||
|
||||
def _wcag_ratio(
|
||||
fg: tuple[float, float, float], bg: tuple[float, float, float]
|
||||
) -> float:
|
||||
l1, l2 = _wcag_luminance(fg), _wcag_luminance(bg)
|
||||
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
|
||||
|
||||
|
||||
def _start_sync(page: Page, app_url: str) -> None:
|
||||
"""Land the admin on the sources view, wait for the settled boot
|
||||
label (the phase-64 pattern — also proves the boot re-attach
|
||||
completed, so no later re-render can clobber the running state),
|
||||
then click **Sync sources** and pin the click's immediate 202
|
||||
state: the 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)."""
|
||||
login(page, app_url) # lands on /sources.html (the button's home)
|
||||
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#sync-label")).to_have_text(IDLE_OR_SETTLED)
|
||||
expect(btn).to_be_enabled()
|
||||
|
||||
btn.click()
|
||||
|
||||
# The click's immediate state (the 202 moment — the bare prefix,
|
||||
# the phase-64 contract): the button is the loading spinner and
|
||||
# the box carries the bare label (phase 149 — one source, two
|
||||
# surfaces).
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_attribute("aria-busy", "true")
|
||||
expect(btn).to_have_class(re.compile(r"\bis-running\b"))
|
||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
|
||||
def _wait_terminal(app_url: str, timeout_s: float = 120.0) -> dict[str, Any]:
|
||||
"""Poll the status endpoint (own admin session) until the UI-
|
||||
triggered run reaches a terminal state — the source of truth for
|
||||
the settle (the UI's 2 s poll renders it at most one tick later).
|
||||
A FAILED terminal fails the test with the terminal body (the
|
||||
``detail`` / error text) — never an opaque label timeout."""
|
||||
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
if body["state"] in ("success", "failed"):
|
||||
assert body["state"] == "success", body
|
||||
return body
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"the run never reached a terminal state: {body}")
|
||||
|
||||
|
||||
def _settle(page: Page, app_url: str) -> None:
|
||||
"""The phase-32 success settle (the 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."""
|
||||
_wait_terminal(app_url)
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
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-error-banner")).to_be_hidden()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. In flight: the button is the spinner only (no visible label text),
|
||||
# the aria-label carries the full live label, and Cancel rides along
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_running_button_is_spinner_only(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
||||
the page's OWN 2 s poll keeps the run in flight long enough (the
|
||||
slow proxy guarantees a ≫ 30 s window) that the in-flight state is
|
||||
observed deterministically: ``#sync-btn`` carries ``is-running``
|
||||
AND the icon ``is-spinning`` (the spinner IS the loading state),
|
||||
``#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
|
||||
mechanics (disabled + ``aria-busy``) are preserved, and the Cancel
|
||||
button is visible (the phase-134 contract unchanged)."""
|
||||
_start_sync(page, app_url)
|
||||
|
||||
# Poll (the generous timeout — the slow proxy's in-flight window is
|
||||
# ~30 s) until a run is in flight: the box is visible + non-empty.
|
||||
snap = page.wait_for_function(
|
||||
_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
# The button is the loading spinner (the owner's ask).
|
||||
assert snap["isRunning"] is True, snap
|
||||
assert snap["isSpinning"] is True, snap
|
||||
# The label parts are hidden — the button is the icon (no visible
|
||||
# label text).
|
||||
assert snap["labelHidden"] is True, snap
|
||||
assert page.locator("#sync-label").is_visible() is False
|
||||
assert page.locator("#sync-progress").is_visible()
|
||||
# The icon-only button keeps an accessible name — the FULL live
|
||||
# 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
|
||||
# The pre-phase running mechanics are preserved.
|
||||
assert snap["btnDisabled"] is True, snap
|
||||
assert snap["ariaBusy"] == "true", snap
|
||||
# The Cancel control rides the running state (phase 134 — unchanged).
|
||||
assert snap["cancelVisible"] is True, snap
|
||||
|
||||
_settle(page, app_url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_box_carries_the_full_untruncated_label(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""During the in-flight window of the LONG-path file (poll until the
|
||||
box text is ``Syncing… <full long path> (1/3)`` — the slow proxy
|
||||
makes the ~15 s span deterministic): the box's text contains the
|
||||
FULL label shape with the complete ``source/relative/path`` as a
|
||||
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)."""
|
||||
_start_sync(page, app_url)
|
||||
|
||||
# Wait A — the in-flight window of the long file (any frame of it —
|
||||
# the pre-first-batch frame has no chunk part yet).
|
||||
snap = page.wait_for_function(
|
||||
_JS_LONG_FILE_SNAPSHOT, arg=LONG_PATH, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
# The FULL label shape — the complete long path IN the text (the
|
||||
# clipping trigger, untruncated).
|
||||
expected_prefix = f"Syncing… {LONG_PATH} (1/3)"
|
||||
assert snap["boxText"].startswith(expected_prefix), snap["boxText"]
|
||||
assert LONG_PATH in snap["boxText"], snap["boxText"]
|
||||
# The wrap engaged — no horizontal overflow (the whole point of the
|
||||
# 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
|
||||
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).
|
||||
ratio = _wcag_ratio(_parse_rgb(snap["ink"]), _parse_rgb(snap["bg"]))
|
||||
assert ratio >= 4.5, f"{snap['ink']} on {snap['bg']}: {ratio:.2f}:1 < 4.5:1 (AA)"
|
||||
|
||||
# Wait B — the phase-129 chunk part IN THE BOX (the retry loop over
|
||||
# the slow embed window — the part is only present while an embed
|
||||
# batch is in flight; the ~15 s long-file window outlives the 2 s
|
||||
# poll by ≫ 1×).
|
||||
snap2 = page.wait_for_function(
|
||||
_JS_LONG_FILE_CHUNK_SNAPSHOT, arg=LONG_PATH, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
assert snap2["chunkN"] >= 1, snap2
|
||||
assert snap2["chunkM"] >= 1, snap2
|
||||
# The longer label (with the chunk part) still wraps — no clipping.
|
||||
assert snap2["boxScrollWidth"] <= snap2["boxClientWidth"], snap2
|
||||
assert snap2["textScrollWidth"] <= snap2["textClientWidth"], snap2
|
||||
|
||||
_settle(page, app_url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Mobile (390×844): the box is full-width, the button is icon-only
|
||||
# (the mobile full-width rule), and the label wraps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mobile_box_full_width(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""A fresh page at the house MOBILE viewport (390×844) clicks
|
||||
**Sync sources**; during the in-flight window of the LONG-path
|
||||
file: the box is FULL WIDTH (its bounding-box width ≈ the
|
||||
container's content width — a block in the same container rides
|
||||
the 100%-width phone shell with no override), no horizontal
|
||||
clipping (``scrollWidth <= clientWidth``), the button is
|
||||
icon-only (``is-running`` + the spinning icon, the label parts
|
||||
hidden) and keeps the mobile full-width rule, and the label wraps
|
||||
(the full long path is a substring — the old mobile-stuffing
|
||||
ellipsis is gone)."""
|
||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||
_start_sync(page, app_url)
|
||||
|
||||
snap = page.wait_for_function(
|
||||
_JS_MOBILE_SNAPSHOT, arg=LONG_PATH, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
|
||||
# The label wraps — the full long path is IN the box's text (the
|
||||
# old mobile-stuffing ellipsis is gone).
|
||||
assert snap["boxText"].startswith(f"Syncing… {LONG_PATH} ("), snap["boxText"]
|
||||
assert LONG_PATH in snap["boxText"], snap["boxText"]
|
||||
# The wrap engaged — no horizontal clipping at the phone width.
|
||||
assert snap["boxScrollWidth"] <= snap["boxClientWidth"], snap
|
||||
# The box is FULL WIDTH: its width ≈ the container's content width
|
||||
# (the 2px slack covers float rounding only).
|
||||
assert abs(snap["boxRect"]["width"] - snap["containerContentWidth"]) <= 2, snap
|
||||
# …and it is inside the viewport (the owner's clipped-detail
|
||||
# report, directly negated).
|
||||
assert snap["boxRect"]["left"] >= 0, snap["boxRect"]
|
||||
assert snap["boxRect"]["right"] <= MOBILE_VIEWPORT["width"] + 1, snap["boxRect"]
|
||||
# The button is icon-only (the spinner) and keeps the mobile
|
||||
# full-width rule: its width:100% resolves against its containing
|
||||
# block — the .page-head card's content width (the card's padding
|
||||
# + border inset it from the container).
|
||||
assert snap["isRunning"] is True, snap
|
||||
assert snap["isSpinning"] is True, snap
|
||||
assert snap["labelHidden"] is True, snap
|
||||
assert abs(snap["btnRect"]["width"] - snap["headContentWidth"]) <= 2, snap
|
||||
|
||||
_settle(page, app_url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Settle: the box hides, the "Synced HH:MM" pill is restored
|
||||
# byte-identical, and the counts line is unchanged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_settled_state_restores_the_pill(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> 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)."""
|
||||
_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.
|
||||
_wait_terminal(app_url)
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
|
||||
|
||||
# The pill is restored (the pre-phase byte-identical settle copy).
|
||||
expect(page.locator("#sync-label")).to_be_visible()
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
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()
|
||||
# The label parts are restored — the settle label carries no count
|
||||
# or chunk part.
|
||||
count = page.locator(".sync-label-count")
|
||||
expect(count).to_be_hidden()
|
||||
expect(count).to_have_text("")
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reattach_shows_the_box(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""A sync is triggered and observed IN FLIGHT (the box is visible),
|
||||
then the page is RELOADED (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 (``is-running`` + the spinning icon, the label
|
||||
parts hidden, the ``aria-label`` equal to the box's text, the
|
||||
Cancel control visible) — the never-stale contract: a page load
|
||||
mid-run shows the truth, not an idle pill."""
|
||||
_start_sync(page, app_url)
|
||||
|
||||
# In flight first (the pre-reload state: the box is visible).
|
||||
page.wait_for_function(_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS).json_value()
|
||||
|
||||
# Reload mid-run — the run keeps going on the server; the admin
|
||||
# session (the cookie) survives the reload.
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
|
||||
# The re-attach: the box is visible with the CURRENT label and the
|
||||
# button is the spinner again (any in-flight state — the run is
|
||||
# ~30 s and the reload lands within a few seconds of the start).
|
||||
snap = page.wait_for_function(
|
||||
_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||
).json_value()
|
||||
assert snap["isRunning"] is True, snap
|
||||
assert snap["isSpinning"] is True, snap
|
||||
assert snap["labelHidden"] is True, snap
|
||||
assert snap["boxText"], snap
|
||||
assert snap["ariaLabel"] == snap["boxText"], snap
|
||||
assert snap["cancelVisible"] is True, snap
|
||||
|
||||
_settle(page, app_url)
|
||||
@@ -727,16 +727,17 @@ def test_sync_button_names_the_summary_phase(
|
||||
page: Page, app_url: str, syncsum_dir: Path, db_ready: None
|
||||
) -> None:
|
||||
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
||||
the page's OWN 2 s poll renders ``#sync-label`` as
|
||||
``Summarizing folders… [source/folder]`` + the pinned ``(n/3)``
|
||||
count span (phase 128: the label is PARTS — the folder part is
|
||||
optional — the phase's first poll may precede the first hook — and
|
||||
the button ``title`` carries the full untruncated string, main +
|
||||
" " + count, A4); on settle the button shows the terminal
|
||||
``Synced HH:MM`` (the phase-32 contract) and ``#sync-result``
|
||||
carries the counts line. The prelude/import labels stay the
|
||||
phase-64 shape (the endpoint test pins those phases; the UI pins
|
||||
the new summaries label)."""
|
||||
the page's OWN 2 s poll renders the summaries label —
|
||||
``Summarizing folders… [source/folder] (n/3)`` (the folder part is
|
||||
optional — the phase's first poll may precede the first hook) — in
|
||||
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
|
||||
stay the phase-64 shape (the endpoint test pins those phases; the
|
||||
UI pins the new summaries label)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_register_source(app_url, str(syncsum_dir))
|
||||
login(page, app_url) # lands on /sources.html (the button's home)
|
||||
@@ -759,38 +760,35 @@ def test_sync_button_names_the_summary_phase(
|
||||
|
||||
# The click's immediate state (the 202 moment — the bare prefix,
|
||||
# the phase-64 contract): disabled, aria-busy, spinning icon, no
|
||||
# error…
|
||||
# error, and the detail box revealed with the bare label
|
||||
# (phase 149 — the running label rides the box)…
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_attribute("aria-busy", "true")
|
||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
# UI layer (D2): the page's own 2 s poll names the summaries phase
|
||||
# — the RENDERED label matches the D2 PARTS shape (phase 128:
|
||||
# the MAIN part carries "Summarizing folders… [folder]" — the
|
||||
# folder part is optional, the phase's first poll may precede the
|
||||
# first hook — and the pinned COUNT part carries the (n/3)
|
||||
# counts), and the button ``title`` carries the full untruncated
|
||||
# string (main + " " + count, A4). ``wait_for_function`` checks
|
||||
# BOTH in ONE in-browser evaluation: a poll tick updates the parts
|
||||
# + the title together in a single JS task, so the atomic check
|
||||
# can never straddle one (the JS regexes are the D2 shapes,
|
||||
# unit-pinned in tests/unit/test_frontend_sync_upload.py).
|
||||
# — the RENDERED label in the detail box (phase 149: #sync-progress
|
||||
# — the button is the spinner) matches the D2 shape —
|
||||
# "Summarizing folders… [folder] (n/3)" (the folder part is
|
||||
# optional, the phase's first poll may precede the first hook — the
|
||||
# (n/3) counts always present) — and the button ``title`` carries
|
||||
# the same full untruncated string (A4 — one source, two surfaces).
|
||||
# ``wait_for_function`` checks BOTH in ONE in-browser evaluation:
|
||||
# a poll tick updates the box + the title together in a single JS
|
||||
# task (the single setSyncLabel writer), so the atomic check can
|
||||
# never straddle one (the D2 shapes are unit-pinned in
|
||||
# tests/unit/test_frontend_sync_upload.py).
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const label = document.querySelector('#sync-label');
|
||||
const box = document.querySelector('#sync-progress');
|
||||
const textEl = document.querySelector('#sync-progress-text');
|
||||
const btn = document.querySelector('#sync-btn');
|
||||
if (!label || !btn) return false;
|
||||
const main = label.querySelector('.sync-label-main');
|
||||
const count = label.querySelector('.sync-label-count');
|
||||
if (!main || !count || count.hidden) return false;
|
||||
const reMain = /^Summarizing folders…(?: \\S+)?$/;
|
||||
const reCount = /^\\(\\d+\\/3\\)$/;
|
||||
return reMain.test(main.textContent)
|
||||
&& reCount.test(count.textContent)
|
||||
&& btn.getAttribute('title')
|
||||
=== main.textContent + ' ' + count.textContent;
|
||||
if (!box || !textEl || !btn || box.hidden) return false;
|
||||
const t = textEl.textContent;
|
||||
const reFull = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
|
||||
return reFull.test(t) && btn.getAttribute('title') === t;
|
||||
}""",
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
@@ -41,8 +41,10 @@ is asserted at BOTH layers the task pins:
|
||||
non-null ``current_file`` (``source/relative/path``) observed at
|
||||
some tick, the counts advancing, and the file-less ticks (model
|
||||
probe — A4) preceding the first file tick;
|
||||
* **UI** — polling the label for the ``Syncing…`` prefix plus a file
|
||||
path (generous timeout), which the page's own 2 s poll ticks render.
|
||||
* **UI** — polling the detail box (``#sync-progress`` — phase 149:
|
||||
the running label rides the box under the button row, the button is
|
||||
the spinner) for the ``Syncing…`` prefix plus a file path (generous
|
||||
timeout), which the page's own 2 s poll ticks render.
|
||||
|
||||
The upload archive is built in-test with Python's ``tarfile`` from
|
||||
**25 small ``.md`` files** (``e2e-prog.tar.gz`` → source
|
||||
@@ -64,9 +66,10 @@ Contract under test:
|
||||
documents** (the upload unpacked + registered only) and the sync
|
||||
button settled idle with no error UI; clicking **Sync sources**
|
||||
then imports the uploaded row with the LIVE "Syncing… <file> (n/m)"
|
||||
label (both layers — the phase-129 pinned chunk part "· chunk n/m"
|
||||
rides the label + title while a file's embed batch is in flight)
|
||||
and lands the counts ("N added", the catalog refreshes);
|
||||
label (both layers — the phase-149 detail box carries the full
|
||||
string, the phase-129 pinned chunk part "· chunk n/m" rides the box
|
||||
+ title while a file's embed batch is in flight) and lands the
|
||||
counts ("N added", the catalog refreshes);
|
||||
* **upload processing (phase 90 A2)**: the upload button shows the
|
||||
BARE "Processing…" for the whole background run (no file, no
|
||||
"(n/m)", no title — proven across a held first status GET); every
|
||||
@@ -76,11 +79,12 @@ Contract under test:
|
||||
result line points at the Sync button; the KB stays empty;
|
||||
* **sync live file (A4)**: a multi-file local source; clicking
|
||||
**Sync sources** on ``/sources.html`` shows "Syncing…" (bare, the
|
||||
pre-64 click state) then "Syncing… <file> (n/m)" (both layers — the
|
||||
phase-129 pinned chunk part "· chunk n/m" rides the label + title
|
||||
while a file's embed batch is in flight), then the pre-64 success
|
||||
settle — "Synced HH:MM" + the counts result line — preserved, plus
|
||||
the file in the label;
|
||||
pre-64 click state) in the detail box (phase 149 — the button is
|
||||
the spinner) then "Syncing… <file> (n/m)" (both layers — the phase-
|
||||
129 pinned chunk part "· chunk n/m" rides the box + title while a
|
||||
file's embed batch is in flight), then the pre-64 success settle —
|
||||
"Synced HH:MM" + the counts result line — preserved, plus the file
|
||||
in the label (the box hides at settle);
|
||||
* **reload re-attach (A1 + A2, phase 90)**: starting an upload and
|
||||
reloading ``/git-sources.html`` (the sub-second run may still be in
|
||||
flight — the boot re-attach enters the bare Processing state — or
|
||||
@@ -548,9 +552,10 @@ def test_upload_toast_then_navigate_away(
|
||||
documents** (phase 90 A1: the upload unpacked + registered only)
|
||||
and the sync button settled idle with no error UI; then click
|
||||
**Sync sources** (the new leg, phase 90) → the LIVE
|
||||
"Syncing… <file> (n/m)" label while the sync imports the uploaded
|
||||
row (both layers), then the success settle with the counts and the
|
||||
catalog refresh."""
|
||||
"Syncing… <file> (n/m)" detail while the sync imports the uploaded
|
||||
row (phase 149: the box under the button row — the button is the
|
||||
spinner — at both layers), then the success settle with the counts
|
||||
and the catalog refresh (the box hides)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
# (A previous test's terminal run may re-render its result line at
|
||||
@@ -599,33 +604,34 @@ def test_upload_toast_then_navigate_away(
|
||||
|
||||
# The click's immediate state (A4 — the bare prefix until the
|
||||
# import's first file): disabled, aria-busy, spinning icon, no
|
||||
# error…
|
||||
# error, and the detail box revealed with the bare label
|
||||
# (phase 149 — the running label rides the box)…
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_attribute("aria-busy", "true")
|
||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
# UI layer: the label gains the live file at the page's 2 s poll
|
||||
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT
|
||||
# span (phase 128: the space between them is the CSS gap, so the
|
||||
# rendered concatenation carries none — the FULL string with the
|
||||
# space rides the button title, A4). Phase 129: the pinned CHUNK
|
||||
# part ("· chunk n/m" — the file's embed-batch position, batch
|
||||
# granularity) renders while a file's embed batch is in flight —
|
||||
# both the rendered label (no space — the CSS gap) and the title
|
||||
# (with the space, A4) carry it.
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
# UI layer (phase 149 task 01): the RUNNING detail rides the
|
||||
# detail box — the page's 2 s poll tick reveals the live file in
|
||||
# #sync-progress as the FULL untruncated string: "Syncing…
|
||||
# <source/rel/path> (n/m)" (+ " · chunk n/m" while a file's embed
|
||||
# batch is in flight — phase 129, batch granularity). The box is
|
||||
# the visible running surface — nothing is ellipsized away, the
|
||||
# "(n/m)" count is IN the full string (the never-clipped pin),
|
||||
# and the button title carries the same full string (A4 — one
|
||||
# source, two surfaces).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
re.compile(
|
||||
rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md\(\d+/{N_FILES}\)(?:· chunk \d+/\d+)?"
|
||||
rf"^Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"
|
||||
r"(?: · chunk \d+/\d+)?$"
|
||||
),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own
|
||||
# visible count span, and the button title carries the full
|
||||
# untruncated string (main + " " + count [+ " " + chunk, phase
|
||||
# 129], A4).
|
||||
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
|
||||
# The box is the VISIBLE running surface (the count's old
|
||||
# "never clipped" pin now lives in its full string — the "(n/m)"
|
||||
# is in the text above).
|
||||
expect(page.locator("#sync-progress")).to_be_visible(
|
||||
timeout=SETTLE_TIMEOUT_MS
|
||||
)
|
||||
expect(btn).to_have_attribute(
|
||||
@@ -762,12 +768,14 @@ def test_sync_live_file_label(
|
||||
page: Page, app_url: str, db_ready: None, sync_local_dir: Path
|
||||
) -> None:
|
||||
"""A multi-file local source (the ``test_sync_button.py`` fixture
|
||||
style): on ``/sources.html`` click **Sync sources** → the label
|
||||
shows "Syncing…" (bare, the pre-64 click state) then "Syncing…
|
||||
<file> (n/m)" while running (endpoint layer: ``current_file``
|
||||
non-null at running ticks; UI layer: the label poll), then the
|
||||
success settle with the counts result line — the pre-phase-64 sync
|
||||
UX is preserved, plus the file."""
|
||||
style): on ``/sources.html`` click **Sync sources** → the detail
|
||||
box (phase 149 — the running label left the pill; the button is
|
||||
the spinner) shows "Syncing…" (bare, the pre-64 click state) then
|
||||
"Syncing… <file> (n/m)" while running (endpoint layer:
|
||||
``current_file`` non-null at running ticks; UI layer: the box
|
||||
poll), then the success settle with the counts result line (the
|
||||
box hides) — the pre-phase-64 sync UX's settle is preserved, plus
|
||||
the file in the label while running."""
|
||||
page.set_default_timeout(30_000)
|
||||
_seed_local_source(str(sync_local_dir))
|
||||
login(page, app_url) # lands on /sources.html (the button's home)
|
||||
@@ -792,33 +800,34 @@ def test_sync_live_file_label(
|
||||
|
||||
# The click's immediate state is the pre-64 one (A4 — the bare
|
||||
# prefix until the import's first file): disabled, aria-busy,
|
||||
# spinning icon, no error…
|
||||
# spinning icon, no error, and the detail box revealed with the
|
||||
# bare label (phase 149 — the running label rides the box)…
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_attribute("aria-busy", "true")
|
||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
# UI layer: the label gains the live file at the page's 2 s poll
|
||||
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT
|
||||
# span (phase 128: the space between them is the CSS gap, so the
|
||||
# rendered concatenation carries none — the FULL string with the
|
||||
# space rides the button title, A4). Phase 129: the pinned CHUNK
|
||||
# part ("· chunk n/m" — the file's embed-batch position, batch
|
||||
# granularity) renders while a file's embed batch is in flight —
|
||||
# both the rendered label (no space — the CSS gap) and the title
|
||||
# (with the space, A4) carry it.
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
# UI layer (phase 149 task 01): the RUNNING detail rides the
|
||||
# detail box — the page's 2 s poll tick reveals the live file in
|
||||
# #sync-progress as the FULL untruncated string: "Syncing…
|
||||
# <source/rel/path> (n/m)" (+ " · chunk n/m" while a file's embed
|
||||
# batch is in flight — phase 129, batch granularity). The box is
|
||||
# the visible running surface — nothing is ellipsized away, the
|
||||
# "(n/m)" count is IN the full string (the never-clipped pin),
|
||||
# and the button title carries the same full string (A4 — one
|
||||
# source, two surfaces).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
re.compile(
|
||||
rf"Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md\(\d+/{N_FILES}\)(?:· chunk \d+/\d+)?"
|
||||
rf"^Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)"
|
||||
r"(?: · chunk \d+/\d+)?$"
|
||||
),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own
|
||||
# visible count span, and the button title carries the full
|
||||
# untruncated string (main + " " + count [+ " " + chunk, phase
|
||||
# 129], A4).
|
||||
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
|
||||
# The box is the VISIBLE running surface (the count's old
|
||||
# "never clipped" pin now lives in its full string — the "(n/m)"
|
||||
# is in the text above).
|
||||
expect(page.locator("#sync-progress")).to_be_visible(
|
||||
timeout=SETTLE_TIMEOUT_MS
|
||||
)
|
||||
expect(btn).to_have_attribute(
|
||||
|
||||
@@ -92,6 +92,23 @@ contract: shown + enabled in EVERY sync-running state (the entry
|
||||
shows it for kind "sync"), hidden + disabled for the upload run
|
||||
(the upload job is NOT cancellable in this phase) and after the
|
||||
settle (settleSyncButton always hides it).
|
||||
|
||||
Phase 149 (task 01) adds the `running` flag to the single writer:
|
||||
setSyncLabel(mainText, countText = "", chunkText = "", running =
|
||||
false) also writes the SAME untruncated full label into the detail
|
||||
box (#sync-progress's .sync-progress-text — textContent only) and
|
||||
reveals the box IFF running (the running entry passes true, the
|
||||
settle passes the default false), and owns the button's accessible
|
||||
name (full live label while running, the ship value "Sync sources"
|
||||
restored on every non-running call). The running entry passes
|
||||
`running = true` + adds the `is-running` class (with the spin), and
|
||||
no longer sets aria-label (the writer owns it); the settle removes
|
||||
`is-running` (with the spin). The node harness rows gain the box's
|
||||
text/hidden pair, the button's `is-running` + the icon's
|
||||
`is-spinning` flags, and the button's aria-label — the behavioral
|
||||
contract: box visible + full label + is-running + spinning + the
|
||||
aria-label equal to the full label in EVERY running state, box
|
||||
hidden + the idle flags + the restored aria-label after the settle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -256,22 +273,33 @@ def test_fmt_sync_label_returns_the_parts_contract() -> None:
|
||||
|
||||
|
||||
def test_set_sync_label_is_the_single_parts_writer() -> None:
|
||||
"""setSyncLabel(mainText, countText = "", chunkText = "") — Phase
|
||||
128 (task 01) + Phase 129 (task 04): the SINGLE point that writes
|
||||
#sync-label. The MAIN part gets mainText (textContent only — the
|
||||
house rule, no HTML strings); the COUNT part gets countText when
|
||||
present (textContent + hidden=false — the "(n/m)" position is
|
||||
visible, NEVER clipped) and is emptied + hidden=true when absent;
|
||||
the CHUNK part (phase 129) gets chunkText under the SAME contract
|
||||
(present → the pinned "· chunk n/m" is visible, absent → emptied
|
||||
+ hidden=true — the part renders only while a file's embed batch
|
||||
"""setSyncLabel(mainText, countText = "", chunkText = "",
|
||||
running = false) — Phase 128 (task 01) + Phase 129 (task 04):
|
||||
the SINGLE point that writes #sync-label. The MAIN part gets
|
||||
mainText (textContent only — the house rule, no HTML strings);
|
||||
the COUNT part gets countText when present (textContent +
|
||||
hidden=false — the "(n/m)" position is visible, NEVER clipped)
|
||||
and is emptied + hidden=true when absent; the CHUNK part
|
||||
(phase 129) gets chunkText under the SAME contract (present →
|
||||
the pinned "· chunk n/m" is visible, absent → emptied +
|
||||
hidden=true — the part renders only while a file's embed batch
|
||||
is in flight). Then the UNTRUNCATED full label — mainText +
|
||||
(countText ? " " + countText : "") + (chunkText ? " " + chunkText
|
||||
: "") — rides the button title + #sync-result (the aria-live
|
||||
announcer) (A4 — the screen reader hears the whole "Syncing…
|
||||
<file> (2/3) · chunk 14/38")."""
|
||||
<file> (2/3) · chunk 14/38"). Phase 149 (task 01): the fourth
|
||||
parameter `running` (the in-flight flag — the running entry
|
||||
passes true, the settle the default false): the SAME full label
|
||||
lands in the detail box (.sync-progress-text, textContent only)
|
||||
and the box (#sync-progress) is visible IFF running (hidden in
|
||||
every non-running state), and the button's accessible name is
|
||||
the full label while running / the ship value "Sync sources"
|
||||
otherwise (overwrites/restores only — never removes)."""
|
||||
body = _fn(_js(), "setSyncLabel")
|
||||
assert 'function setSyncLabel(mainText, countText = "", chunkText = "")' in body
|
||||
assert (
|
||||
'function setSyncLabel(mainText, countText = "", chunkText = "", running = false)'
|
||||
in body
|
||||
), "phase 149: the in-flight flag is the fourth parameter"
|
||||
# the parts: textContent only (the house rule), null-guarded refs
|
||||
assert "syncLabelMain.textContent = mainText" in body
|
||||
assert "syncLabelCount.textContent = countText" in body
|
||||
@@ -302,7 +330,13 @@ def test_set_sync_label_is_the_single_parts_writer() -> None:
|
||||
i_full = body.find("const full")
|
||||
i_title = body.find("syncBtn.title = full")
|
||||
i_result = body.find("syncResult.textContent = full")
|
||||
assert -1 < i_full < i_title < i_result, ("built once: full → title → announcer")
|
||||
i_box = body.find("syncProgressText.textContent = full")
|
||||
i_hide = body.find("syncProgress.hidden = !running")
|
||||
i_aria = body.find('syncBtn.setAttribute("aria-label", running ? full : "Sync sources")')
|
||||
assert -1 < i_full < i_title < i_result < i_box < i_hide < i_aria, (
|
||||
"built once: full → title → announcer → the detail box (revealed IFF"
|
||||
" running) → the button's accessible name (phase 149)"
|
||||
)
|
||||
assert "innerHTML" not in body, "the label path is textContent only"
|
||||
|
||||
|
||||
@@ -421,16 +455,25 @@ def test_enter_running_state_is_phase_aware_and_writes_the_parts() -> None:
|
||||
# the parts are handed to the single writer (A4 lives in it) —
|
||||
# phase 129: the chunk part destructures with a "" default (the
|
||||
# bare builders — the upload job, the 202/409 click — return no
|
||||
# chunk part, so the part stays hidden there).
|
||||
# chunk part, so the part stays hidden there); phase 149: the
|
||||
# call passes `running = true` (the detail box reveals + the
|
||||
# full-label aria-label ride in the writer).
|
||||
assert "const { main, count, chunk = \"\" } =" in body, (
|
||||
"the chunk part destructures with the '' default (bare builders return none)"
|
||||
)
|
||||
i_set = body.find("setSyncLabel(main, count, chunk)")
|
||||
assert -1 < i_fall < i_set, "the parts are written after the split"
|
||||
i_set = body.find("setSyncLabel(main, count, chunk, true)")
|
||||
assert -1 < i_fall < i_set, "the parts are written after the split (running = true)"
|
||||
assert body.count("setSyncLabel(") == 1, "one writer call per entry"
|
||||
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
||||
assert "syncResult" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
||||
assert 'syncBtn.setAttribute("aria-label"' not in body, (
|
||||
"the aria-label write lives in setSyncLabel (phase 149: the full"
|
||||
" live label while running)"
|
||||
)
|
||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||
assert "syncBtn.classList.add(\"is-running\")" in body, (
|
||||
"the icon-only running button class is added with the spin (phase 149)"
|
||||
)
|
||||
|
||||
|
||||
def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
||||
@@ -448,6 +491,12 @@ def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
||||
assert "function settleSyncButton(label)" in settle
|
||||
assert "setSyncLabel(label)" in settle, "the settle label goes through the parts writer"
|
||||
assert settle.count("setSyncLabel(") == 1
|
||||
assert "setSyncLabel(label, true)" not in settle, (
|
||||
"the settle is NEVER a running state — the detail box hides (phase 149)"
|
||||
)
|
||||
assert "syncBtn.classList.remove(\"is-running\")" in settle, (
|
||||
"the running class is removed with the spin on EVERY settle (phase 149)"
|
||||
)
|
||||
assert "syncLabel.textContent" not in settle, ("no direct span write — the parts")
|
||||
# the success settle keeps the "Synced HH:MM" copy + the counts line
|
||||
# AFTER the settle (the settle's announcer write is superseded by
|
||||
@@ -487,6 +536,39 @@ def test_sync_label_markup_ships_the_hidden_chunk_part() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_sync_progress_box_markup_ships_under_the_button_row() -> None:
|
||||
"""Phase 149 (task 01): #sync-progress — the sync detail box under
|
||||
the button row (the shell's RAG view): between the .page-head block
|
||||
(the button row + the page sub) and #sync-result. Ships hidden +
|
||||
aria-hidden (the anonymous-safe/idle state — sources.js reveals it
|
||||
through the single setSyncLabel writer only while a run is in
|
||||
flight; presentational, so #sync-result stays the SOLE aria-live
|
||||
announcer — one live region, no double announce). It carries one
|
||||
.sync-progress-text span (id-pinned) — the writer's textContent
|
||||
target, the full untruncated live label."""
|
||||
html = _html()
|
||||
box = re.search(
|
||||
r'<div class="sync-progress" id="sync-progress" hidden aria-hidden="true">\s*'
|
||||
r'<span class="sync-progress-text" id="sync-progress-text"></span>\s*</div>',
|
||||
html,
|
||||
)
|
||||
assert box, "the shell must carry the #sync-progress detail box (phase 149)"
|
||||
# Under the button row, above the announcer: the RAG view's
|
||||
# .page-head block precedes the box, and the box precedes
|
||||
# #sync-result (one live region — the box is presentational).
|
||||
rag = html.find('id="view-rag"')
|
||||
head = html.find('class="page-head"', rag)
|
||||
result = html.find('id="sync-result"', rag)
|
||||
assert -1 < head < box.start() < result, (
|
||||
"the box sits between the RAG view's .page-head block and "
|
||||
"#sync-result (directly under the button row, above the announcer)"
|
||||
)
|
||||
# Ship-hidden + presentational (the reveal is run-scoped, in JS).
|
||||
tag = html[box.start() : html.find(">", box.start())]
|
||||
assert re.search(r"\bhidden\b", tag), "the box ships hidden (idle / anonymous-safe)"
|
||||
assert 'aria-hidden="true"' in tag, ("the box is presentational (no double announce)")
|
||||
|
||||
|
||||
def test_sync_label_chunk_css_is_the_pinned_part() -> None:
|
||||
""".sync-label-chunk (phase 129 task 04): the same pinned-part
|
||||
language as .sync-label-count — flex 0 0 auto (NEVER clipped),
|
||||
@@ -515,15 +597,33 @@ const syncLabelMain = { textContent: "" };
|
||||
const syncLabelCount = { textContent: "", hidden: true };
|
||||
const syncLabelChunk = { textContent: "", hidden: true };
|
||||
const syncResult = { textContent: "" };
|
||||
// Phase 149 (task 01): the detail box — presentational, ship-hidden;
|
||||
// the REAL writer mutates it (full label + the running reveal).
|
||||
const syncProgress = { hidden: true };
|
||||
const syncProgressText = { textContent: "" };
|
||||
// Phase 149 (task 01): class-tracking stubs — the is-running /
|
||||
// is-spinning travel-together contract is behavioral, not source.
|
||||
const syncBtn = {
|
||||
disabled: false,
|
||||
title: "",
|
||||
attrs: {},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
removeAttribute(k) { delete this.attrs[k]; },
|
||||
classList: { add() {}, remove() {} },
|
||||
classes: new Set(),
|
||||
classList: {
|
||||
add(c) { syncBtn.classes.add(c); },
|
||||
remove(c) { syncBtn.classes.delete(c); },
|
||||
has(c) { return syncBtn.classes.has(c); },
|
||||
},
|
||||
};
|
||||
const syncIcon = {
|
||||
classes: new Set(),
|
||||
classList: {
|
||||
add(c) { syncIcon.classes.add(c); },
|
||||
remove(c) { syncIcon.classes.delete(c); },
|
||||
has(c) { return syncIcon.classes.has(c); },
|
||||
},
|
||||
};
|
||||
const syncIcon = { classList: { add() {}, remove() {} } };
|
||||
// Phase 134 (task 02): the Cancel control — ships hidden + disabled
|
||||
// (the markup's state); the extracted REAL show/hide helpers mutate
|
||||
// it (show also sets the aria-label — the attrs pocket records it).
|
||||
@@ -544,6 +644,11 @@ const show = (tag) => {
|
||||
syncLabelCount.textContent, syncLabelCount.hidden,
|
||||
syncLabelChunk.textContent, syncLabelChunk.hidden,
|
||||
syncCancelBtn.hidden, syncCancelBtn.disabled,
|
||||
// Phase 149 (task 01): the detail box (text + hidden), the
|
||||
// is-running / is-spinning flags, the button's accessible name.
|
||||
syncProgressText.textContent, syncProgress.hidden,
|
||||
syncBtn.classes.has('is-running'), syncIcon.classes.has('is-spinning'),
|
||||
syncBtn.attrs['aria-label'] || '',
|
||||
]));
|
||||
};
|
||||
// 1. import, batch in flight (the task's fixture values, 14/38):
|
||||
@@ -596,6 +701,34 @@ def _run_node(script: str) -> str:
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _chunk_harness_rows() -> list:
|
||||
"""The node harness's rows — the REAL extracted label path against
|
||||
stub element refs (the test-document-viewer pattern), one JSON row
|
||||
per state: [tag, title, announcer, main, count, countHidden,
|
||||
chunk, chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||
progressHidden, btnRunning, iconSpinning, ariaLabel]. Shared by
|
||||
the chunk-part test (phase 129) and the detail-box test
|
||||
(phase 149)."""
|
||||
js = _js()
|
||||
fns = "\n".join(
|
||||
_fn(js, name)
|
||||
for name in (
|
||||
"fmtSyncLabel",
|
||||
"fmtSyncPhaseLabel",
|
||||
"setSyncLabel",
|
||||
# Phase 134 (task 02): the running entry's REAL cancel
|
||||
# show/hide + the REAL settle (step 8) — extracted against
|
||||
# the syncCancelBtn stub.
|
||||
"showSyncCancelBtn",
|
||||
"hideSyncCancelBtn",
|
||||
"enterSyncRunningState",
|
||||
"settleSyncButton",
|
||||
)
|
||||
)
|
||||
script = _CHUNK_NODE_HEAD + fns + _CHUNK_NODE_TAIL
|
||||
return [json.loads(line) for line in _run_node(script).splitlines() if line]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_chunk_part_renders_only_in_the_import_phase_under_node() -> None:
|
||||
"""Behavioral check (node) of the REAL label path — the extracted
|
||||
@@ -620,27 +753,11 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
|
||||
for the upload run (the upload job is NOT cancellable in this
|
||||
phase) and after the (real) settle.
|
||||
"""
|
||||
js = _js()
|
||||
fns = "\n".join(
|
||||
_fn(js, name)
|
||||
for name in (
|
||||
"fmtSyncLabel",
|
||||
"fmtSyncPhaseLabel",
|
||||
"setSyncLabel",
|
||||
# Phase 134 (task 02): the running entry's REAL cancel
|
||||
# show/hide + the REAL settle (step 8) — extracted against
|
||||
# the syncCancelBtn stub.
|
||||
"showSyncCancelBtn",
|
||||
"hideSyncCancelBtn",
|
||||
"enterSyncRunningState",
|
||||
"settleSyncButton",
|
||||
)
|
||||
)
|
||||
script = _CHUNK_NODE_HEAD + fns + _CHUNK_NODE_TAIL
|
||||
rows = [json.loads(line) for line in _run_node(script).splitlines() if line]
|
||||
rows = _chunk_harness_rows()
|
||||
by_tag = {row[0]: row for row in rows}
|
||||
# row = [tag, title, announcer, main, count, countHidden, chunk,
|
||||
# chunkHidden, cancelHidden, cancelDisabled]
|
||||
# chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||
# progressHidden, btnRunning, iconSpinning, ariaLabel]
|
||||
|
||||
# 1. the in-flight import — the chunk part renders, the full
|
||||
# untruncated label rides the title + the announcer (A4).
|
||||
@@ -694,6 +811,79 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_progress_box_and_running_button_contract_under_node() -> None:
|
||||
"""Phase 149 (task 01) — the detail box + the icon-only running
|
||||
button, behaviorally pinned through the SAME node harness (the
|
||||
REAL setSyncLabel / enterSyncRunningState / settleSyncButton
|
||||
against the stub element refs). The contract — the box is
|
||||
visible IFF a run is in flight, and its text is EXACTLY the full
|
||||
untruncated label (one source, two surfaces: the box for sighted
|
||||
users, the announcer for AT):
|
||||
|
||||
* EVERY running state (the in-flight import, the pre-batch
|
||||
import, the prelude, the 202/409 click, the upload run, the
|
||||
overview, the summaries): the box is REVEALED (hidden=false)
|
||||
and carries the full label (the same string as the button
|
||||
title / announcer — the writer's `full`), the button carries
|
||||
`is-running` + the icon `is-spinning` (the class travels with
|
||||
the spin), and the button's accessible name (aria-label) is
|
||||
the FULL live label (the icon-only running button keeps a
|
||||
name);
|
||||
* the settle (the REAL settleSyncButton through the writer with
|
||||
the default `running = false`): the box HIDES, the settle
|
||||
label is written into the box text ("Sync sources" — hidden,
|
||||
so inert), `is-running` + `is-spinning` are both gone, and
|
||||
the aria-label is restored to the ship value "Sync sources".
|
||||
"""
|
||||
rows = _chunk_harness_rows()
|
||||
by_tag = {row[0]: row for row in rows}
|
||||
# row = [tag, title, announcer, main, count, countHidden, chunk,
|
||||
# chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||
# progressHidden, btnRunning, iconSpinning, ariaLabel]
|
||||
|
||||
# Every running state: box visible + the EXACT full label +
|
||||
# is-running + spinning + the full-label accessible name.
|
||||
for tag in (
|
||||
"import-inflight",
|
||||
"import-prebatch",
|
||||
"prelude",
|
||||
"click",
|
||||
"upload",
|
||||
"overview",
|
||||
"summaries",
|
||||
):
|
||||
row = by_tag[tag]
|
||||
assert row[11] is False, (
|
||||
f"{tag}: the detail box must be VISIBLE while the run is in flight"
|
||||
)
|
||||
assert row[10] == row[1], (
|
||||
f"{tag}: the box carries the EXACT full label (title == box)"
|
||||
)
|
||||
assert row[12] is True, (
|
||||
f"{tag}: the button must carry is-running while in flight"
|
||||
)
|
||||
assert row[13] is True, (
|
||||
f"{tag}: is-running travels with the spin (the icon spins)"
|
||||
)
|
||||
assert row[14] == row[1], (
|
||||
f"{tag}: the running button's accessible name is the full live label"
|
||||
)
|
||||
|
||||
# The settle: the box hides, the running state is fully cleared,
|
||||
# and the accessible name is back to the ship value.
|
||||
row = by_tag["idle"]
|
||||
assert row[11] is True, "the settle must HIDE the detail box (run-scoped only)"
|
||||
assert row[10] == "Sync sources", (
|
||||
"the writer wrote the settle label into the box (hidden — inert)"
|
||||
)
|
||||
assert row[12] is False, "is-running is removed on the settle (with the spin)"
|
||||
assert row[13] is False, "the spin is removed on the settle"
|
||||
assert row[14] == "Sync sources", (
|
||||
"the settle restores the ship aria-label (the idle truth)"
|
||||
)
|
||||
|
||||
|
||||
def test_every_other_state_passes_no_chunk_text() -> None:
|
||||
"""Source-level pin of the render-only-while-chunks_total>0
|
||||
contract: the import fall-through is the ONLY place in sources.js
|
||||
@@ -701,7 +891,8 @@ def test_every_other_state_passes_no_chunk_text() -> None:
|
||||
branches and the bare builders never touch them), and every
|
||||
NON-running label emission passes no chunk text (the part hides):
|
||||
the settle/idle labels go through setSyncLabel(label) — the only
|
||||
three-arg call site is the running entry."""
|
||||
running-flagged call site (phase 149: `running = true`) is the
|
||||
running entry."""
|
||||
js = _js()
|
||||
body = _fn(js, "fmtSyncPhaseLabel")
|
||||
i_fall = body.find("fmtSyncLabel(")
|
||||
@@ -711,7 +902,9 @@ def test_every_other_state_passes_no_chunk_text() -> None:
|
||||
)
|
||||
fall = body[i_fall:]
|
||||
assert "status.chunks_total" in fall and "status.current_chunk" in fall
|
||||
assert js.count("setSyncLabel(main, count, chunk)") == 1
|
||||
assert js.count("setSyncLabel(main, count, chunk, true)") == 1, (
|
||||
"the ONLY running-flagged call site is the running entry (phase 149)"
|
||||
)
|
||||
assert "setSyncLabel(label)" in js, "the settle/idle labels stay two-arg (no chunk)"
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,17 @@ flight (the import phase after the first batch — hidden in every
|
||||
other state); the markup pin below pins the exact three-part ship
|
||||
shape.
|
||||
|
||||
Phase 149 (task 01): the running button becomes the SPINNER — the
|
||||
writer gains the `running` flag (the running entry passes `true`,
|
||||
the settle the default `false`), which reveals the detail box
|
||||
(#sync-progress — the full untruncated label, presentational
|
||||
aria-hidden; #sync-result stays the sole announcer) and owns the
|
||||
button's accessible name (the full live label while running, the
|
||||
ship value "Sync sources" restored on every settle); the
|
||||
`is-running` class travels with the spin (added in the running
|
||||
entry, removed in the settle). The pins below move onto that shape
|
||||
(the aria-label write leaves the running entry for the writer).
|
||||
|
||||
Phase 129 (task 03): the status contract gains the in-flight file's
|
||||
EMBED-chunk position — ``current_chunk`` (the 1-based first chunk of
|
||||
the in-flight token-budgeted batch) + ``chunks_total`` (the file's
|
||||
@@ -372,7 +383,12 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
||||
(phase 129 — hidden when absent), and the FULL untruncated label
|
||||
on the button title + #sync-result (A4) — the direct span /
|
||||
title / announcer writes are GONE from the entry (they live in
|
||||
setSyncLabel)."""
|
||||
setSyncLabel). Phase 149 (task 01): the writer call passes
|
||||
`running = true` (the detail box reveals with the full label),
|
||||
and the button's accessible name rides in the writer too (the
|
||||
full live label — the icon-only running button keeps a name);
|
||||
the entry no longer sets aria-label at all. The `is-running`
|
||||
class is added here, with the spin."""
|
||||
js = _text(SOURCES_JS)
|
||||
body = _body(js, "enterSyncRunningState")
|
||||
assert "syncBtn.disabled = true" in body
|
||||
@@ -381,15 +397,22 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
||||
assert "fmtSyncPhaseLabel(status)" in body
|
||||
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
|
||||
# …and the single writer lands the parts (A4 in it — the title is
|
||||
# set for EVERY running label: the file-only rule is adjusted away).
|
||||
assert "setSyncLabel(main, count, chunk)" in body
|
||||
# set for EVERY running label: the file-only rule is adjusted
|
||||
# away) with the phase-149 `running` flag (the box reveals).
|
||||
assert "setSyncLabel(main, count, chunk, true)" in body
|
||||
assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer"
|
||||
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||
assert "syncResult.textContent" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
||||
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
||||
assert 'syncBtn.setAttribute("aria-label"' not in body, (
|
||||
"the aria-label write lives in setSyncLabel (phase 149: the full"
|
||||
" live label while running)"
|
||||
)
|
||||
assert "syncBtn.classList.remove(\"is-error\")" in body
|
||||
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
||||
assert "syncBtn.classList.add(\"is-running\")" in body, (
|
||||
"the icon-only running button class travels with the spin"
|
||||
)
|
||||
|
||||
|
||||
def test_sources_js_terminal_states() -> None:
|
||||
@@ -439,7 +462,11 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
|
||||
(failed included: retry-ready). Phase 128 (task 01): the settle /
|
||||
idle label ("Sync sources", "Synced HH:MM") goes through the parts
|
||||
writer — setSyncLabel(label), count hidden (absent), the full
|
||||
string riding the title + #sync-result (A4, every label state)."""
|
||||
string riding the title + #sync-result (A4, every label state).
|
||||
Phase 149 (task 01): the `is-running` class is removed WITH the
|
||||
spin (every terminal + both idles pass through the settle), and
|
||||
the writer call's default `running = false` hides the detail box
|
||||
+ restores the idle accessible name."""
|
||||
js = _text(SOURCES_JS)
|
||||
body = _body(js, "settleSyncButton")
|
||||
assert "syncBtn.disabled = false" in body
|
||||
@@ -447,7 +474,13 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
|
||||
assert 'syncBtn.removeAttribute("title")' in body
|
||||
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
||||
assert "syncIcon.classList.remove(\"is-spinning\")" in body
|
||||
assert "syncBtn.classList.remove(\"is-running\")" in body, (
|
||||
"the running class travels with the spin — the settle removes both"
|
||||
)
|
||||
assert "setSyncLabel(label)" in body, ("the settle label goes through the parts writer")
|
||||
assert "setSyncLabel(label, true)" not in body, (
|
||||
"the settle is NEVER a running state — the box hides (phase 149)"
|
||||
)
|
||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user