**Phase 134 final verification pass — all green** (all 3 task files already in `complete/`; no implementation was missing) - Verified: 5-state `SyncStatus` + `cancelled` terminal (error None, final counts kept), `POST /api/sync/cancel` (202/409/403), cooperative `_check_cancelled` boundaries, shutdown `CancelledError` contract preserved, `#sync-cancel-btn` show/hide + neutral "Sync cancelled" settle, 8-branch poll tree, re-attach — code, unit, integration, and E2E layers all present - `uv run pytest --cov=app --cov-report=term-missing` → **3150 passed**, `app/` coverage **99%** (gate: >90%) - `uv run pytest tests/e2e/test_sync_cancel.py -v --no-cov` (isolated) → **3 passed** (47.6 s) - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings **Completion criteria:** ① cancel endpoint 202/409/403 ✅ (unit + E2E) ② `cancelled` terminal reported by status, error null, counts kept ✅ (integration `test_cancel_mid_run_lands_in_cancelled_then_resyncs_clean`) ③ unrequested-cancel pin ✅ (`test_unrequested_cancel_is_never_swallowed_and_never_converts`) ④ UI lifecycle + clean re-sync (sentinel row, full 40-file import) ✅ (E2E) ⑤ suite/coverage/E2E gates ✅ ⑥ uncancelled runs byte-identical ✅ (all 3150 pre-existing tests green untouched) - Docs: README.md reviewed in full against code (pages, auth endpoints, every config default vs `app/config.py`, palette 9+8, fail-loud, cancel section) — consistent, no changes needed. AGENTS.md: fixed one pre-existing inaccuracy (rule 2 "move its file" → "move its directory" — phases are directories); everything else verified accurate - No live infrastructure touched; no deviations **Next pending phase:** `135_git_source_branch`
599 lines
26 KiB
Python
599 lines
26 KiB
Python
"""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
|