Files
ducoterra c054b47df5
Build and Push Containers / build-and-push-app (push) Successful in 1m47s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 187_sync_peak_memory_line
**Phase 187 — final verification pass: all criteria met, repo 100% green.**

Verified (all four tasks already in `complete/`)
- `app/core/memory.py`: `rss_mb` / `rss_peak_mb` / `cgroup_peak_mb` / `fmt_mb`, pure stdlib, never-raise, **100%** coverage, 24 branch tests.
- Lifecycle lines append-only: `sync: started … rss_mb=`, all three terminals `… rss_mb= rss_peak_mb=` (failed = sanitized one-liner beside the surviving `logger.exception`), `import: summary … rss_mb=`; prefixes byte-identical.
- `GET /api/sync/status`: additive `rss_mb`/`rss_peak_mb` (`int|null`), keyed off the ONE `_sample_rss` (run start, phase transitions, forced terminals, 5 s-throttled hook), `peak >= current` invariant, anonymous 403.
- `sources.js` `fmtSyncPeakClause`: ` · peak <N> MB` trailing the counts line / "Sync cancelled", nothing when null, no new control/live region.

Gates (exact commands)
- `uv run pytest --cov=app --cov-report=term-missing` → **4570 passed**, TOTAL **99%**, `app/api/sync.py` **100%**, `app/core/memory.py` **100%**
- `uv run ruff check .` → clean · `uv run pyright` → **0 errors**
- `uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov` → **5 passed in isolation** (ran twice)
- All 27 touched E2E families re-run **in isolation**, all green (progress_box 8, cancel 3, status_freshness 4, summary_visibility 3, sync_button 3, session_reset 4, git_freeze 2, git_log_stream_dates 5, PDF/image suites, …)

Completion criteria: every checkbox ✅ except the commit + phase-dir move, which belong to the harness.

Documentation
- README reviewed in full (pages, commands, config tables cross-checked against `app/config.py` — 85 env rows, all defaults match; phase-187 ops section, greps, process-vs-cgroup caveat): consistent, no changes needed.
- `AGENTS.md`: **fixed one pre-existing inconsistency** — rule 6 claimed the no-CDN test covers "the index page"; it actually parametrizes 10 pages (all but `/doc-edit.html`).

Notable
- Two failures early in the pass were self-inflicted by running the E2E module concurrently with the full suite on the shared Postgres (TRUNCATE deadlock + one order-dependent `test_overview` flake); both vanish under the documented serial isolation — no code defect, no change made.
- **PLAN §9 still needs the owner's reconciliation** (phase 187 adds the sync memory fields + two status keys; PLAN is never agent-edited — AGENTS rule 10 carries the spec). `TODO.md` holds an unrelated owner note, left untouched.

Next pending phase: `188_chunks_hnsw_index`.
2026-10-16 15:22:51 -04:00

