feat(sources): real-time file progress for sync and upload — background upload with success toast

This commit is contained in:
2026-09-01 23:51:43 -04:00
parent cddc84c7db
commit 4677d86f49
103 changed files with 5914 additions and 456 deletions
+93
View File
@@ -0,0 +1,93 @@
"""Phase 64 E2E helper — a delay-injecting reverse proxy in front of the
mock LLM.
The mock LLM (``mock_llm.py``) answers instantly: a real archive scan of
dozens of files finishes in well under a second and never outlives the
UI's 2 s status poll — the phase-64 live file labels ("Processing…
<file>", "Importing <file>", "Syncing… <file>"), the at-202 toast →
navigate-away contract, and the mid-scan reload re-attach would all be
races against the mock. This proxy sits between the app under test and
the mock LLM and sleeps ``SLOW_LLM_DELAY_S`` seconds (default 0.15)
before forwarding each request, so a scan's duration is deterministic
(≈ the run's number of LLM requests × the delay — for an N-file
archive/sync that is N + 3: the ``check_models`` embed + chat probe,
one embed per file, and the change-gated overview chat). Both the
tests' ~100 ms status polling (the deterministic layer) and the UI's
2 s poll (the UI layer) then observe the running state, the current
file, and the counts reliably.
Everything else is byte-transparent: method, path, query, headers, and
body are forwarded verbatim; the upstream response's status and body
come back as-is (``content-encoding`` / ``content-length`` are dropped
— httpx has already decoded the body and starlette recomputes the
length). The mock's responses are all finite (its SSE streams end with
``[DONE]``), so the proxy reads each body to completion before
answering.
Run it the conftest way (a suite's module ``app_server`` fixture spawns
it as a subprocess):
uv run python -m uvicorn tests.e2e.slow_llm:app --port 8902
with ``E2E_MOCK_PORT`` (upstream, default 8901) and ``SLOW_LLM_DELAY_S``
(delay, default 0.15) in its environment.
"""
from __future__ import annotations
import asyncio
import os
import httpx
from fastapi import FastAPI, Request, Response
#: The mock LLM this proxy forwards to (the conftest's MOCK_PORT).
MOCK_PORT = int(os.environ.get("E2E_MOCK_PORT", "8901"))
UPSTREAM = f"http://127.0.0.1:{MOCK_PORT}"
#: Per-request delay in seconds — each suite's proxy fixture picks its
#: own (passed through the subprocess env).
DELAY_S = float(os.environ.get("SLOW_LLM_DELAY_S", "0.15"))
app = FastAPI()
_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient(base_url=UPSTREAM, timeout=60.0)
return _client
@app.on_event("shutdown")
async def _close_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy(path: str, request: Request) -> Response:
"""Sleep ``DELAY_S``, then forward the request to the mock LLM."""
await asyncio.sleep(DELAY_S)
body = await request.body()
headers = {
k: v for k, v in request.headers.items() if k.lower() not in ("host", "content-length")
}
upstream = await _get_client().request(
request.method,
f"/{path}",
content=body,
headers=headers,
params=dict(request.query_params),
)
resp_headers = {
k: v
for k, v in upstream.headers.items()
if k.lower()
not in ("content-length", "content-encoding", "transfer-encoding", "connection")
}
return Response(
content=upstream.content, status_code=upstream.status_code, headers=resp_headers
)
+169 -39
View File
@@ -11,10 +11,26 @@ form is gone, replaced by this form): an uploaded ``.tar``/``.tar.gz``/
``.tgz``/``.zip`` is safely unpacked under ``BOR_UPLOAD_DIR/<name>/``
(name = filename minus the archive suffix), the ``git_sources`` row is
upserted (``kind='local'``, no duplicates), and the source is **scanned
synchronously in the request** (single-source ``import_sources`` with
``prune=True`` + the change-gated overview refresh) — the real pipeline,
against the deterministic mock LLM (no real models, no network beyond
the app itself).
in a background task** (phase 64, task 03 — owner-locked A1: the POST
answers **202 the moment the archive is safely on disk** — the
"Successfully uploaded — <source>" toast fires then and the user may
navigate away — while unpack → swap → row upsert → model check →
single-source ``import_sources`` with ``prune=True`` + the change-gated
overview refresh run server-side behind
``GET /api/git-sources/upload/status``, the phase-32 ``SyncStatus``
pattern with the phase-64 ``current_file``/counts). The result line and
the row land from the status ``success`` (same ``UploadOut`` counts,
uncompressed in shape) — the real pipeline, against the deterministic
mock LLM (no real models, no network beyond the app itself).
**Timing fixture (phase 64):** the mock LLM answers instantly, so this
module's app boots behind ``tests/e2e/slow_llm.py`` — a delay-injecting
reverse proxy in front of it (``SLOW_DELAY_S`` per request). A 2-file
scan is 5 LLM requests ≈ 5 × 0.6 s ≈ 3 s: long enough to outlive the
UI's 2 s status poll, so the button's literal
"Uploading… → Processing… → restored" lifecycle is observable
(the "Processing…" tick even carries the live file, A4) instead of
racing the mock.
The archives are **built in-test** with Python's ``tarfile`` over
``tmp_path`` fixture files carrying markdown sentinels (``ALPHA-…`` /
@@ -38,24 +54,33 @@ Contract under test:
the upload form is in its place with the labeled file input (accept
= the four archive extensions), the "Upload & scan" button, and the
hint explains unpack/scan + in-place replace;
* **upload → scan → list** (§7.4 never-stale): the button shows
"Uploading…" while the POST is in flight (the request is held in the
browser via ``page.route`` so the in-flight state is deterministic),
then restores; the result line shows the added count; the list gains
exactly one row for ``e2e-upload`` with the **Local** badge;
``GET /api/docs`` lists both sentinel files under source
``e2e-upload``; the RAG catalog (``/sources.html``) shows them;
* **upload → 202 + toast → background scan → list** (§7.4 never-stale,
phase-64 A1/A2): the button shows "Uploading…" while the POST is in
flight (the request is held in the browser via ``page.route`` so the
in-flight state is deterministic); at the 202 the "Successfully
uploaded — <source>" toast fires (``.toast.is-visible``,
``role="status"``) WHILE the scan is still running, and the button
hands over to the scan — "Processing…" (the live-file tick carries
the current file, A4) — then restores when the status ``success``
lands: the result line shows the added count; the list gains exactly
one row for ``e2e-upload`` with the **Local** badge; ``GET
/api/docs`` lists both sentinel files under source ``e2e-upload``;
the RAG catalog (``/sources.html``) shows them;
* **re-upload, same filename** → in-place replace: the result line
shows the prune, the list still has exactly ONE ``e2e-upload`` row
(no duplicate), the KB shows the changed ``alpha`` + the new
shows the prune, the SECOND RUN'S STATUS ``detail`` carries the
prune/refresh counts, the list still has exactly ONE ``e2e-upload``
row (no duplicate), the KB shows the changed ``alpha`` + the new
``gamma`` and NOT the dropped ``beta``, and the on-disk folder holds
only the new archive's files;
* **bad file** → inline 422 (role=alert) naming the accepted formats,
button restored, the file selection kept, the list unchanged, and a
subsequent good upload still works (the form is not wedged);
* **bad file** → inline 422 (role=alert) naming the accepted formats
(UNCHANGED — the name/format/cap gates are inline, pre-202, exactly
as before), button restored, the file selection kept, the list
unchanged, and a subsequent good upload still works (the form is not
wedged);
* **anonymous** → the sign-in gate (``#git-sources-gate``) shows, the
manager (and thus the upload form) stays hidden, and
``POST /api/git-sources/upload`` is 403.
``POST /api/git-sources/upload`` is 403 — as is the phase-64
``GET /api/git-sources/upload/status`` (same wall).
Test → story mapping (Playwright Mapping Rule):
1. ``test_form_swapped``
@@ -86,6 +111,7 @@ from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
@@ -94,6 +120,17 @@ from e2e.conftest import (
REPO = Path(__file__).resolve().parents[2]
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).
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 — a 2-file scan is 5 LLM requests
#: (the check_models embed + chat probe, one embed per file, the
#: change-gated overview chat) ≈ 5 × 0.6 s ≈ 3 s: the scan outlives the
#: UI's 2 s status poll, so the button's "Uploading… → Processing… →
#: restored" lifecycle (with the live-file tick, A4) is observable.
SLOW_DELAY_S = "0.6"
GIT_SOURCES_URL = "/git-sources.html"
SOURCES_URL = "/sources.html"
@@ -184,17 +221,47 @@ def tarball_v2(tmp_path_factory: pytest.TempPathFactory) -> Path:
return _build_targz(root / f"{SOURCE_NAME}.tar.gz", V2_FILES)
@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: the phase-64
button lifecycle ("Uploading… → Processing… → restored") needs the
2-file scan to outlive the UI's 2 s status 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 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: uploads unpack into a
scratch dir and the env git list is forced empty (the dev ``.env``'s
``BOR_GIT_SOURCES`` must not render as env rows on the initially
empty table). No sync is triggered here — the upload's own scan is
the pipeline under test."""
"""The real app under test — per-module env: the LLM base URL is the
SLOW PROXY in front of the mock (the timing fixture), uploads unpack
into a scratch dir and the env git list is forced empty (the dev
``.env``'s ``BOR_GIT_SOURCES`` must not render as env rows on the
initially empty table). No sync is triggered here — the upload's own
scan is the pipeline under test."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
@@ -202,7 +269,7 @@ def app_server(
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
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.
@@ -286,9 +353,10 @@ def _docs(page: Page, app_url: str) -> list[tuple[str, str]]:
def _upload_via_page(page: Page, archive: Path) -> str:
"""Pick the archive, submit the form, and wait for the result line
(the 200 path) — returns its text. The failing path is asserted
explicitly by the bad-file test, so any non-result outcome here is
a test error."""
(the phase-64 202 path: toast at the 202, then the button's status
polling renders the line from the run's ``success``) — returns its
text. The failing path is asserted explicitly by the bad-file test,
so any non-result outcome here is a test error."""
page.set_input_files("#archive-upload-file", str(archive))
page.click("#archive-upload-btn")
result = page.locator("#archive-upload-result")
@@ -298,6 +366,24 @@ def _upload_via_page(page: Page, archive: Path) -> str:
return text
def _wait_upload_running(page: Page, app_url: str, timeout_s: float = 15.0) -> dict[str, Any]:
"""Poll (cookie-authenticated) the upload status endpoint until the
run is ``running`` — the phase-64 single source of truth for the
background scan (A1)."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = page.request.get(f"{app_url}/api/git-sources/upload/status")
assert r.status == 200, r.text
body = r.json()
if body["state"] == "running":
return body
if body["state"] in ("success", "failed"):
raise AssertionError(f"the scan settled too fast to observe: {body}")
time.sleep(0.1)
raise AssertionError(f"the scan never entered running: {body}")
def _hold_upload_request(page: Page, hold_s: float) -> None:
"""Intercept the upload POST and hold the REQUEST in the browser for
``hold_s`` seconds before letting it reach the server. While it is
@@ -365,11 +451,15 @@ def test_upload_scans_and_lists(
page: Page, app_url: str, db_ready: None, tarball_v1: Path, upload_dir: Path
) -> None:
"""One real upload through the page: while the POST is in flight the
button is disabled and reads "Uploading…"; on the 200 it restores,
the result line shows the added count (2), the file input clears,
the list gains exactly ONE row for ``e2e-upload`` with the Local
badge, ``/api/docs`` lists both sentinel files under the source, and
the RAG catalog shows them where the admin expects them."""
button is disabled and reads "Uploading…"; at the 202 the
"Successfully uploaded — <source>" toast fires (A2) WHILE the
background scan is still running and the button hands over to it —
"Processing…" (the 2 s poll tick carries the live file, A4); when
the status ``success`` lands the button restores, the result line
shows the added count (2), the file input clears, the list gains
exactly ONE row for ``e2e-upload`` with the Local badge,
``/api/docs`` lists both sentinel files under the source, and the
RAG catalog shows them where the admin expects them."""
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
@@ -378,7 +468,7 @@ def test_upload_scans_and_lists(
result = page.locator("#archive-upload-result")
# Hold the upload request in the browser: the in-flight state below
# cannot race the (fast) mock-LLM scan while it is held.
# cannot race the receive while it is held.
_hold_upload_request(page, hold_s=0.8)
page.set_input_files("#archive-upload-file", str(tarball_v1))
btn.click()
@@ -388,12 +478,35 @@ def test_upload_scans_and_lists(
expect(btn).to_have_text("Uploading…")
expect(result).to_be_hidden()
# The request goes out, the server unpacks + scans (mock LLM) and
# answers 200 → the result line shows the added count.
# The request goes out; the server stores the archive and answers
# 202 the moment it is safely on disk (A1) → the toast fires NOW
# (A2) — while the scan is still running — and the button hands
# over to the scan (bare "Processing…" — A4: no file yet during the
# unpack phase).
toast = page.locator(".toast")
expect(toast).to_have_count(1, timeout=UPLOAD_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 — {SOURCE_NAME}")
expect(result).to_be_hidden()
expect(btn).to_be_disabled()
expect(btn).to_have_text("Processing…", timeout=5_000)
# The scan is running server-side (the status endpoint is the
# single source of truth, A1) — the run the UI's poll tracks.
_wait_upload_running(page, app_url)
# …and the button's 2 s poll tick renders the live file label
# ("Processing… <file> (n/m)", A4).
expect(btn).to_have_text(
re.compile(rf"Processing… {re.escape(SOURCE_NAME)}/.+\.md"),
timeout=UPLOAD_TIMEOUT_MS,
)
# The status success lands → the result line (the same UploadOut
# counts) + the never-stale restore (input cleared).
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
expect(result).to_have_text("2 added")
# Never stale: the button restored on success and the input cleared.
expect(btn).to_be_enabled()
expect(btn).to_have_text("Upload & scan")
expect(page.locator("#archive-upload-file")).to_have_value("")
@@ -434,7 +547,9 @@ def test_reupload_replaces_in_place(
upload_dir: Path,
) -> None:
"""v1 then v2 under the SAME filename (``e2e-upload.tar.gz``): the
result line shows the prune, the list still has exactly ONE
result line shows the prune, the SECOND RUN'S STATUS ``detail``
carries the prune/refresh counts (phase 64 — the line is rendered
from the status success), the list still has exactly ONE
``e2e-upload`` row (the row count for that source is invariant — no
duplicate), the KB shows the changed ``alpha`` + the new ``gamma``
and NOT the dropped ``beta``, and the on-disk folder holds only the
@@ -442,7 +557,7 @@ def test_reupload_replaces_in_place(
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
# Baseline: v1 through the page (200 → "2 added", one row).
# Baseline: v1 through the page (202 → "2 added", one row).
assert _upload_via_page(page, tarball_v1) == "2 added"
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
@@ -452,6 +567,19 @@ def test_reupload_replaces_in_place(
result = page.locator("#archive-upload-result")
expect(result).to_have_text(re.compile(r"\d+ pruned"))
# The SECOND RUN's status ``detail`` shows the prune/refresh counts
# (phase 64: the result line is rendered from this success).
r = page.request.get(f"{app_url}/api/git-sources/upload/status")
assert r.status == 200, r.text
status = r.json()
assert status["state"] == "success", status
detail = status["detail"]
assert detail["source"] == SOURCE_NAME
assert detail["files"] == 2
assert detail["added"] == 1 # gamma — new in v2
assert detail["updated"] == 1 # alpha — changed in v2
assert detail["pruned"] == 1 # beta — dropped in v2
# No duplicate: exactly ONE row for that source (and one row total).
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
@@ -552,6 +680,8 @@ def test_anonymous_gate(page: Page, app_url: str, db_ready: None) -> None:
# The upload route 403s anonymous callers (require_admin runs before
# the multipart body is parsed — the body is a stand-in, the
# test_local_directory_sources.py pattern for this route).
# test_local_directory_sources.py pattern for this route)…
r = page.request.post(f"{app_url}/api/git-sources/upload", data={"file": ""})
assert r.status == 403
# …and so does the phase-64 upload STATUS endpoint (same wall).
assert page.request.get(f"{app_url}/api/git-sources/upload/status").status == 403
+758
View File
@@ -0,0 +1,758 @@
"""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):
both long-running KB jobs report **which file is being processed right
now** — not just "Syncing…"/"Uploading…" — and the archive upload is
fully **backgrounded**: ``POST /api/git-sources/upload`` answers 202 the
moment the archive is on disk (the "Successfully uploaded — <file>"
toast fires — the user may navigate away), the unpack/scan continues
server-side behind ``GET /api/git-sources/upload/status`` (the phase-32
``SyncStatus`` pattern), and the RAG-page sync button
(``/sources.html``) animates with the upload's current file while that
scan runs.
**Timing fixture (the phase's fixture note):** the mock LLM indexes
fast — the in-progress state is real but brief (a 25-file scan against
it takes ≈0.4 s, well under the UI's 2 s status poll). This module's
app therefore boots behind ``tests/e2e/slow_llm.py`` — a delay-injecting
reverse proxy in front of the mock LLM (``SLOW_DELAY_S`` per request →
a 25-file scan is 28 LLM requests ≈ 4.2 s), so the scan 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 (unpack/row/
model probe — A4) preceding the first file tick;
* **UI** — polling the button labels for the ``Importing`` /
``Syncing…`` / ``Processing…`` prefix plus a file path (generous
timeout), which the pages' 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 subject is 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 row), ``BOR_LLM_BASE_URL`` the slow
proxy.
Contract under test:
* **toast → navigate away (A2 + A3)**: on ``/git-sources.html`` the
"Successfully uploaded — <source>" toast (``.toast.is-visible``,
``role="status"``) appears while the scan is still running;
navigating to ``/sources.html`` shows the sync button animating
(spinner + ``aria-busy``) with the ``Importing <file>`` label; on
completion the button settles to "Sync sources" (no error UI,
``#sync-result`` stays empty — the upload's counts never render
there, A3) and the catalog shows the uploaded documents (the
phase-63 listing, untouched);
* **upload progress (A4)**: during the scan the status endpoint
reports a non-null ``current_file`` (``source/relative/path`` shape,
full denominator, advancing counts) at running ticks, the upload
button shows "Processing… <file>" (bare "Processing…" during unpack)
before the result line lands, and the toast fired earlier in the run
— the result line itself comes from the status ``success``;
* **sync live file (A4)**: a multi-file local source; clicking
**Sync sources** on ``/sources.html`` shows "Syncing…" (bare, the
pre-64 click state) then "Syncing… <file> (n/m)" (both layers), 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)**: starting an upload and reloading
``/git-sources.html`` mid-scan leaves the button in the Processing
state (disabled) with no error banner and NO second upload (the
status endpoint's single run is still the one from before the
reload — pinned on its ``started_at``); it then settles with the
result line and the list shows exactly one row for the archive.
Test → story mapping (Playwright Mapping Rule):
1. ``test_upload_toast_then_navigate_away``
2. ``test_upload_progress_shows_current_file``
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 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,
APP_PORT,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
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 scan's duration becomes
#: deterministic: an N-file archive scan issues N + 3 LLM requests
#: (the check_models embed + chat probe, one embed per file, the
#: change-gated overview chat), so a 25-file upload 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).
SLOW_DELAY_S = "0.15"
#: The uploaded archive: 25 small docs under ``docs/`` (the phase's
#: fixture note — 20+ files so the scan outlives the 2 s poll).
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 scan 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: the live-file
contract needs the scan 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 timing fixture), uploads unpack
into a scratch dir, and the env git list is forced empty (the sync
sources are this suite's own ``kind=local`` row, 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 _wait_running_started_at(
page: Page, app_url: str, path: str, timeout_s: float = 15.0
) -> str:
"""Poll the status endpoint until the run is ``running``; return its
``started_at`` (the run's identity — a second run would reset it)."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
body = _status(page, app_url, path)
if body["state"] == "running":
assert body["started_at"] is not None
return str(body["started_at"])
if body["state"] in ("success", "failed"):
raise AssertionError(f"the run settled too fast to observe: {body}")
time.sleep(0.1)
raise AssertionError(f"the run never entered running: {body}")
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 (file-less
unpack/row/probe phase, then the per-file 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."""
def __init__(self, app_url: str, path: str) -> None:
self._url = f"{app_url}{path}"
self._login_url = f"{app_url}/api/login"
self._ticks: list[dict[str, Any]] = []
self._terminal: dict[str, Any] | None = None
self._stop = threading.Event()
self._thread: threading.Thread | None = None
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)
# The run status is in the app's memory and
# SURVIVES across this module's tests: a
# residual terminal state (a previous test's
# run) must not be mistaken for this test's
# own terminal — accept it only AFTER this
# run's "running" has been observed.
if body["state"] == "running":
saw_running = True
elif body["state"] in ("success", "failed") and 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 (unpack/row/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 unpack phase): {ticks[:6]}"
assert ticks.index(pre[0]) < ticks.index(first_with), (
"a file tick preceded the file-less unpack ticks"
)
# ---------------------------------------------------------------------------
# 1. Toast on 202 → navigate away → sync button animates with the
# upload's current file → settle + catalog refresh (A2 + A3)
# ---------------------------------------------------------------------------
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 — <source>" toast appears WHILE the scan
is still running; immediately navigate to ``/sources.html`` → the
sync button is present, animating (icon ``is-spinning``,
``aria-busy``) with the ``Importing`` label; wait for the settle →
button idle ("Sync sources"), no error UI, and the catalog table
shows the uploaded documents (the phase-63 listing, untouched)."""
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 task-05 re-attach contract; the submit below clears
# it, and that is the "clean start" asserted after the click.)
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}")
# …and the scan is still running — the result line is not up yet
# (the toast precedes the scan's completion, A2).
expect(page.locator("#archive-upload-result")).to_be_hidden()
started_at = _wait_running_started_at(page, app_url, "/api/git-sources/upload/status")
assert started_at is not None
# Navigate away immediately (A1: the scan no longer dies with the
# page).
page.goto(app_url + SOURCES_URL)
# The RAG-page sync button re-attaches to the in-flight upload scan
# (A3): present, animating (spinner + aria-busy), disabled, with
# the live "Importing <file>" label — no error UI on this page
# (the upload's failure UI lives on the Sources page, A3).
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
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(
re.compile(rf"Importing {re.escape(UPLOAD_NAME)}/"), timeout=SETTLE_TIMEOUT_MS
)
expect(page.locator("#sync-error-banner")).to_be_hidden()
# Settle: the button returns to idle, the sync-result line never
# rendered the upload's counts (A3), and the catalog refreshes with
# the uploaded documents — the phase-63 listing, untouched.
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")
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-result")).to_have_text("")
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 progress: live current file at BOTH layers; the toast fired
# earlier than the result (A4)
# ---------------------------------------------------------------------------
def test_upload_progress_shows_current_file(
page: Page, app_url: str, db_ready: None, upload_archive: Path
) -> None:
"""During the scan: the status endpoint reports a non-null
``current_file`` (``source/relative/path`` shape) at some running
tick; the upload button label shows "Processing…" with a file path
(UI layer) BEFORE the result line lands; the toast fired earlier in
the run (not after the result). The result line + the settle come
from the status ``success``."""
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
recorder = _TickRecorder(app_url, "/api/git-sources/upload/status")
recorder.start()
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 — earlier in the run, 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()
# UI layer: the button hands over to the scan — bare "Processing…"
# at the 202 (A4: no file yet during the unpack phase)…
btn = page.locator("#archive-upload-btn")
expect(btn).to_be_disabled()
expect(btn).to_have_text("Processing…", timeout=5_000)
# …then the live file label ("Processing… <file> (n/m)") at the
# page's next 2 s poll tick, still before the result line lands.
expect(btn).to_have_text(
re.compile(rf"Processing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"),
timeout=SETTLE_TIMEOUT_MS,
)
expect(page.locator("#archive-upload-result")).to_be_hidden()
# Deterministic layer: the recorder's full tick series (the same
# endpoint the UI's 2 s poll reads) — file-less unpack ticks first,
# then the per-file ticks with the full denominator.
terminal = recorder.stop()
assert terminal["state"] == "success", terminal
_assert_live_file_ticks(recorder.running_ticks, UPLOAD_NAME, N_FILES)
# Phase 64: current_file is null in terminal states (the final
# counts survive).
assert terminal["current_file"] is None
assert terminal["files_done"] == N_FILES
assert terminal["files_total"] == N_FILES
# The result line is rendered from the status success (the
# UploadOut-shaped detail, counts unchanged in shape).
assert terminal["detail"]["files"] == N_FILES
assert terminal["detail"]["added"] == N_FILES
assert terminal["detail"]["source"] == UPLOAD_NAME
# Settle: the result line lands, the button restores, the input
# cleared, and the list has exactly one row for the archive.
result = page.locator("#archive-upload-result")
expect(result).to_have_text(f"{N_FILES} added", timeout=30_000)
expect(btn).to_be_enabled()
expect(btn).to_have_text("Upload & scan")
expect(page.locator("#archive-upload-file")).to_have_value("")
expect(page.locator("#git-sources-tbody tr", has_text=UPLOAD_NAME)).to_have_count(1)
# ---------------------------------------------------------------------------
# 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…
<file> (n/m)" while running (endpoint layer: ``current_file``
non-null at running ticks; UI layer: the label poll), then the
success settle with the counts result line — the pre-phase-64 sync
UX is preserved, plus the file."""
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… <source/relative/path> (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 mid-scan → re-attach: no error, no second upload (A1 + A2)
# ---------------------------------------------------------------------------
def test_upload_reattach_after_reload(
page: Page, app_url: str, db_ready: None, upload_archive: Path
) -> None:
"""Start the upload and, DURING the scan, reload
``/git-sources.html`` → the button is in the Processing state
(disabled) with no error banner and NO second upload (the status
endpoint's single run is still the one from before the reload —
pinned on its ``started_at``); it then settles with the result line
and 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 = _wait_running_started_at(page, app_url, "/api/git-sources/upload/status")
# Reload mid-scan — the page must re-attach, 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()
# The boot re-attach (task 05): the button is in the Processing
# state (disabled, "Processing…") with no error banner and no
# result line yet…
btn = page.locator("#archive-upload-btn")
expect(btn).to_be_disabled(timeout=15_000)
expect(btn).to_have_text(re.compile(r"Processing…"), timeout=15_000)
expect(page.locator("#archive-upload-error")).to_be_hidden()
expect(page.locator("#archive-upload-result")).to_be_hidden()
# …and it settles with the result line from the status success…
expect(page.locator("#archive-upload-result")).to_have_text(
f"{N_FILES} added", timeout=SETTLE_TIMEOUT_MS
)
expect(btn).to_be_enabled()
expect(btn).to_have_text("Upload & scan")
# …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"]) == started_at, (
f"the run's identity changed (a second upload ran): {terminal['started_at']}"
)
assert terminal["current_file"] is None