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:
@@ -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)
|
||||
Reference in New Issue
Block a user