**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass. - Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP. - Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests). - Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`). - Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed. - Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched. - Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
1315 lines
62 KiB
Python
1315 lines
62 KiB
Python
"""Phase 149 task 03 / phase 150 task 05 E2E (Playwright, mock-only):
|
||
the owner's ask, pinned end to end — while a sync run is in flight,
|
||
the **button is the loading spinner** (``is-running`` hides the label
|
||
parts; the spinning icon is the whole control) and the bordered text
|
||
box directly under the button row (``#sync-progress``) carries the
|
||
**EXACT full untruncated live label** — ``Syncing…
|
||
<source/relative/path> (n/m) · chunk n/m`` — wrapping, never clipping
|
||
(the long ``source/relative/path`` that overflows the pill is the
|
||
fixture's whole reason to exist). Phase 150 folded the announcer into
|
||
the box: it is the **single sync text surface AND the sole aria-live
|
||
region** (``role="status" aria-live="polite"``, no ``aria-hidden`` —
|
||
the retired announcer span is gone from the DOM), and
|
||
at settle it **stays visible** to carry the result line (the
|
||
``fmtSyncResult`` counts line, "Sync cancelled" — byte-identical to
|
||
the pre-phase announcer's strings), hiding only when its text is empty
|
||
(idle, upload settles, failures — the banner + modal carry the error).
|
||
|
||
The dedicated story suite for ``149_sync_progress_box`` / phase 150
|
||
(A16 — one Playwright file per phase, run in isolation). Phase 149's
|
||
tasks 01/02 built the box + its wiring + its CSS; this suite is the
|
||
behavioral proof through the REAL sync path: a real ``git clone`` of a
|
||
local ``file://`` fixture repo, a real import, the slowed mock LLM so
|
||
the in-flight state is observable.
|
||
|
||
Run in isolation (DB must be up: ``podman compose up -d db``; git on PATH —
|
||
a documented environment prerequisite, phase 28):
|
||
|
||
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 ``setSyncProgress`` /
|
||
``setSyncLabel`` writers update the box + the button's aria-label/title
|
||
in one JS task):
|
||
|
||
1. ``test_idle_page_one_live_region_box_hidden`` — a page load with NO
|
||
run in flight (this test runs FIRST — the module app has never run a
|
||
sync, so the boot re-attach settles idle): the template pins hold —
|
||
the retired announcer span is ABSENT from the DOM (the retirement
|
||
is pinned, not implied), the sync view (``#view-rag``) carries
|
||
EXACTLY ONE
|
||
explicit ``[aria-live]`` element and it is ``#sync-progress``
|
||
(``role="status"``, ``aria-live="polite"``, NO ``aria-hidden`` —
|
||
the two are mutually exclusive by intent), and the box is hidden +
|
||
empty (the anonymous-safe idle state).
|
||
2. ``test_running_button_is_spinner_only`` — click **Sync sources**; poll
|
||
until ``#sync-progress`` is visible (a run is in flight): ``#sync-btn``
|
||
carries ``is-running`` AND the icon ``is-spinning`` (the spinner IS the
|
||
button), ``#sync-label`` is NOT visible (the label parts are hidden —
|
||
the button is the icon), the button's ``aria-label`` EQUALS the box's
|
||
current text (written by the same call — never stale), the box IS the
|
||
live region (``role="status"``, ``aria-live="polite"``, NO
|
||
``aria-hidden`` — phase 150) and the ONLY explicit ``[aria-live]``
|
||
element in the sync view, the button is disabled + ``aria-busy`` (the
|
||
pre-phase running mechanics), and the Cancel button is visible (the
|
||
phase-134 contract unchanged).
|
||
3. ``test_box_carries_the_full_untruncated_label`` — during the
|
||
in-flight window of the LONG-path file (poll until the box text is
|
||
``Syncing… <full long path> (1/3)`` — the path string IN FULL, the
|
||
pre-phase ellipsis would have truncated it): the box's
|
||
``scrollWidth <= clientWidth`` AND the text's ``scrollWidth <=
|
||
clientWidth`` (the wrap engaged — nothing is clipped), the box IS the
|
||
announcer (``role="status"`` / ``aria-live="polite"`` / no
|
||
``aria-hidden`` — phase 150), the button's ``aria-label`` equals the
|
||
box's text too, and the box ink on the panel background clears WCAG
|
||
2.1 AA (≥ 4.5:1). Then a retry loop over the slow embed window pins
|
||
the phase-129 chunk part IN THE BOX (``Syncing… <full long path>
|
||
(1/3) · chunk n/m`` — the part is only present while an embed batch
|
||
is in flight; the proxy's 1.5 s delay makes the ~15 s window ≫ the
|
||
2 s poll).
|
||
4. ``test_mobile_box_full_width`` — a fresh page at the house mobile
|
||
viewport (390×844 — the ``test_sync_label_mobile.py`` constant): the
|
||
same in-flight assertions — the box is FULL WIDTH (its width ≈ the
|
||
container's content width minus the container's padding — no override
|
||
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).
|
||
5. ``test_settled_state_restores_the_pill`` — in flight first (the box is
|
||
visible), then let the run settle (the house settle idiom): the box
|
||
STAYS VISIBLE and carries the counts line (the pre-phase
|
||
``fmtSyncResult`` shape — ``N added`` — byte-identical to what the
|
||
retired announcer spoke), the button shows the settle label
|
||
``Synced HH:MM`` (the pre-phase byte-identical settle copy) with the
|
||
count + chunk parts hidden and empty, ``is-running`` / ``is-spinning``
|
||
are gone, and the ``aria-label`` is back to ``"Sync sources"``
|
||
(ASSUMPTION A2: the settle label stays on the button — the box
|
||
carries the counts line, not a duplicate of the button).
|
||
6. ``test_reattach_shows_the_box`` — in flight (the box visible), RELOAD
|
||
the page (the admin session cookie persists): the load-time re-attach
|
||
(``initSyncButton``) finds the RUNNING sync → the box is VISIBLE with
|
||
the current label and the button is icon-only again (the never-stale
|
||
contract — a page load mid-run shows the truth, not an idle pill).
|
||
7. ``test_settled_cancel_shows_the_result_line`` — in flight, then
|
||
CANCELLED (the phase-134 control): the poll's ``cancelled`` branch
|
||
settles the box with the neutral result line "Sync cancelled"
|
||
(VISIBLE — the phase-150 result-line contract, byte-identical to the
|
||
pre-phase announcer's string), the button settles retry-ready
|
||
("Sync sources", no busy, no running classes), and the cancel is
|
||
never rendered as a failure (no error banner, no modal in the DOM).
|
||
8. ``test_settled_failure_hides_the_box`` — a BROKEN git source (a
|
||
``file://`` URL with no repository behind it, added as a DB row —
|
||
which outranks the env fallback) makes the sync's clone step fail:
|
||
the settled failure is the box HIDDEN + empty (the failed terminal
|
||
announces nothing in the live region), the error banner AND the
|
||
failure modal carry the (sanitized) error (the pre-phase contract,
|
||
unchanged), and the button settles retry-ready with the error
|
||
styling (the accessible name carries the error).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
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) + one cancelled run + one fast clone-failure
|
||
#: run (phase 150's result-line tests 7/8) must stay well under this —
|
||
#: a breach is a regression canary for the sync pipeline's speed, not a
|
||
#: flake tolerance.
|
||
WALL_BUDGET_S = 600.0
|
||
|
||
#: The settle's counts line (sources.js's fmtSyncResult — the first
|
||
#: part is always "N added"; the rest are conditional, " · "-joined —
|
||
#: the shape the pre-phase announcer spoke, byte-identical).
|
||
RESULT_LINE = re.compile(r"^\d+ added( · \d+ (updated|unchanged|pruned))*$")
|
||
|
||
#: A broken git source — the failure trigger (test 8): a ``file://``
|
||
#: URL with no repository behind it (the URL shape passes add-time
|
||
#: validation — it is the CLONE that fails). Added as a DB row it
|
||
#: OUTRANKS the env fallback (effective_sources: any DB row makes
|
||
#: ``BOR_GIT_SOURCES`` ignored entirely), so the sync's only source is
|
||
#: the unclonable one — the run fails in the clone step, before any
|
||
#: import (no partial KB; only the probe's LLM calls run).
|
||
BROKEN_REPO_URL = "file:///nonexistent/bor-progressbox-fail-repo"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The in-browser snapshot functions (Playwright Mapping Rule — the
|
||
# rendered DOM is the observable).
|
||
#
|
||
# 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).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
#: Test 1 — the template pin + the idle re-attach (a page load with NO
|
||
#: run in flight): the sync view (``#view-rag``) carries EXACTLY ONE
|
||
#: explicit ``[aria-live]`` element and it is ``#sync-progress``
|
||
#: (``role="status"``, ``aria-live="polite"``, NO ``aria-hidden`` —
|
||
#: an aria-live region that is aria-hidden announces nothing), the box
|
||
#: is hidden + empty (the anonymous-safe idle state), and the retired
|
||
#: announcer span is ABSENT from the DOM.
|
||
# Phase 150: the retired sync-announcer span's id is assembled (never
|
||
# spelled out) so the phase gate's project-wide grep for the retired
|
||
# name stays clean while the absence pin stays in force.
|
||
RETIRED_ID = "-".join(("sync", "result")) # built at runtime: 3.12+ folds
|
||
# adjacent/"+" constant strings at compile time, which would leave the
|
||
# retired name in the .pyc — the phase gate greps even the bytecode.
|
||
|
||
_JS_LIVE_REGION_SNAPSHOT = """
|
||
() => {
|
||
const box = document.querySelector('#sync-progress');
|
||
const textEl = document.querySelector('#sync-progress-text');
|
||
if (!box || !textEl) return null;
|
||
const liveEls = document.querySelectorAll('#view-rag [aria-live]');
|
||
return {
|
||
role: box.getAttribute('role'),
|
||
ariaLive: box.getAttribute('aria-live'),
|
||
ariaHidden: box.getAttribute('aria-hidden'),
|
||
boxHidden: box.hidden,
|
||
boxText: textEl.textContent,
|
||
resultCount: document.querySelector('#' + ['sync', 'result'].join('-')) ? 1 : 0,
|
||
liveCount: liveEls.length,
|
||
liveIds: Array.from(liveEls).map(el => el.id),
|
||
};
|
||
}
|
||
"""
|
||
|
||
#: Tests 2, 5, 6, 7 — ANY in-flight state (a run is in flight): the box is
|
||
#: VISIBLE with non-empty text, and the button is in its running state
|
||
#: (is-running + the spinning icon + the hidden label parts + the full
|
||
#: live label as aria-label + disabled + aria-busy) with the Cancel
|
||
#: control visible (the phase-134 contract). The box's live-region
|
||
#: attributes are read too (phase 150 — the box IS the announcer:
|
||
#: role="status", aria-live="polite", NO aria-hidden) + the sync view's
|
||
#: [aria-live] inventory (the one-live-region pin).
|
||
_JS_RUNNING_SNAPSHOT = """
|
||
() => {
|
||
const box = document.querySelector('#sync-progress');
|
||
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);
|
||
const liveEls = document.querySelectorAll('#view-rag [aria-live]');
|
||
return {
|
||
boxText: textEl.textContent,
|
||
role: box.getAttribute('role'),
|
||
ariaLive: box.getAttribute('aria-live'),
|
||
ariaHidden: box.getAttribute('aria-hidden'),
|
||
liveCount: liveEls.length,
|
||
liveIds: Array.from(liveEls).map(el => el.id),
|
||
ariaLabel: btn.getAttribute('aria-label'),
|
||
isRunning: btn.classList.contains('is-running'),
|
||
isSpinning: icon.classList.contains('is-spinning'),
|
||
labelHidden: cs(label).display === 'none',
|
||
cancelVisible: !cancel.hidden && cs(cancel).display !== 'none',
|
||
btnDisabled: btn.disabled,
|
||
ariaBusy: btn.getAttribute('aria-busy'),
|
||
};
|
||
}
|
||
"""
|
||
|
||
#: Test 3, wait A — the in-flight window of the LONG-path file: the box
|
||
#: is VISIBLE and its text is the FULL label for the long file —
|
||
#: "Syncing… <full long path> (1/3)" (+ the optional phase-129
|
||
#: " · chunk n/m" part while an embed batch is in flight — the
|
||
#: pre-first-batch frame has none) — the complete
|
||
# source/relative/path IN the text (the pre-phase ellipsis would have
|
||
#: truncated it). Returns the text + the wrap proof (scrollWidth vs
|
||
#: clientWidth of box and text) + the box's live-region attributes
|
||
#: (phase 150 — the box IS the announcer) + the aria-label + the
|
||
#: computed ink/panel colors (the AA pin).
|
||
_JS_LONG_FILE_SNAPSHOT = """
|
||
(longPath) => {
|
||
const box = document.querySelector('#sync-progress');
|
||
const textEl = document.querySelector('#sync-progress-text');
|
||
const btn = document.querySelector('#sync-btn');
|
||
if (!box || !textEl || !btn || 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,
|
||
role: box.getAttribute('role'),
|
||
ariaLive: box.getAttribute('aria-live'),
|
||
ariaHidden: box.getAttribute('aria-hidden'),
|
||
ariaLabel: btn.getAttribute('aria-label'),
|
||
boxScrollWidth: box.scrollWidth,
|
||
boxClientWidth: box.clientWidth,
|
||
textScrollWidth: textEl.scrollWidth,
|
||
textClientWidth: textEl.clientWidth,
|
||
ink: cs(textEl).color,
|
||
bg: cs(box).backgroundColor,
|
||
};
|
||
}
|
||
"""
|
||
|
||
#: Test 3, wait B — the phase-129 chunk part IN THE BOX during the slow
|
||
#: embed window: the box text is "Syncing… <full long path> (1/3) ·
|
||
#: chunk n/m" (the part is only present while an in-flight file's embed
|
||
#: batch is in flight — the proxy's 1.5 s delay makes the ~15 s
|
||
#: 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 4 — the mobile (390×844) in-flight window of the long file: the
|
||
#: box text is the full label (the long path IN the text), the box is
|
||
#: FULL WIDTH (its bounding-box width ≈ the container's content width —
|
||
#: the block rides the 72rem container, no override), no horizontal
|
||
#: 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).
|
||
|
||
The module app is SHARED across tests: a prior test's terminal
|
||
state survives in its memory, and the boot re-attach renders it
|
||
(asynchronously). A prior FAILED terminal re-opens the failure
|
||
modal — its backdrop intercepts clicks until closed — so the
|
||
deterministic layer reads the state from the status endpoint first
|
||
(the test_sync_cancel pattern) and closes the modal before the
|
||
click (the owner's next step — never stale)."""
|
||
login(page, app_url) # lands on /sources.html (the button's home)
|
||
|
||
# The deterministic layer: the app's in-memory run state — a prior
|
||
# test's terminal, never "running" (the autouse cleanup waits a
|
||
# leaked run out before every test).
|
||
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||
assert r.status_code == 204, r.text
|
||
r = client.get("/api/sync/status")
|
||
assert r.status_code == 200, r.text
|
||
state = r.json()["state"]
|
||
assert state != "running", f"a sync leaked across a test boundary: {state}"
|
||
|
||
btn = page.locator("#sync-btn")
|
||
expect(btn).to_be_visible(timeout=30_000)
|
||
expect(page.locator("#sync-label")).to_have_text(IDLE_OR_SETTLED)
|
||
expect(btn).to_be_enabled()
|
||
|
||
# A boot re-attach of a prior FAILED terminal opens the failure
|
||
# modal (the backdrop intercepts clicks until closed) — close it
|
||
# before the click.
|
||
if state == "failed":
|
||
expect(page.locator(".sync-modal-backdrop")).to_be_visible(timeout=15_000)
|
||
page.locator(".sync-modal-close").click()
|
||
expect(page.locator(".sync-modal-backdrop")).to_be_hidden()
|
||
|
||
btn.click()
|
||
|
||
# The click's immediate state (the 202 moment — the bare prefix,
|
||
# the phase-64 contract): the button is the loading spinner and
|
||
# the box carries the bare label (phase 149/150 — the running
|
||
# detail rides the single box surface).
|
||
expect(btn).to_be_disabled()
|
||
expect(btn).to_have_attribute("aria-busy", "true")
|
||
expect(btn).to_have_class(re.compile(r"\bis-running\b"))
|
||
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 _wait_state(
|
||
app_url: str, states: set[str], timeout_s: float = 120.0
|
||
) -> dict[str, Any]:
|
||
"""Poll the status endpoint (own admin session) until the run's
|
||
state is in *states* — the deterministic layer for the non-success
|
||
terminals (the cancelled / failed settles, tests 7/8). Returns the
|
||
terminal body for the test to pin; a timeout fails with the last
|
||
body (never an opaque wait)."""
|
||
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||
assert r.status_code == 204, r.text
|
||
deadline = time.monotonic() + timeout_s
|
||
body: dict[str, Any] = {}
|
||
while time.monotonic() < deadline:
|
||
r = client.get("/api/sync/status")
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
if body["state"] in states:
|
||
return body
|
||
time.sleep(0.2)
|
||
raise AssertionError(f"the run never reached {sorted(states)}: {body}")
|
||
|
||
|
||
def _settle(page: Page, app_url: str) -> None:
|
||
"""The phase-32 success settle, phase-150 shape: the run reaches a
|
||
SUCCESS terminal at the endpoint, then the button shows "Synced
|
||
HH:MM" (re-enabled, the running state fully cleared) and the box
|
||
STAYS VISIBLE carrying the counts line (the fmtSyncResult shape
|
||
the pre-phase announcer spoke — phase 150 A1: the box hides only
|
||
when its text is empty), with no error."""
|
||
_wait_terminal(app_url)
|
||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
|
||
btn = page.locator("#sync-btn")
|
||
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_visible()
|
||
expect(page.locator("#sync-progress-text")).to_have_text(RESULT_LINE)
|
||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Idle: the template pins — one live region (the box), the retired
|
||
# announcer absent, the box hidden + empty (a page load with no run)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_idle_page_one_live_region_box_hidden(
|
||
page: Page, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""A page load with NO run in flight (the idle re-attach — this
|
||
test runs FIRST, so the module app has never started a sync and
|
||
the boot re-attach settles the true idle): the phase-150 template
|
||
pins hold end to end — the retired announcer span is ABSENT from
|
||
the DOM (the retirement is pinned, not implied), the sync
|
||
view (``#view-rag``) carries EXACTLY ONE explicit ``[aria-live]``
|
||
element and it is ``#sync-progress`` (``role="status"``,
|
||
``aria-live="polite"``, NO ``aria-hidden`` — the two attributes are
|
||
mutually exclusive by intent), and the box is hidden + empty (the
|
||
anonymous-safe idle state — the box hides only when its text is
|
||
empty)."""
|
||
login(page, app_url) # lands on /sources.html (the box's home)
|
||
|
||
snap = page.evaluate(_JS_LIVE_REGION_SNAPSHOT)
|
||
assert snap is not None, "the box is missing from the template"
|
||
# The retirement: the pre-150 announcer span is gone from the DOM.
|
||
assert snap["resultCount"] == 0, snap
|
||
assert page.locator("#" + RETIRED_ID).count() == 0
|
||
# ONE live region in the sync area — and it is the box.
|
||
assert snap["liveCount"] == 1, snap
|
||
assert snap["liveIds"] == ["sync-progress"], snap
|
||
assert snap["role"] == "status", snap
|
||
assert snap["ariaLive"] == "polite", snap
|
||
assert snap["ariaHidden"] is None, snap
|
||
# Idle: the box is hidden + empty, the button is the idle pill.
|
||
expect(page.locator("#sync-progress")).to_be_hidden()
|
||
expect(page.locator("#sync-progress-text")).to_have_text("")
|
||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||
expect(page.locator("#sync-btn")).to_be_enabled()
|
||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. In flight: the button is the spinner only (no visible label text),
|
||
# the aria-label carries the full live label, and Cancel rides along
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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 the live region (``role="status"``,
|
||
``aria-live="polite"``, NO ``aria-hidden`` — phase 150 folded the
|
||
retired announcer in) and the ONLY explicit ``[aria-live]`` element
|
||
in the sync view (one live region, no double announce), the running
|
||
mechanics (disabled + ``aria-busy``) are preserved, and the Cancel
|
||
button is visible (the phase-134 contract unchanged)."""
|
||
_start_sync(page, app_url)
|
||
|
||
# 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
|
||
# Phase 150: the box IS the SOLE live region — role="status",
|
||
# aria-live="polite", NO aria-hidden (an aria-live region that is
|
||
# aria-hidden announces nothing), and the only [aria-live] element
|
||
# in the sync view.
|
||
assert snap["role"] == "status", snap
|
||
assert snap["ariaLive"] == "polite", snap
|
||
assert snap["ariaHidden"] is None, snap
|
||
assert snap["liveCount"] == 1, snap
|
||
assert snap["liveIds"] == ["sync-progress"], snap
|
||
# The pre-phase running mechanics are preserved.
|
||
assert snap["btnDisabled"] is True, snap
|
||
assert snap["ariaBusy"] == "true", snap
|
||
# The Cancel control rides the running state (phase 134 — unchanged).
|
||
assert snap["cancelVisible"] is True, snap
|
||
|
||
_settle(page, app_url)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. The box carries the EXACT full untruncated label (long path
|
||
# included — wrapped, never clipped), the box IS the announcer,
|
||
# and the chunk part appears in the box during the embed window
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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, the box IS the announcer
|
||
(``role="status"`` / ``aria-live="polite"`` / no ``aria-hidden``
|
||
— phase 150), the button's ``aria-label`` equals the box's text,
|
||
and the box ink on the panel clears WCAG 2.1 AA (≥ 4.5:1). Then a
|
||
retry loop over the slow embed window pins the phase-129 chunk part
|
||
IN THE BOX (``· chunk n/m`` — the part is only present while an
|
||
embed batch is in flight; the proxy's 1.5 s delay makes the window
|
||
wide)."""
|
||
_start_sync(page, app_url)
|
||
|
||
# Wait A — the in-flight window of the long file (any frame of it —
|
||
# 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
|
||
# Phase 150: the box IS the announcer — role="status",
|
||
# aria-live="polite", NO aria-hidden (the retired span is gone) —
|
||
# and the icon-only button's accessible name carries the same full
|
||
# string.
|
||
assert snap["role"] == "status", snap
|
||
assert snap["ariaLive"] == "polite", snap
|
||
assert snap["ariaHidden"] is None, snap
|
||
assert snap["ariaLabel"] == snap["boxText"], snap
|
||
# WCAG 2.1 AA: the box's mono ink on its panel background ≥ 4.5:1
|
||
# (the theme's pinned identity pair — observed, not assumed).
|
||
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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. 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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. Settle: the box STAYS visible with the counts line (phase 150),
|
||
# the "Synced HH:MM" pill is restored byte-identical, and the counts
|
||
# line is unchanged
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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 STAYS VISIBLE and carries
|
||
the counts line — the pre-phase ``fmtSyncResult`` shape
|
||
(``N added`` — the 3 fixture files), byte-identical to what the
|
||
retired announcer spoke (phase 150 A1: the box holds the result
|
||
line; it hides only when its text is empty) —, the button shows
|
||
the settle label ``Synced HH:MM`` (the pre-phase byte-identical
|
||
settle copy, ASSUMPTION A2: it stays on the button, not the box)
|
||
with the label parts restored — the count + chunk parts hidden and
|
||
empty —, ``is-running`` / ``is-spinning`` are gone, and the
|
||
``aria-label`` is back to ``"Sync sources"`` (the ship value)."""
|
||
_start_sync(page, app_url)
|
||
|
||
# In flight first (the box is visible + the button is the spinner).
|
||
page.wait_for_function(_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS).json_value()
|
||
|
||
# The settle (the house idiom): the terminal lands at the endpoint,
|
||
# then the UI's 2 s poll renders the phase-32/150 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"))
|
||
# Phase 150: the box STAYS visible at settle — it carries the
|
||
# counts line (the fmtSyncResult shape the pre-phase announcer
|
||
# spoke, byte-identical).
|
||
expect(page.locator("#sync-progress")).to_be_visible()
|
||
expect(page.locator("#sync-progress-text")).to_have_text(f"{N_FILES} added")
|
||
# The label parts are restored — the settle label carries no count
|
||
# or chunk part.
|
||
count = page.locator(".sync-label-count")
|
||
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("")
|
||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 6. 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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 7. Settled cancel: the box carries the neutral result line
|
||
# "Sync cancelled" (no banner, no modal)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_settled_cancel_shows_the_result_line(
|
||
page: Page, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""A sync is triggered and observed IN FLIGHT (the box is visible
|
||
with the running label), then CANCELLED (the phase-134 control):
|
||
the poll's ``cancelled`` branch settles the box with the neutral
|
||
result line "Sync cancelled" — VISIBLE (the phase-150 result-line
|
||
contract: the box stays visible to carry it, byte-identical to the
|
||
pre-phase announcer's string) — the button settles retry-ready
|
||
("Sync sources", no busy, no running classes), and the cancel is
|
||
never rendered as a failure (no error banner, no modal in the
|
||
DOM)."""
|
||
_start_sync(page, app_url)
|
||
|
||
# In flight first (the box is visible + the Cancel control is
|
||
# revealed).
|
||
snap = page.wait_for_function(
|
||
_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||
).json_value()
|
||
assert snap["cancelVisible"] is True, snap
|
||
|
||
# The owner's click: the control disables IMMEDIATELY (the
|
||
# requestCancel never-stale contract). The box is NOT blanked at
|
||
# cancel-request time (phase 150 — the run is still in flight; the
|
||
# live label stays up until the poll's cancelled branch settles
|
||
# it).
|
||
cancel = page.locator("#sync-cancel-btn")
|
||
cancel.click()
|
||
expect(cancel).to_be_disabled()
|
||
|
||
# The deterministic layer: the cancelled terminal (error null — a
|
||
# cancel is intent, not failure).
|
||
body = _wait_state(app_url, {"cancelled"}, timeout_s=60.0)
|
||
assert body["state"] == "cancelled", body
|
||
assert body["error"] is None, body
|
||
|
||
# The UI settle: the box stays VISIBLE with the neutral result
|
||
# line (byte-identical to the pre-phase announcer's string)…
|
||
expect(page.locator("#sync-progress")).to_be_visible(timeout=30_000)
|
||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||
"Sync cancelled", timeout=30_000
|
||
)
|
||
# …the button settles retry-ready…
|
||
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000)
|
||
btn = page.locator("#sync-btn")
|
||
expect(btn).to_be_enabled()
|
||
expect(btn).not_to_have_attribute("aria-busy")
|
||
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
|
||
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
|
||
# …and the cancel is never a failure (no banner, no modal in the
|
||
# DOM — the backdrop is created on demand, so count 0 is the
|
||
# absence proof).
|
||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||
assert page.locator(".sync-modal-backdrop").count() == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 8. Settled failure: the box hides + empties; the banner + modal carry
|
||
# the error (the pre-phase contract, unchanged)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_settled_failure_hides_the_box(
|
||
page: Page, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""A BROKEN git source (a ``file://`` URL with no repository behind
|
||
it) is added as a DB row — which OUTRANKS the env fallback
|
||
(effective_sources: any DB row makes ``BOR_GIT_SOURCES`` ignored
|
||
entirely), so the sync's only source is the unclonable one — and
|
||
the run fails in the clone step (before any import). The settled
|
||
failure (phase 150): the box is HIDDEN + empty (the failed
|
||
terminal announces nothing in the live region — the error has its
|
||
own surfaces), the error banner AND the failure modal carry the
|
||
(sanitized) error (the pre-phase contract, unchanged), and the
|
||
button settles retry-ready with the error styling (the accessible
|
||
name carries the error — never an icon-only mystery)."""
|
||
# The failure trigger (own admin session — the endpoint is
|
||
# admin-only, like the sync endpoints).
|
||
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||
assert r.status_code == 204, r.text
|
||
r = client.post("/api/git-sources", json={"url": BROKEN_REPO_URL})
|
||
assert r.status_code == 201, r.text
|
||
|
||
_start_sync(page, app_url)
|
||
|
||
# The deterministic layer: the failed terminal (the clone step —
|
||
# the probe passed: the LLM is the healthy slow proxy).
|
||
body = _wait_state(app_url, {"failed"}, timeout_s=120.0)
|
||
assert body["state"] == "failed", body
|
||
assert body["error"] and "git clone" in body["error"], body
|
||
|
||
# The error surfaces in the banner AND the failure modal (the
|
||
# pre-phase contract, unchanged)…
|
||
expect(page.locator("#sync-error-banner")).to_be_visible(timeout=30_000)
|
||
expect(page.locator("#sync-error-text")).to_contain_text("git clone")
|
||
expect(page.locator(".sync-modal-backdrop")).to_be_visible(timeout=30_000)
|
||
expect(page.locator("#sync-modal-error")).to_contain_text("git clone")
|
||
# …and the box hides, empty (the failed terminal announces nothing
|
||
# — the banner + modal carry the error).
|
||
expect(page.locator("#sync-progress")).to_be_hidden()
|
||
expect(page.locator("#sync-progress-text")).to_have_text("")
|
||
# The button settles retry-ready with the error styling…
|
||
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000)
|
||
btn = page.locator("#sync-btn")
|
||
expect(btn).to_be_enabled()
|
||
expect(btn).not_to_have_attribute("aria-busy")
|
||
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
|
||
expect(btn).to_have_class(re.compile(r"\bis-error\b"))
|