"""Phase 64 story E2E (Playwright): real-time progress for sync + upload. Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_sync_upload_progress.py -v --no-cov The story gate for the phase's executable proof (owner-locked A1–A5), re-pointed by **phase 90** (the upload no longer scans): the long-running KB job that reports **which file is being processed right now** is the **sync** — and the archive upload is fully **backgrounded but unpack-only**: ``POST /api/git-sources/upload`` answers 202 the moment the archive is on disk (the "Successfully uploaded — " toast fires — the user may navigate away), the unpack → swap → row upsert continues server-side behind ``GET /api/git-sources/upload/status`` (the phase-32 ``SyncStatus`` pattern; the phase-64 key set with ``current_file``/``files_done``/ ``files_total`` null/0/0 for the whole run — phase 90 A2), and the upload's UI processing state is the BARE "Processing…" (no file, no "(n/m)") until the no-count "Uploaded — press Sync sources to import it." result line lands (phase 90 A3). The scan — with its live file label — is the RAG page's **Sync sources** button's job, and the suite proves the new loop: upload → nothing indexed → **the sync that follows the upload shows its live file label and lands the counts**. **Timing fixture (the phase's fixture note, phase-90 re-pointed):** the mock LLM indexes fast — an in-progress state is real but brief. The UPLOAD run no longer calls the LLM at all (phase 90 removed the model check + import), so it settles in milliseconds: the upload-side assertions lean on (a) the deterministic terminal status shape (null/0/0 progress, the ``{"message": "uploaded"}`` detail — every running tick the recorder catches is asserted bare) and (b) a held first status GET that widens the bare "Processing…" window past the UI's 2 s poll. The SYNC leg still needs ``tests/e2e/slow_llm.py`` — the delay-injecting reverse proxy in front of the mock LLM (``SLOW_DELAY_S`` per request → a 25-file sync is 28 LLM requests ≈ 4.2 s) — so the sync outlives the 2 s poll and the live-file label is asserted at BOTH layers the task pins: * **deterministic** — the status endpoints (``page.request`` / the concurrent recorder, ~100 ms cadence): ``state == "running"`` with a non-null ``current_file`` (``source/relative/path``) observed at some tick, the counts advancing, and the file-less ticks (model probe — A4) preceding the first file tick; * **UI** — polling the label for the ``Syncing…`` prefix plus a file path (generous timeout), which the page's own 2 s poll ticks render. The upload archive is built in-test with Python's ``tarfile`` from **25 small ``.md`` files** (``e2e-prog.tar.gz`` → source ``e2e-prog``); the sync subjects are the uploaded row itself (the new leg) and a host temp dir (``sync-corpus/``, 25 small ``.md`` files under ``notes/``) registered as a ``kind=local`` row — the ``test_sync_button.py`` / ``test_local_directory_sources.py`` fixture styles. Per-module app env (the conftest pattern): ``BOR_UPLOAD_DIR`` scratch, ``BOR_GIT_SOURCES`` forced empty (the sync sources are this suite's own local rows), ``BOR_LLM_BASE_URL`` the slow proxy. Contract under test: * **toast → navigate away → the sync does the scan (A1/A2 + phase 90)**: on ``/git-sources.html`` the "Successfully uploaded — " toast (``.toast.is-visible``, ``role="status"``) fires at the 202; navigating to ``/sources.html`` shows **zero indexed documents** (the upload unpacked + registered only) and the sync button settled idle with no error UI; clicking **Sync sources** then imports the uploaded row with the LIVE "Syncing… (n/m)" label (both layers) and lands the counts ("N added", the catalog refreshes); * **upload processing (phase 90 A2)**: the upload button shows the BARE "Processing…" for the whole background run (no file, no "(n/m)", no title — proven across a held first status GET); every running tick the recorder catches carries a null ``current_file`` and 0/0 counts; the terminal status is ``success`` with the no-count ``{"message": "uploaded"}`` detail and null/0/0 progress; the result line points at the Sync button; the KB stays empty; * **sync live file (A4)**: a multi-file local source; clicking **Sync sources** on ``/sources.html`` shows "Syncing…" (bare, the pre-64 click state) then "Syncing… (n/m)" (both layers), then the pre-64 success settle — "Synced HH:MM" + the counts result line — preserved, plus the file in the label; * **reload re-attach (A1 + A2, phase 90)**: starting an upload and reloading ``/git-sources.html`` (the sub-second run may still be in flight — the boot re-attach enters the bare Processing state — or has settled — the boot re-renders the result line, the NAMELESS variant: the safe name was page-local) leaves the page with no error banner, no toast, and NO second upload (the status endpoint's single run is still the one from before the reload — pinned on its ``started_at``); the list shows exactly one row for the archive (in-place identity preserved). Test → story mapping (Playwright Mapping Rule): 1. ``test_upload_toast_then_navigate_away`` 2. ``test_upload_progress_is_bare`` 3. ``test_sync_live_file_label`` 4. ``test_upload_reattach_after_reload`` """ from __future__ import annotations import io import os import re import subprocess import sys import tarfile import threading import time from collections.abc import Iterator from datetime import datetime from pathlib import Path from typing import Any import httpx import pytest from playwright.sync_api import Page, expect from sqlalchemy import text from app.db import SessionLocal from app.models import GitSource 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 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_SYNCUP", "8133")) APP_URL = f"http://127.0.0.1:{APP_PORT}" GIT_SOURCES_URL = "/git-sources.html" SOURCES_URL = "/sources.html" #: The slow-LLM proxy's port (the conftest's mock LLM stays on MOCK_PORT). SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT", "8902")) SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}" #: Per-LLM-request delay on the proxy — the SYNC's duration becomes #: deterministic: an N-file sync issues N + 3 LLM requests (the #: check_models embed + chat probe, one embed per file, the #: change-gated overview chat), so a 25-file sync takes ≈ 28 × 0.15 s #: ≈ 4.2 s — long enough to outlive the UI's 2 s status poll (see the #: module docstring's timing-fixture note). The upload run is #: unaffected — phase 90 removed its LLM calls. SLOW_DELAY_S = "0.15" #: The uploaded archive: 25 small docs under ``docs/``. UPLOAD_NAME = "e2e-prog" UPLOAD_ARCHIVE = f"{UPLOAD_NAME}.tar.gz" N_FILES = 25 UPLOAD_FILES: dict[str, str] = { f"docs/{i:02d}.md": f"# Doc {i:02d}\n\nDeterministic upload content {i:02d}.\n" for i in range(N_FILES) } #: The local sync source (host temp dir — the app runs on the same #: machine): 25 small docs under ``notes/`` (the current_file's #: source/relative/path shape has a directory level). SYNC_SOURCE_DIR = "sync-corpus" SYNC_FILES: dict[str, str] = { f"notes/{i:02d}.md": f"# Sync doc {i:02d}\n\nDeterministic sync content {i:02d}.\n" for i in range(N_FILES) } #: "Synced HH:MM" — the local-time last-result label (sources.js's #: fmtSyncTime), any hour/minute (test_sync_button.py's pattern). SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}") #: Generous settle budget: a 25-file sync against the slowed LLM is #: ≈4.2 s; the UI's 2 s poll settles at most one tick after the #: terminal state lands. SETTLE_TIMEOUT_MS = 45_000 # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _build_targz(path: Path, files: dict[str, str]) -> Path: """A deterministic ``.tar.gz`` (mtime 0) over the given files.""" with tarfile.open(path, "w:gz") as tf: for rel, content in files.items(): data = content.encode("utf-8") info = tarfile.TarInfo(rel) info.size = len(data) info.mtime = 0 tf.addfile(info, io.BytesIO(data)) return path @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) — this suite's timing fixture for the SYNC legs: the live-file contract needs the sync to outlive the UI's 2 s poll (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 upload_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: """The app's ``BOR_UPLOAD_DIR`` for this suite — a scratch dir the host-side assertions inspect (the app server runs on the same machine).""" return tmp_path_factory.mktemp("bor_uploads") / "uploads" @pytest.fixture(scope="module") def upload_archive(tmp_path_factory: pytest.TempPathFactory) -> Path: """The 25-file upload archive (``e2e-prog.tar.gz``).""" root = tmp_path_factory.mktemp("bor_archive") return _build_targz(root / UPLOAD_ARCHIVE, UPLOAD_FILES) @pytest.fixture(scope="module") def sync_local_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: """The host temp dir the sync test registers as a ``kind=local`` source — ``sync-corpus/notes/NN.md`` (25 files).""" root = tmp_path_factory.mktemp("bor_sync_local") / SYNC_SOURCE_DIR for rel, content in SYNC_FILES.items(): path = root / rel path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") return root @pytest.fixture(scope="module") def app_server( mock_llm: int, slow_llm: int, upload_dir: Path, tmp_path_factory: pytest.TempPathFactory, ) -> Iterator[str]: """The real app under test — per-module env: the LLM base URL is the SLOW PROXY in front of the mock (the sync-leg timing fixture; the upload run makes no LLM calls — phase 90), uploads unpack into a scratch dir, and the env git list is forced empty (the sync sources are this suite's own ``kind=local`` rows, seeded per test).""" 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 f"{SLOW_URL}/v1" ) # Mock-calibrated threshold (conftest pattern) — no chat turn is # ever sent in this suite, but the app boots with the same env shape. env["BOR_RELEVANCE_THRESHOLD"] = "0.30" 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 env["BOR_GIT_SOURCES"] = "" env["BOR_UPLOAD_DIR"] = str(upload_dir) env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts")) 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 per test (the E2E isolation pattern): the upload's/sync's counts and every ``/api/docs`` assertion must be this test's own doing. The E2E suites share one Postgres, and a leftover git_sources row or document would corrupt the row-count and doc-list assertions (and a leftover row would be a SECOND sync source, skewing the per-file counts).""" with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources")) db.commit() def _wait_no_running_jobs(app_url: str) -> None: """No background job may leak across tests (the run states live in the app's memory, and a still-running scan would keep importing into the NEXT test's truncated KB): wait for both status endpoints to be non-running BEFORE the truncate. Own admin session (the endpoints are admin-only) — usually a no-op: the tests settle only after their run's terminal state.""" with httpx.Client(timeout=5.0) as client: client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD}) for path in ("/api/sync/status", "/api/git-sources/upload/status"): body: dict[str, Any] = {} deadline = time.monotonic() + 90 while time.monotonic() < deadline: r = client.get(f"{app_url}{path}") if r.status_code == 200: body = r.json() if body["state"] != "running": break time.sleep(0.2) assert body["state"] != "running", ( f"a background job was still running at test boundary: {path} {body}" ) @pytest.fixture(autouse=True) def _clean(app_url: str, db_ready: None) -> Iterator[None]: _wait_no_running_jobs(app_url) _truncate_all() yield _truncate_all() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _seed_local_source(path: str) -> None: """Register a ``kind=local`` source row directly (deterministic — the phase-49 page form is upload-only; a plain directory is an API/DB-only operation, the test_local_directory_sources.py note).""" with SessionLocal() as db: db.add(GitSource(url=path, kind="local", path=path)) db.commit() def _admin_git_sources_page(page: Page, app_url: str) -> None: """Real form login landing on the git sources page (admin settled: Sign out visible, the manager revealed by the page module).""" login(page, app_url, next=GIT_SOURCES_URL) expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) expect(page.locator("#git-sources-gate")).to_be_hidden() expect(page.locator("#git-sources-content")).to_be_visible() def _status(page: Page, app_url: str, path: str) -> dict[str, Any]: """One (cookie-authenticated) status-endpoint fetch.""" r = page.request.get(f"{app_url}{path}") assert r.status == 200, r.text return r.json() def _hold_first_status_fetch(page: Page, hold_s: float) -> None: """Intercept the upload-status GETs and hold ONLY THE FIRST one for ``hold_s`` seconds (later fetches pass straight through). Install AFTER the page's boot re-attach fetch, before the submit. The poll's first tick fires 2 s after the 202; holding its fetch keeps the button in the in-run state long enough to assert the bare "Processing…" label (no file, no "(n/m)") across the whole background run — phase 90's run settles in milliseconds, so without the hold the in-run window is only the 2 s pre-tick gap.""" state = {"held": False} def handle(route: Any) -> None: if not state["held"]: state["held"] = True time.sleep(hold_s) route.continue_() page.route("**/api/git-sources/upload/status", handle) class _TickRecorder: """The deterministic layer, concurrent with the UI assertions. A daemon thread that tight-polls (~100 ms cadence) the status endpoint with its OWN admin session (``httpx`` — the browser page drives itself in the meantime; the Playwright sync API is not thread-safe, so the thread never touches it), recording every tick from the first poll: the idle prelude, the running ticks, and the terminal body. The thread only READS the same endpoint the UI's 2 s poll reads — it starts no jobs and cannot skew the run. ``require_running`` (phase 90): the unpack-only upload run settles in milliseconds, so a fast machine can miss every running tick — with the flag off, a terminal is accepted when the run it names started at or after this recorder's start (the stale-terminal guard: the run state lives in the app's memory, so a previous test's terminal must not be mistaken for this run's).""" def __init__(self, app_url: str, path: str, require_running: bool = True) -> None: self._url = f"{app_url}{path}" self._login_url = f"{app_url}/api/login" self._require_running = require_running self._t0 = time.time() self._ticks: list[dict[str, Any]] = [] self._terminal: dict[str, Any] | None = None self._stop = threading.Event() self._thread: threading.Thread | None = None def _terminal_is_this_run(self, body: dict[str, Any], saw_running: bool) -> bool: """See the class docstring — a terminal is accepted when the run it names is THIS recorder's: a running tick was observed, or (``require_running=False``) its ``started_at`` is no earlier than the recorder's start.""" if saw_running: return True if self._require_running: return False started = body.get("started_at") if not started: return False try: started_at = datetime.fromisoformat(str(started)).timestamp() except ValueError: return False return started_at >= self._t0 - 2.0 # tolerance for the pre-submit gap def start(self) -> None: def run() -> None: with httpx.Client(timeout=5.0) as client: # Its own admin session — the recorder's reads must not # depend on (or disturb) the browser context's cookie. client.post(self._login_url, json={"password": ADMIN_PASSWORD}) saw_running = False while not self._stop.is_set(): try: r = client.get(self._url) if r.status_code == 200: body = r.json() self._ticks.append(body) if body["state"] == "running": saw_running = True elif ( body["state"] in ("success", "failed") and self._terminal_is_this_run(body, saw_running) ): self._terminal = body return except Exception: # noqa: BLE001 — blip: retry next tick pass self._stop.wait(0.1) self._thread = threading.Thread(target=run, daemon=True) self._thread.start() def stop(self, timeout_s: float = 60.0) -> dict[str, Any]: """Join until a terminal tick is recorded (or fail the test).""" deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: if self._terminal is not None: break time.sleep(0.05) self._stop.set() if self._thread is not None: self._thread.join(timeout=5) assert self._terminal is not None, ( "the recorder saw no terminal state " f"(last ticks: {self._ticks[-3:] if self._ticks else 'none'})" ) return self._terminal @property def running_ticks(self) -> list[dict[str, Any]]: return [t for t in self._ticks if t["state"] == "running"] def _assert_live_file_ticks( ticks: list[dict[str, Any]], source: str, n_files: int ) -> None: """A4 against the recorded running ticks (the deterministic layer): * the file-less ticks (model probe — before any file is indexed) come FIRST (the label is the bare prefix then); * SOME tick reports a non-null ``current_file`` in the ``source/relative/path`` shape; * ``files_total`` is the full pre-walk count from the first file tick, and ``files_done`` advances monotonically to it. """ with_file = [t for t in ticks if t["current_file"]] assert with_file, f"no running tick carried a current_file: {ticks[:6]}" first_with = with_file[0] assert first_with["current_file"].startswith(f"{source}/"), first_with assert re.fullmatch( rf"{re.escape(source)}/.+\.(md|markdown)", first_with["current_file"] ), first_with assert first_with["files_total"] == n_files, first_with assert 1 <= first_with["files_done"] <= n_files, first_with dones = [t["files_done"] for t in with_file] assert dones == sorted(dones), f"files_done not monotonic: {dones}" # The last file tick is (n-1, n): the hook fires before the final # file's index, so the final count itself can land in the terminal # body instead of a recorded tick (100 ms cadence vs ≈160 ms file). assert max(dones) >= n_files - 1, f"files_done never advanced: {dones}" pre = [t for t in ticks if t["current_file"] is None] assert pre, f"no file-less running tick (the probe phase): {ticks[:6]}" assert ticks.index(pre[0]) < ticks.index(first_with), ( "a file tick preceded the file-less probe ticks" ) # --------------------------------------------------------------------------- # 1. Toast on 202 → navigate away → nothing indexed → the sync that # follows shows its live file label and lands the counts (A1/A2 + # the phase-90 leg) # --------------------------------------------------------------------------- def test_upload_toast_then_navigate_away( page: Page, app_url: str, db_ready: None, upload_archive: Path ) -> None: """On ``/git-sources.html``: pick the multi-file archive, submit → the "Successfully uploaded — " toast fires at the 202 (A2); immediately navigate to ``/sources.html`` → **zero indexed documents** (phase 90 A1: the upload unpacked + registered only) and the sync button settled idle with no error UI; then click **Sync sources** (the new leg, phase 90) → the LIVE "Syncing… (n/m)" label while the sync imports the uploaded row (both layers), then the success settle with the counts and the catalog refresh.""" page.set_default_timeout(30_000) _admin_git_sources_page(page, app_url) # (A previous test's terminal run may re-render its result line at # boot — the re-attach contract; the submit below clears it.) page.set_input_files("#archive-upload-file", str(upload_archive)) page.click("#archive-upload-btn") # The 202 moment (A2): a single .toast node, visible, role=status, # naming the safe source name… toast = page.locator(".toast") expect(toast).to_have_count(1, timeout=SETTLE_TIMEOUT_MS) expect(toast).to_have_class(re.compile(r"\bis-visible\b")) assert toast.get_attribute("role") == "status" expect(toast).to_have_text(f"Successfully uploaded — {UPLOAD_NAME}") # Navigate away immediately (A1: the run no longer dies with the # page). page.goto(app_url + SOURCES_URL) # Phase 90 A1: the upload indexed NOTHING — the catalog is empty… expect(page.locator("#docs-tbody tr")).to_have_count(0) # …and the sync button settles idle with no error UI (the # sub-second upload run is over by the time this page's 2 s poll # first ticks; the upload's counts never render here — A3). btn = page.locator("#sync-btn") expect(btn).to_be_visible(timeout=30_000) expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator("#sync-label")).to_have_text( "Sync sources", timeout=SETTLE_TIMEOUT_MS ) expect(btn).to_be_enabled() expect(btn).not_to_have_attribute("aria-busy") # The new leg (phase 90): the Sync button does the scan the upload # deferred — live file label at both layers, counts + catalog on # the settle. recorder = _TickRecorder(app_url, "/api/sync/status") recorder.start() btn.click() # The click's immediate state (A4 — the bare prefix until the # import's first file): disabled, aria-busy, spinning icon, no # error… expect(btn).to_be_disabled() expect(btn).to_have_attribute("aria-busy", "true") expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b")) expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-error-banner")).to_be_hidden() # UI layer: the label gains the live file at the page's 2 s poll # tick ("Syncing… (n/m)"). expect(page.locator("#sync-label")).to_have_text( re.compile(rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"), timeout=SETTLE_TIMEOUT_MS, ) # Deterministic layer: the recorder's full tick series — file-less # model-check ticks first, then the per-file ticks (full # denominator, advancing counts). terminal = recorder.stop() assert terminal["state"] == "success", terminal _assert_live_file_ticks(recorder.running_ticks, UPLOAD_NAME, N_FILES) assert terminal["current_file"] is None assert terminal["files_done"] == N_FILES assert terminal["files_total"] == N_FILES assert terminal["detail"]["added"] == N_FILES # The success settle: "Synced HH:MM" + the counts result line, the # button re-enabled, no error — and the catalog lists the # imported docs (the upload's row, scanned by the sync). expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000) expect(btn).to_be_enabled() expect(btn).not_to_have_attribute("aria-busy") expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added") expect(page.locator("#docs-tbody tr")).to_have_count(N_FILES, timeout=30_000) expect(page.locator("#docs-tbody tr", has_text="docs/00.md")).to_have_count(1) expect(page.locator("#docs-tbody tr", has_text="docs/24.md")).to_have_count(1) expect(page.locator("#docs-tbody tr", has_text=UPLOAD_NAME)).to_have_count(N_FILES) # --------------------------------------------------------------------------- # 2. Upload processing: BARE "Processing…" for the whole run, the # no-count terminal shape, nothing indexed (phase 90 A2) # --------------------------------------------------------------------------- def test_upload_progress_is_bare( page: Page, app_url: str, db_ready: None, upload_archive: Path ) -> None: """Phase 90 A2: the upload's background run has NO file-level progress. The button shows the BARE "Processing…" for the whole run (no file, no "(n/m)", no title — proven across a held first status GET); every running tick the recorder catches carries a null ``current_file`` and 0/0 counts; the terminal status is ``success`` with the no-count ``{"message": "uploaded"}`` detail and null/0/0 progress; the settled result line points at the Sync button; and the KB stays empty (no scan).""" page.set_default_timeout(30_000) _admin_git_sources_page(page, app_url) # require_running=False — phase 90: the unpack-only run settles in # milliseconds, so a fast machine can miss every running tick; the # stale-terminal guard (started_at vs. the recorder's start) keeps # a previous test's terminal from being mistaken for this run's. recorder = _TickRecorder( app_url, "/api/git-sources/upload/status", require_running=False ) recorder.start() # Hold the FIRST status GET (installed after the boot re-attach # fetch, before the submit): the bare in-run label gets a window # wider than the 2 s pre-tick gap. _hold_first_status_fetch(page, hold_s=4.5) page.set_input_files("#archive-upload-file", str(upload_archive)) page.click("#archive-upload-btn") # A new attempt starts clean (the submit handler hides the result # line — any previous run's re-rendered line is gone by now). expect(page.locator("#archive-upload-result")).to_be_hidden() # The toast fires at the 202 — NOT after the result (the result # line is still down when the toast is up). toast = page.locator(".toast") expect(toast).to_have_count(1, timeout=SETTLE_TIMEOUT_MS) expect(toast).to_have_class(re.compile(r"\bis-visible\b")) expect(toast).to_have_text(f"Successfully uploaded — {UPLOAD_NAME}") expect(page.locator("#archive-upload-result")).to_be_hidden() # The button hands over to the run — the BARE "Processing…" (phase # 90 A2: no file, no counts, no title)… btn = page.locator("#archive-upload-btn") expect(btn).to_be_disabled() expect(btn).to_have_text("Processing…", timeout=5_000) expect(btn).to_have_attribute("title", "") # …and it STAYS bare across the whole background run: the first # status GET is held, so the settling tick is in flight — no file # and no "(n/m)" can have rendered. time.sleep(2.5) expect(btn).to_have_text("Processing…") # Deterministic layer: every running tick the recorder caught is # bare (null file, 0/0 counts — phase 90 A2). for t in recorder.running_ticks: assert t["current_file"] is None, t assert t["files_done"] == 0 and t["files_total"] == 0, t terminal = recorder.stop() assert terminal["state"] == "success", terminal assert terminal["detail"] == {"message": "uploaded"}, terminal assert terminal["current_file"] is None assert terminal["files_done"] == 0 assert terminal["files_total"] == 0 # Settle (the held GET released): the ready-for-sync result line, # the button restored ("Upload"), the input cleared, one row for # the archive — and the KB empty (no scan, phase 90 A1). result = page.locator("#archive-upload-result") expect(result).to_have_text( f"Uploaded {UPLOAD_NAME} — press Sync sources to import it.", timeout=30_000, ) expect(btn).to_be_enabled() expect(btn).to_have_text("Upload") expect(page.locator("#archive-upload-file")).to_have_value("") expect(page.locator("#git-sources-tbody tr", has_text=UPLOAD_NAME)).to_have_count(1) r = page.request.get(f"{app_url}/api/docs") assert r.status == 200, r.text assert r.json()["documents"] == [] # --------------------------------------------------------------------------- # 3. Sync live file label: both layers, then the preserved pre-64 # success settle (A4) # --------------------------------------------------------------------------- def test_sync_live_file_label( page: Page, app_url: str, db_ready: None, sync_local_dir: Path ) -> None: """A multi-file local source (the ``test_sync_button.py`` fixture style): on ``/sources.html`` click **Sync sources** → the label shows "Syncing…" (bare, the pre-64 click state) then "Syncing… (n/m)" while running (endpoint layer: ``current_file`` non-null at running ticks; UI layer: the label poll), then the success settle with the counts result line — the pre-phase-64 sync UX is preserved, plus the file.""" page.set_default_timeout(30_000) _seed_local_source(str(sync_local_dir)) login(page, app_url) # lands on /sources.html (the button's home) btn = page.locator("#sync-btn") expect(btn).to_be_visible(timeout=30_000) expect(page.locator("#sync-label")).to_have_text("Sync sources") recorder = _TickRecorder(app_url, "/api/sync/status") recorder.start() btn.click() # The click's immediate state is the pre-64 one (A4 — the bare # prefix until the import's first file): disabled, aria-busy, # spinning icon, no error… expect(btn).to_be_disabled() expect(btn).to_have_attribute("aria-busy", "true") expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b")) expect(page.locator("#sync-label")).to_have_text("Syncing…") expect(page.locator("#sync-error-banner")).to_be_hidden() # UI layer: the label gains the live file at the page's 2 s poll # tick ("Syncing… (n/m)"). expect(page.locator("#sync-label")).to_have_text( re.compile(rf"Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)"), timeout=SETTLE_TIMEOUT_MS, ) # Deterministic layer: the recorder's full tick series — file-less # model-check ticks first, then the per-file ticks (full # denominator, advancing counts). terminal = recorder.stop() assert terminal["state"] == "success", terminal _assert_live_file_ticks(recorder.running_ticks, SYNC_SOURCE_DIR, N_FILES) assert terminal["current_file"] is None assert terminal["files_done"] == N_FILES assert terminal["files_total"] == N_FILES assert terminal["detail"]["added"] == N_FILES # The pre-64 success settle, preserved: "Synced HH:MM" + the counts # result line, button re-enabled, no error — and the catalog lists # the imported docs. expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000) expect(btn).to_be_enabled() expect(btn).not_to_have_attribute("aria-busy") expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added") expect(page.locator("#docs-tbody tr")).to_have_count(N_FILES, timeout=30_000) expect(page.locator("#docs-tbody tr", has_text="notes/00.md")).to_have_count(1) expect(page.locator("#docs-tbody tr", has_text=SYNC_SOURCE_DIR)).to_have_count(N_FILES) # --------------------------------------------------------------------------- # 4. Reload → re-attach: no dead-end, no error, no toast, no second # upload (A1 + A2, phase 90) # --------------------------------------------------------------------------- def test_upload_reattach_after_reload( page: Page, app_url: str, db_ready: None, upload_archive: Path ) -> None: """Start the upload and, while the sub-second run is in flight (or has just settled), reload ``/git-sources.html`` → the page never dead-ends: the boot re-attach either enters the bare Processing state + poll (run still in flight) or re-renders the settled result line (the NAMELESS variant — the safe name was page-local), with no error banner, no toast, and NO second upload (the status endpoint's single run is still the one from before the reload — pinned on its ``started_at``); the list shows exactly one row for the archive (in-place identity preserved).""" page.set_default_timeout(30_000) _admin_git_sources_page(page, app_url) page.set_input_files("#archive-upload-file", str(upload_archive)) page.click("#archive-upload-btn") # The 202 toast (the run is in flight)… toast = page.locator(".toast") expect(toast).to_have_count(1, timeout=SETTLE_TIMEOUT_MS) expect(toast).to_have_class(re.compile(r"\bis-visible\b")) # …and the run's identity: the status's started_at (a second run # would reset it — the single-run claim is pinned on it). started_at = _status(page, app_url, "/api/git-sources/upload/status")["started_at"] # Reload — the run may be in flight (the boot re-attach enters the # bare Processing state + poll) or settled (the boot re-renders # the result line); either way the page must not dead-end. page.reload() expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) expect(page.locator("#git-sources-content")).to_be_visible() # No error banner, no toast at boot (A2 — the toast fired at the # 202, on the previous document life)… expect(page.locator("#archive-upload-error")).to_be_hidden() expect(page.locator(".toast")).to_have_count(0) # …and the settled result line: the nameless variant (the safe # name was page-local — lastUploadName is null after a reload). expect(page.locator("#archive-upload-result")).to_have_text( "Uploaded — press Sync sources to import it.", timeout=SETTLE_TIMEOUT_MS, ) expect(page.locator("#archive-upload-btn")).to_be_enabled() expect(page.locator("#archive-upload-btn")).to_have_text("Upload") # …and the list shows exactly one row for the archive. expect(page.locator("#git-sources-tbody tr")).to_have_count(1) row = page.locator("#git-sources-tbody tr", has_text=UPLOAD_NAME) expect(row).to_have_count(1) expect(row.locator("span.git-source-kind")).to_have_text("Local") # No second upload: the terminal status is the SAME run — its # started_at is the one from before the reload. terminal = _status(page, app_url, "/api/git-sources/upload/status") assert terminal["state"] == "success", terminal assert str(terminal["started_at"]) == str(started_at), ( f"the run's identity changed (a second upload ran): {terminal['started_at']}" ) assert terminal["current_file"] is None assert terminal["detail"] == {"message": "uploaded"}