"""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, ) 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). RESULT_LINE = re.compile(r"^(\d+) added(?: · (\d+) unchanged)?$") #: 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 + "Syncing…", and the Cancel control reveals + enables # (enterSyncRunningState, kind "sync"). expect(btn).to_be_disabled() expect(page.locator("#sync-label")).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() expect(page.locator("#sync-result")).to_have_text( "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-label")).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-result").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