605 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 134 (task 03) E2E (Playwright, mock-only): the owner's cancel
path pinned end to end against the REAL pipeline (the owner's TODO item,
L3 — "Need to be able to cancel a sync after I've clicked the button.").
While a sync is in flight the Sources page shows a Cancel button; one
click requests the cancel, the pipeline stops at the next boundary, and
the run lands in the new ``cancelled`` TERMINAL state (reported by the
status endpoint with ``error: null`` and rendered by the UI as the
neutral "Sync cancelled" — cancel is intent, not failure: no error
banner, no modal, the controls settle retry-ready). The re-sync after
the partial run then imports the FULL fixture (the importer's
content-hash delta makes the restart clean — a partial cancel never
leaves a corrupted or permanently-incomplete KB).
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_cancel.py -v --no-cov
Isolation (the combined-run convention): a per-module app on a
dedicated port (the conftest session app is never started in this
isolated run), the mock LLM on the conftest port, a dedicated
``BOR_SOURCES_DIR`` for the real ``file://`` fixture repo's checkout,
and the shared E2E Postgres truncated before AND after every test.
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the cancel
window IS the timing fixture, and the phase-64 ``tests/e2e/slow_llm.py``
delay proxy is the ONLY thing that makes it deterministic: it sleeps
``SLOW_LLM_DELAY_S = 0.3`` before forwarding every LLM request, so the
40-file fixture's import span is ≈ 40 × 0.3 s ≈ 12+ s (plus the prelude
probes, and the change-gated overview + folder summaries on the
re-sync) — wide against the UI's 2 s status poll and against the
tests' ~100 ms ``page.request`` status poll (the slow_llm module's
deterministic-layer convention). The running state, the cancel, the
``cancelled`` terminal, and the re-sync are observed as a SEQUENCE, not
a race — a rerun on a quiet machine passes. The cancel is cooperative
(phase 134 design): a git subprocess in flight at the cancel moment
runs to completion and the run stops at the NEXT boundary (before the
import, between files, or before the overview/summaries span) — but
the full import outlasts the ~0.5 s cancel by ~10×, so the cancelled
terminal's ``files_done`` is always < N_FILES, and the re-sync's
added + unchanged is exactly N_FILES.
Wall-time guard: the whole module (two syncs under the proxy + page
setup) is budgeted at < 120 s and pinned by the module-scoped
``_wall_guard`` fixture — if the suite drifts, cut the file count, not
the guard.
Test → observable mapping (Playwright Mapping Rule):
1. ``test_anonymous_cancel_403`` — the admin-only surface: an
anonymous POST /api/sync/cancel is 403 (same as the other two sync
endpoints), and #sync-cancel-btn is never revealed to a logged-out
visitor (the whoami gate).
2. ``test_cancel_when_idle_409`` — no run in flight: 409 with the
exact detail "no sync is running"; the UI shows no cancel
affordance at boot + idle (the button is hidden, the main button
settled retry-ready).
3. ``test_admin_cancel_lifecycle`` — the owner's exact flow: Cancel
visible + enabled while the run is in flight (the 202 enters the
running state synchronously) → click (the control disables
immediately — never stale) → the status endpoint reports the
``cancelled`` terminal (``error: null``, ``detail: {}``, the
in-flight positions cleared, ``files_done < N_FILES``) → the UI
settles to "Sync cancelled" (main button retry-ready, Cancel
control hidden, no error banner, no modal in the DOM) → the
re-sync imports the FULL fixture ("Synced HH:MM", added +
unchanged == N_FILES, the sentinel row in the drill-down catalog,
the sentinel's content intact in the DB — the cancel never
corrupted the KB).
"""
from __future__ import annotations
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 Browser, ConsoleMessage, Page, expect
from sqlalchemy import text
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,
)
from e2e.sync_helpers import PEAK_CLAUSE, counts_line
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_SYCNCEL", "8133"))
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_SYCNCEL", "8907"))
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
#: Per-LLM-request delay on the proxy — 0.3 s: the 40-file fixture's
#: import span is ≈ 12+ s, a wide, deterministic cancel window against
#: the UI's 2 s poll and the tests' ~100 ms status poll (the module
#: docstring's timing paragraph).
SLOW_DELAY_S = "0.3"
#: The fixture repo's directory name — the source name the import
#: records for it (the repo_name basename rule, phase 28).
GIT_SOURCE_NAME = "cancel-fixture"
#: The fixture's file count — the re-sync's full-pass bar (added +
#: unchanged must reach exactly N_FILES after the partial cancel).
N_FILES = 40
#: The ONE sentinel file's marker (task 03 step 1) — the assertion
#: target for the re-sync: its presence in the catalog + its intact
#: content in the DB prove the REAL clone was fully indexed and the
#: interrupted run left no partial junk.
SENTINEL = "RESE-CANCEL-SENTINEL-4f1e"
FIXTURE_DOC = "notes/note-40.md"
#: "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 SUCCESS result line — "N added" or "N added · M
#: unchanged" (sources.js's fmtSyncResult — updated/pruned are zero on
#: the re-sync: the repo is unchanged and nothing was pruned) with the
#: phase-187 peak clause optional at the end (null pair → no clause).
RESULT_LINE = re.compile(rf"^(\d+) added(?: · (\d+) unchanged)?{PEAK_CLAUSE}$")
#: Settle budgets: the cancel terminal lands ~1 s after the click and
#: the UI's 2 s poll settles at most one tick after the terminal
#: (15 s is ~7× the worst case); the re-sync is ≈ 45 LLM calls ×
#: 0.3 s ≈ 14 s under the proxy (60 s is ~4×).
SYNC_SETTLE_TIMEOUT_MS = 15_000
RESYNC_TIMEOUT_MS = 60_000
#: The browser logs "Failed to load resource: the server responded with
#: a status of 403" for the anonymous endpoint probes (the whoami gate
#: + the admin-only status fetch) — expected noise, not a defect.
_CONSOLE_NOISE = re.compile(r"Failed to load resource", re.IGNORECASE)
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 _note_md(k: int) -> str:
"""One deterministic fixture note — unique per file (each one
imports independently under the content-hash delta); the LAST file
is the sentinel (the re-sync's assertion target)."""
marker = SENTINEL if k == N_FILES else f"RESE-CANCEL-NOTE-{k:02d}"
return (
f"# Note {k:02d}\n"
"\n"
f"Deterministic homelab note number {k} of the {GIT_SOURCE_NAME} "
"fixture repo. It exists only to give the sync a wide, "
"file-by-file cancel window under the slow-LLM proxy — every "
"file is unique, so every one imports independently.\n"
"\n"
f"Marker: {marker}\n"
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def sync_git_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""A real one-commit git repo the sync must clone (the
``test_sync_button.sync_git_repo`` pattern): N_FILES small A9-format
``.md`` files under ``notes/`` — the ~12+ s import span under the
0.3 s proxy that IS the cancel window (the module docstring's
timing paragraph)."""
root = tmp_path_factory.mktemp("sync_cancel_git")
repo = root / GIT_SOURCE_NAME
(repo / "notes").mkdir(parents=True)
for k in range(1, N_FILES + 1):
(repo / "notes" / f"note-{k:02d}.md").write_text(_note_md(k), encoding="utf-8")
_git(repo, "init", "-q")
_git(repo, "add", "-A")
_git(
repo,
"-c", "user.email=e@x", "-c", "user.name=t",
"-c", "commit.gpgsign=false", # the fixture commit never signs
"commit", "-qm", "one",
)
assert (repo / ".git").is_dir()
return repo
@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 conftest spawn pattern) — the ONLY
thing that makes the cancel window deterministic (the module
docstring's timing paragraph)."""
if USE_REAL_LLM:
pytest.skip(
"mock-only suite — the slow proxy fronts the deterministic "
"mock (E2E_REAL_LLM unsupported)"
)
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, sync_git_repo: Path) -> Iterator[str]:
"""The real app under test — per-module env (the
``test_sync_button.app_server`` pattern): ``BOR_LLM_BASE_URL`` is
the SLOW proxy in front of the deterministic mock (the timing
fixture), ``BOR_GIT_SOURCES`` the one real ``file://`` fixture
repo, its own ``BOR_SOURCES_DIR`` (the checkout home)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = f"{SLOW_URL}/v1"
# Mock-calibrated threshold (conftest pattern) — keeps the suite's
# deterministic gate behavior; production default stays 0.62.
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
# The sync's subject — one real local repo (N_FILES files).
env["BOR_GIT_SOURCES"] = f"file://{sync_git_repo}"
env["BOR_SOURCES_DIR"] = str(sync_git_repo.parent / "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 (the E2E isolation pattern): the shared
Postgres may carry leftover rows from other suites, and a leftover
git_sources row would pollute the source list (effective_sources
prefers the DB list over the app's BOR_GIT_SOURCES env fallback)."""
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(app_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=app_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
if r.json()["state"] != "running":
return
time.sleep(0.2)
raise AssertionError(f"a sync was still running at a test boundary: {app_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; 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_guard() -> Iterator[None]:
"""The task's wall-time guard: the whole module (two syncs under
the 0.3 s proxy + page setup) must fit inside 120 s — if it
drifts, cut the file count, not the guard."""
t0 = time.monotonic()
yield
elapsed = time.monotonic() - t0
assert elapsed < 120.0, (
f"module wall time {elapsed:.1f}s blew the 120 s budget — cut "
"the fixture file count, not the guard"
)
@pytest.fixture()
def page(browser: Browser) -> Iterator[Page]:
"""The conftest's page + the console-error collector (task 02's
criterion: the page renders with NO console errors): an uncaught JS
exception (pageerror) or a console error that is not the browser's
expected "Failed to load resource" 403 noise (the anonymous
endpoint probes) fails the owning test at teardown."""
pg = browser.new_page(viewport={"width": 1280, "height": 800})
errors: list[str] = []
def _on_console(msg: ConsoleMessage) -> None:
if msg.type == "error" and not _CONSOLE_NOISE.search(msg.text):
errors.append(msg.text)
pg.on("console", _on_console)
pg.on("pageerror", lambda err: errors.append(str(err)))
try:
yield pg
finally:
pg.close()
assert not errors, f"console/page errors: {errors}"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _wait_sync_state(
page: Page, app_url: str, states: set[str], timeout_s: float = 30.0
) -> dict[str, Any]:
"""Poll the (cookie-authenticated) status endpoint at ~100 ms
cadence until the run's state is in *states* — the slow_llm
module's deterministic layer (the ``test_sync_button._wait_sync_done``
helper generalized to a set of terminal states: here ``{"cancelled"}``
and ``{"success", "failed"}`` shapes for the re-sync)."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = page.request.get(f"{app_url}/api/sync/status")
assert r.status == 200, r.text
body = r.json()
if body["state"] in states:
return body
time.sleep(0.1)
raise AssertionError(f"sync did not reach {sorted(states)}: {body}")
def _go_top(page: Page) -> None:
"""Back to the top level (no-op when already there — the
breadcrumb is hidden at the top)."""
if not page.locator("#kb-crumb").is_hidden():
page.locator("#kb-crumb a.kb-crumb-link").first.click()
def _expect_fixture_row(page: Page) -> None:
"""The REAL clone was fully imported after the cancel — the
sentinel's fixture path is listed. Phase 97: the catalog is the
drill-down tree, so drill source → folder ``notes`` first."""
_go_top(page)
page.click(f'#folders-tbody a.folder-link:text-is("{GIT_SOURCE_NAME}")')
page.click('#folders-tbody a.folder-link:text-is("notes")')
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
# ---------------------------------------------------------------------------
# 1. Anonymous: locked endpoint, the Cancel control is never revealed
# ---------------------------------------------------------------------------
def test_anonymous_cancel_403(page: Page, app_url: str, db_ready: None) -> None:
"""The admin-only surface (same as the other two sync endpoints):
an anonymous POST /api/sync/cancel is 403, and #sync-cancel-btn is
NEVER revealed to a logged-out visitor (the whoami gate — the
button ships hidden and only a running sync state reveals it, which
the anonymous page never enters)."""
page.goto(f"{app_url}/sources.html")
expect(page.locator("#sync-btn")).to_be_hidden()
expect(page.locator("#sync-cancel-btn")).to_be_hidden()
# The same whoami gate that hides the buttons also hides the admin
# nav link and shows the catalog sign-in gate.
expect(page.locator("#nav-sources")).to_be_hidden()
expect(page.locator("#sources-gate")).to_be_visible()
assert page.request.get(f"{app_url}/api/sync/status").status == 403
assert page.request.post(f"{app_url}/api/sync/cancel").status == 403
# ---------------------------------------------------------------------------
# 2. Idle: 409 with the exact detail, no cancel affordance in the UI
# ---------------------------------------------------------------------------
def test_cancel_when_idle_409(page: Page, app_url: str, db_ready: None) -> None:
"""No run in flight (no test before this one started one — the
anonymous test never does): POST /api/sync/cancel is 409 with the
exact detail "no sync is running" (no run was started, and no
terminal one is in memory — the module app's state is fresh), and
the UI shows NO cancel affordance at boot + idle: the button is
hidden and the main button is settled retry-ready. The rejected
cancel changes nothing."""
login(page, app_url) # lands on /sources.html (the button's home)
btn = page.locator("#sync-btn")
expect(btn).to_be_visible()
expect(btn).to_be_enabled()
expect(page.locator("#sync-label")).to_have_text("Sync sources")
cancel = page.locator("#sync-cancel-btn")
expect(cancel).to_be_hidden() # boot + idle: no cancel affordance
r = page.request.post(f"{app_url}/api/sync/cancel")
assert r.status == 409, r.text
assert r.json()["detail"] == "no sync is running"
# The rejected cancel is a no-op: still settled + hidden.
expect(btn).to_be_enabled()
expect(page.locator("#sync-label")).to_have_text("Sync sources")
expect(cancel).to_be_hidden()
# ---------------------------------------------------------------------------
# 3. The owner's exact flow: Cancel visible → click → "Sync cancelled"
# → the clean re-sync (the full fixture, the sentinel intact)
# ---------------------------------------------------------------------------
def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
"""The owner's TODO item, L3, end to end: a running sync shows the
Cancel button; one click requests the cancel and the control
disables IMMEDIATELY (never stale); the pipeline stops at the next
boundary and lands in the ``cancelled`` terminal (the status
endpoint reports ``error: null`` + ``detail: {}`` with the
in-flight positions cleared and ``files_done < N_FILES`` — the
full import outlasts the ~0.5 s cancel by ~10×); the UI settles to
the NEUTRAL "Sync cancelled" (main button retry-ready, Cancel
control hidden, no error banner, no modal — a cancel is intent,
not failure); and the re-sync after the partial run imports the
FULL fixture ("Synced HH:MM", added + unchanged == N_FILES, the
sentinel row in the catalog, the sentinel's content intact — the
cancel never corrupted the KB)."""
page.set_default_timeout(30_000)
login(page, app_url) # lands on /sources.html (the button's home)
btn = page.locator("#sync-btn")
cancel = page.locator("#sync-cancel-btn")
expect(btn).to_be_visible()
expect(btn).to_be_enabled()
expect(page.locator("#sync-label")).to_have_text("Sync sources")
expect(cancel).to_be_hidden() # idle: no cancel affordance
# --- run 1: start + cancel -------------------------------------------
btn.click()
# The 202 enters the running state SYNCHRONOUSLY (the click handler
#'s 202 branch, before the first 2 s poll tick): the main button
# disables, the detail box reveals with "Syncing…" (phase 149 —
# the running label rides the box; the button is the spinner),
# and the Cancel control reveals + enables (enterSyncRunningState,
# kind "sync").
expect(btn).to_be_disabled()
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(cancel).to_be_visible()
expect(cancel).to_be_enabled()
# The deterministic layer (the slow_llm module's convention):
# confirm via the status endpoint that the run is actually in
# flight (page.request poll at ~100 ms).
_wait_sync_state(page, app_url, {"running"})
# The owner's click: the control disables IMMEDIATELY — the first
# line of requestCancel(), before the fetch (never stale, one
# request at a time).
cancel.click()
expect(cancel).to_be_disabled()
# --- the cancelled terminal (the deterministic layer) -----------------
body = _wait_sync_state(page, app_url, {"cancelled"}, timeout_s=30.0)
assert body["state"] == "cancelled"
assert body["error"] is None # a cancel is intent, not failure
assert body["detail"] == {} # a partial run reports no counts
assert body["started_at"] is not None
assert body["finished_at"] is not None
# The terminal clears the in-flight positions but keeps the run's
# final file counts (the phase-64/98/129 keep-final-counts
# convention, the phase-134 fifth state end to end).
assert body["current_file"] is None
assert body["phase"] is None
assert body["current_summary"] is None
assert body["current_chunk"] == 0
assert body["chunks_total"] == 0
# The cancel interrupted the run: the import (a full pass is ≥12 s
# under the 0.3 s proxy) had no time to finish all N_FILES.
assert body["files_done"] < N_FILES, body
# --- the neutral UI settle (timeout-bound: the 2 s poll sees the
# terminal within one tick) -------------------------------------------
expect(
page.locator("#sync-label")
).to_have_text("Sync sources", timeout=SYNC_SETTLE_TIMEOUT_MS)
expect(btn).to_be_enabled() # retry-ready
expect(btn).not_to_have_attribute("aria-busy")
expect(cancel).to_be_hidden()
# Phase 150: the neutral result line rides the box (the sole sync
# text surface — the retired announcer span is gone).
expect(page.locator("#sync-progress-text")).to_have_text(
counts_line("Sync cancelled"), timeout=SYNC_SETTLE_TIMEOUT_MS
)
# A1: the cancel is never rendered as a failure — no error banner,
# no error modal in the DOM.
expect(page.locator("#sync-error-banner")).to_be_hidden()
assert page.locator(".sync-modal-backdrop").count() == 0
# --- run 2: the re-sync is clean --------------------------------------
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(cancel).to_be_visible() # the fresh run is cancellable again
expect(page.locator("#sync-label")).to_have_text(
SYNCED_LABEL, timeout=RESYNC_TIMEOUT_MS
)
expect(btn).to_be_enabled()
expect(cancel).to_be_hidden()
# The FULL fixture landed: added + unchanged == N_FILES — the
# cancelled run's partial import is idempotent under the
# content-hash delta (already-indexed files re-import unchanged,
# the rest are added; the interrupted file's uncommitted
# transaction rolled back).
line = page.locator("#sync-progress-text").inner_text()
m = RESULT_LINE.fullmatch(line)
assert m, f"unexpected result line: {line!r}"
added = int(m.group(1))
unchanged = int(m.group(2) or 0)
assert added >= 1, (line, body["files_done"])
assert added + unchanged == N_FILES, (line, body["files_done"])
# The KB is complete AND uncorrupted: every fixture file is
# indexed, and the sentinel's content is intact (the REAL clone's
# full content — not a partial write from the interrupted run).
with SessionLocal() as db:
n_docs = db.execute(
text("SELECT count(*) FROM documents WHERE source = :s"),
{"s": GIT_SOURCE_NAME},
).scalar_one()
sentinel_content = db.execute(
text("SELECT content FROM documents WHERE source = :s AND path = :p"),
{"s": GIT_SOURCE_NAME, "p": FIXTURE_DOC},
).scalar_one()
assert n_docs == N_FILES, n_docs
assert SENTINEL in sentinel_content
# And the sentinel row is present in the catalog (the
# _expect_fixture_row pattern — drill source → notes).
_expect_fixture_row(page)
# No error surface on the success settle either.
expect(page.locator("#sync-error-banner")).to_be_hidden()
assert page.locator(".sync-modal-backdrop").count() == 0