feat(sources): removing a source deletes its files and index entries behind a confirmation modal
This commit is contained in:
@@ -35,8 +35,10 @@ Contract under test:
|
||||
render as a full-width table (mono URL, added date, per-row Remove)
|
||||
with the env note hidden while the DB has rows; add (201 → row, input
|
||||
cleared, button re-enabled), duplicate (inline role=alert, no new
|
||||
row), invalid shape (inline 422, no new row), remove (confirm → gone;
|
||||
cancel → stays); with the table truncated the env rows render with
|
||||
row), invalid shape (inline 422, no new row), remove (the
|
||||
confirmation modal: accept → gone; cancel → stays — the native
|
||||
confirm() retired, phase 69); with the table truncated the env rows
|
||||
render with
|
||||
"from .env" tags and ``#git-sources-env-note`` visible;
|
||||
* the page a11y / no-CDN basics (UI Structure Check, AGENTS.md rule 5):
|
||||
landmarks, labeled form control, full-width table, ≥44px targets,
|
||||
@@ -64,7 +66,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Dialog, Page, expect
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
@@ -425,42 +427,43 @@ def test_admin_add_then_remove_lifecycle(
|
||||
expect(add_btn).to_be_enabled()
|
||||
expect(add_btn).to_have_text("Add source")
|
||||
|
||||
# --- remove: accept the confirm → the row disappears --------------
|
||||
dialog_action: dict[str, bool] = {"accept": True}
|
||||
# --- remove: accept the confirmation modal → the row disappears ---
|
||||
# (phase 69: the native confirm() is retired — a row's Remove opens
|
||||
# the page-local alertdialog; "Remove source" accepts, Cancel
|
||||
# cancels without a request.)
|
||||
deletes: list[str] = []
|
||||
|
||||
def handle_dialog(dialog: Dialog) -> None:
|
||||
if dialog_action["accept"]:
|
||||
dialog.accept()
|
||||
else:
|
||||
dialog.dismiss()
|
||||
|
||||
def track_delete(r: Any) -> None:
|
||||
if r.method == "DELETE" and "/api/git-sources/" in r.url:
|
||||
deletes.append(r.url)
|
||||
|
||||
page.on("dialog", handle_dialog)
|
||||
page.on("request", track_delete)
|
||||
try:
|
||||
_row(page, NEW_REPO_URL).locator(".git-source-remove").click()
|
||||
dialog = page.locator("#remove-confirm-dialog")
|
||||
expect(dialog).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#remove-confirm-source")).to_have_text(NEW_REPO_URL)
|
||||
page.locator("#remove-confirm-remove").click()
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1, timeout=30_000)
|
||||
expect(_row(page, NEW_REPO_URL)).to_have_count(0)
|
||||
expect(dialog).to_be_hidden()
|
||||
# The seed row survived — and exactly one DELETE went out.
|
||||
expect(_row(page, SEED_URL)).to_have_count(1)
|
||||
assert len(deletes) == 1, f"expected one DELETE, saw: {deletes}"
|
||||
|
||||
# --- remove: cancel the confirm → the row stays, NO DELETE ----
|
||||
dialog_action["accept"] = False
|
||||
# --- remove: cancel the modal → the row stays, NO DELETE ------
|
||||
_row(page, SEED_URL).locator(".git-source-remove").click()
|
||||
# Wait for the dialog round-trip to settle (dismiss → the JS
|
||||
# returns without fetching) so the "no second DELETE" claim is
|
||||
expect(page.locator("#remove-confirm-dialog")).to_be_visible(timeout=30_000)
|
||||
page.locator("#remove-confirm-cancel").click()
|
||||
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
|
||||
# Wait for the cancel round-trip to settle (Cancel closes the
|
||||
# dialog without fetching) so the "no second DELETE" claim is
|
||||
# made on a settled page.
|
||||
page.wait_for_timeout(500)
|
||||
assert len(deletes) == 1, f"canceled removal still deleted: {deletes}"
|
||||
expect(_row(page, SEED_URL)).to_have_count(1)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
finally:
|
||||
page.remove_listener("dialog", handle_dialog)
|
||||
page.remove_listener("request", track_delete)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
"""Phase 69 story E2E (Playwright): total source removal — the
|
||||
confirmation modal, the row + index prune, and the app-managed disk
|
||||
cleanup (``/git-sources.html``).
|
||||
|
||||
Story: n/a (owner request from chat, 2026-09-02)
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_source_removal_cleanup.py -v --no-cov
|
||||
|
||||
The story gate for TOTAL source removal (phase 69): removing a source
|
||||
removes, in one action, the stored row, every indexed document of that
|
||||
source (chunks + embeddings), and — for app-managed sources — the files
|
||||
on disk (the git checkout under ``BOR_SOURCES_DIR`` or the unpacked
|
||||
upload folder under ``BOR_UPLOAD_DIR``), immediately (not deferred to
|
||||
the next sync). The page confirms first through a page-local
|
||||
``role="alertdialog"`` modal (the native ``confirm()`` is retired) that
|
||||
names the source and states the removal policy; Cancel/Escape close
|
||||
without a request, and only "Remove source" sends the DELETE.
|
||||
|
||||
**Disk paths are resolved exactly like the app**:
|
||||
``Path(get_settings().sources_dir).expanduser()`` /
|
||||
``Path(get_settings().upload_dir).expanduser()`` — the E2E conftest
|
||||
does NOT override those two vars and pytest runs with ``cwd=REPO``, so
|
||||
the test process and the app subprocess resolve the same ``.env``
|
||||
(``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR``) and the same-host ``pathlib``
|
||||
assertions hit the very directories the DELETE handler cleans.
|
||||
|
||||
The suite triggers **no sync** (the git rows are ``example.com`` URLs
|
||||
that are never cloned); the only real artifact is the API-driven
|
||||
upload of one small archive with a unique name (``phase69-<8-hex>.tar.gz``,
|
||||
one ``.md`` file) — its background scan runs the mock-LLM pipeline
|
||||
(no network beyond the app itself). Seeded ``Document`` rows
|
||||
(``SessionLocal``, the ``test_git_sources_admin.py`` pattern) give the
|
||||
prune assertions a deterministic KB.
|
||||
|
||||
Per-module app env (the conftest pattern, module-scoped): the same env
|
||||
shape as ``test_git_sources_admin.py`` with ``BOR_GIT_SOURCES`` forced
|
||||
empty (a dev ``.env`` fallback URL must never render as an env row on
|
||||
the table); **no** ``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR`` override
|
||||
(deliberate — see the disk-path note above).
|
||||
|
||||
Contract under test:
|
||||
|
||||
* **upload → modal → total removal**: the uploaded folder exists on
|
||||
disk and its document is in ``GET /api/docs``; the row's Remove →
|
||||
the alertdialog opens (``#remove-confirm-source`` = the upload path,
|
||||
focus on ``#remove-confirm-cancel``) → "Remove source" → the
|
||||
"Removing…" in-flight state (both buttons disabled) → settled: the
|
||||
row is gone, the document is pruned, **the folder is gone from
|
||||
disk**, exactly one DELETE went out, and the announcer carries the
|
||||
success line;
|
||||
* **git checkout removal**: a seeded git row + a hand-made checkout
|
||||
dir (marker file) + a seeded document → modal removal → the row is
|
||||
gone, **the checkout dir is gone from disk** (marker included) and
|
||||
the document is pruned;
|
||||
* **no-checkout no-op**: a git row with NO checkout dir (never synced)
|
||||
removes cleanly — the absent-dir path, no error state anywhere on
|
||||
the page;
|
||||
* **foreign local directories are never touched**: a seeded
|
||||
``kind='local'`` row pointing at a test-owned dir (marker file) + a
|
||||
seeded document → modal removal → the row is gone and the document
|
||||
is pruned, **but the dir + marker file are still present**;
|
||||
* **cancel + Escape keep everything**: Cancel click → dialog hidden,
|
||||
focus back on the trigger button, **zero DELETE requests**, row +
|
||||
document remain; re-open → Escape → the same;
|
||||
* **modal a11y + no CDN** (UI Structure Check, AGENTS.md rule 5 +
|
||||
rule 6): the dialog's aria attributes, the 3px ``:focus-visible``
|
||||
outline computable on BOTH buttons (keyboard focus), both buttons
|
||||
≥44px tall, the success line on ``#git-sources-announcer``
|
||||
(role=status) after a real removal, and the page loads only
|
||||
same-origin resources.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_uploaded_source_removal_cleans_index_and_disk``
|
||||
2. ``test_git_source_removal_removes_checkout_and_index``
|
||||
3. ``test_git_source_removal_without_checkout_succeeds``
|
||||
4. ``test_local_directory_source_files_never_deleted``
|
||||
5. ``test_remove_modal_cancel_and_esc_keep_everything``
|
||||
6. ``test_remove_modal_a11y_and_no_cdn``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, Route, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Document, GitSource
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
APP_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"
|
||||
|
||||
#: The success line the page announces on a total removal (the
|
||||
#: git-sources.js announce() string).
|
||||
REMOVAL_ANNOUNCE = "Source removed — its files and index entries were cleaned up."
|
||||
|
||||
#: Deterministic, never-cloned example.com URLs — the suite never
|
||||
#: triggers a sync, so the only on-disk artifact a git row can have is
|
||||
#: the checkout dir this suite itself creates (test 2).
|
||||
GONE_URL = "https://example.com/reese/phase69-gone.git"
|
||||
GONE_REPO = "phase69-gone" # repo_name(GONE_URL)
|
||||
NO_CHECKOUT_URL = "https://example.com/reese/phase69-nodir.git"
|
||||
KEPT_URL = "https://example.com/reese/phase69-kept.git"
|
||||
KEPT_REPO = "phase69-kept"
|
||||
A11Y_URL = "https://example.com/reese/phase69-a11y.git"
|
||||
A11Y_REPO = "phase69-a11y"
|
||||
|
||||
#: The upload sentinel — one markdown file in the unique archive.
|
||||
UPLOAD_SENTINEL = "PHASE69-UPLOAD-9c2d"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: ``BOR_GIT_SOURCES``
|
||||
forced empty (no env fallback rows on the table). Deliberately NO
|
||||
``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR`` override: the disk
|
||||
assertions must resolve the dirs EXACTLY like the app (the module
|
||||
docstring explains why the same ``.env`` resolves on both sides)."""
|
||||
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"http://127.0.0.1:{mock_llm}/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
|
||||
# No env fallback rows: the table state (seeded per test) is the
|
||||
# only row source, so row-count assertions are deterministic.
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sources_dir() -> Path:
|
||||
"""The app's git-checkout root — resolved EXACTLY like the app
|
||||
(same ``.env``, same ``cwd=REPO``; the conftest does not override
|
||||
``BOR_SOURCES_DIR``)."""
|
||||
return Path(get_settings().sources_dir).expanduser()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def upload_dir() -> Path:
|
||||
"""The app's upload root — resolved EXACTLY like the app (see
|
||||
``sources_dir``)."""
|
||||
return Path(get_settings().upload_dir).expanduser()
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB per test (the E2E isolation pattern, the
|
||||
``test_archive_upload_sources.py`` set): the row/doc assertions
|
||||
must be this test's own doing — a leftover document under one of
|
||||
the deterministic source names would survive the prune and flip an
|
||||
assertion."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(db_ready: None) -> Iterator[None]:
|
||||
_truncate_all()
|
||||
yield
|
||||
_truncate_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_git_row(url: str) -> None:
|
||||
"""Store a git row directly (deterministic — the
|
||||
``test_git_sources_admin.py`` SessionLocal seeding pattern)."""
|
||||
with SessionLocal() as db:
|
||||
db.add(GitSource(url=url, kind="git"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_local_row(path: str) -> None:
|
||||
"""Store a kind=local row (the phase-38 shape: the expanded
|
||||
absolute path in BOTH ``path`` and the NOT-NULL ``url``)."""
|
||||
with SessionLocal() as db:
|
||||
db.add(GitSource(url=path, kind="local", path=path))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_doc(source: str, path: str, full_path: str, title: str, content: str) -> None:
|
||||
"""One indexed document (the prune subject) — a full row so the
|
||||
cascade + the /api/docs listing behave exactly like a scanned doc."""
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
Document(
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=full_path,
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||
)
|
||||
)
|
||||
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 _row(page: Page, value: str) -> Any:
|
||||
"""The table row whose mono cell shows ``value`` (git URL or
|
||||
local path — the row's rendered value)."""
|
||||
return page.locator("#git-sources-tbody tr", has_text=value)
|
||||
|
||||
|
||||
def _docs(page: Page, app_url: str) -> list[tuple[str, str]]:
|
||||
"""``GET /api/docs`` as the signed-in page → sorted (source, path)
|
||||
pairs (the admin cookie rides the browser context)."""
|
||||
r = page.request.get(f"{app_url}/api/docs")
|
||||
assert r.status == 200, r.text
|
||||
return sorted((d["source"], d["path"]) for d in r.json()["documents"])
|
||||
|
||||
|
||||
def _stored_sources(page: Page, app_url: str) -> list[dict[str, Any]]:
|
||||
"""``GET /api/git-sources`` as the signed-in page → the row list."""
|
||||
r = page.request.get(f"{app_url}/api/git-sources")
|
||||
assert r.status == 200, r.text
|
||||
return r.json()["sources"]
|
||||
|
||||
|
||||
def _build_targz(path: Path, files: dict[str, str]) -> Path:
|
||||
"""A deterministic ``.tar.gz`` (mtime 0) over the given files (the
|
||||
``test_archive_upload_sources.py`` pattern)."""
|
||||
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
|
||||
|
||||
|
||||
def _upload_and_wait_success(page: Page, app_url: str, archive: Path) -> dict[str, Any]:
|
||||
"""POST the archive through the logged-in page's request context
|
||||
(the admin cookie rides along) and poll the phase-64 status
|
||||
endpoint to ``success`` — returns the terminal status body."""
|
||||
r = page.request.post(
|
||||
f"{app_url}/api/git-sources/upload",
|
||||
multipart={
|
||||
"file": {
|
||||
"name": archive.name,
|
||||
"mimeType": "application/gzip",
|
||||
"buffer": archive.read_bytes(),
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status == 202, f"upload POST failed: {r.status} {r.text}"
|
||||
deadline = time.monotonic() + 60.0
|
||||
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"] == "success":
|
||||
return body
|
||||
if body["state"] == "failed":
|
||||
raise AssertionError(f"the upload scan failed: {body}")
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"the upload scan never settled: {body}")
|
||||
|
||||
|
||||
def _open_remove_modal(page: Page, value: str) -> None:
|
||||
"""Click the row's Remove for ``value`` and assert the opened
|
||||
alertdialog: it names the source and focus lands on Cancel (the
|
||||
safe default)."""
|
||||
_row(page, value).locator(".git-source-remove").click()
|
||||
dialog = page.locator("#remove-confirm-dialog")
|
||||
expect(dialog).to_be_visible(timeout=15_000)
|
||||
assert dialog.get_attribute("role") == "alertdialog"
|
||||
expect(page.locator("#remove-confirm-source")).to_have_text(value)
|
||||
assert page.evaluate("() => document.activeElement.id") == "remove-confirm-cancel"
|
||||
|
||||
|
||||
def _confirm_removal(page: Page) -> None:
|
||||
"""Click "Remove source" and wait for the settled removal: the
|
||||
(single) row is gone and the dialog is closed."""
|
||||
page.locator("#remove-confirm-remove").click()
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
|
||||
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
|
||||
|
||||
|
||||
def _hold_delete_requests(page: Page, hold_s: float) -> None:
|
||||
"""Intercept DELETEs to the git-sources API and hold the REQUEST in
|
||||
the browser for ``hold_s`` seconds (the
|
||||
``test_archive_upload_sources.py`` ``_hold_upload_request``
|
||||
pattern) — the modal's "Removing…" in-flight state (§7.4) becomes
|
||||
deterministic instead of racing the (fast) mock-LLM cleanup.
|
||||
Every other request (the list GETs, the upload status poll) passes
|
||||
through untouched."""
|
||||
|
||||
def handle(route: Route) -> None:
|
||||
if route.request.method == "DELETE":
|
||||
time.sleep(hold_s)
|
||||
route.continue_()
|
||||
|
||||
page.route("**/api/git-sources/**", handle)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Upload → modal → total removal: index pruned AND the upload
|
||||
# folder is gone from disk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_uploaded_source_removal_cleans_index_and_disk(
|
||||
page: Page, app_url: str, db_ready: None, upload_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A uniquely named archive uploaded through the API (202 → status
|
||||
success): the folder exists on disk and its document is in the KB;
|
||||
then the row's Remove → the alertdialog (the upload path named,
|
||||
focus on Cancel) → "Remove source" → the "Removing…" in-flight
|
||||
state → settled: row gone, document pruned, **the folder is gone
|
||||
from disk**, one DELETE, the announcer's success line."""
|
||||
page.set_default_timeout(30_000)
|
||||
# Unique per run — never collides with a real (or a crashed-run's)
|
||||
# upload, so the disk assertions are safe on the shared dir.
|
||||
name = f"phase69-{uuid.uuid4().hex[:8]}"
|
||||
archive = _build_targz(
|
||||
tmp_path / f"{name}.tar.gz",
|
||||
{
|
||||
"note.md": (
|
||||
"# Upload note\n"
|
||||
"\n"
|
||||
f"Phase 69 removal subject. Marker: {UPLOAD_SENTINEL}\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
# The API-driven upload (202) + the background scan (success).
|
||||
status = _upload_and_wait_success(page, app_url, archive)
|
||||
assert status["detail"]["source"] == name
|
||||
assert status["detail"]["added"] == 1
|
||||
|
||||
# Preconditions — the artifact is real: the folder on disk (the
|
||||
# app's resolved upload dir — this process resolved the same one),
|
||||
# the document in the KB, the row in the registry.
|
||||
folder = upload_dir / name
|
||||
assert (folder / "note.md").is_file(), f"{folder}/note.md missing on disk"
|
||||
assert _docs(page, app_url) == [(name, "note.md")]
|
||||
r = page.request.get(f"{app_url}/api/git-sources")
|
||||
assert r.status == 200, r.text
|
||||
assert [(s["kind"], s["path"]) for s in r.json()["sources"]] == [
|
||||
("local", str(folder))
|
||||
]
|
||||
|
||||
# The page's table still shows the pre-upload (empty) state — the
|
||||
# upload went through the API, not the form (whose 202-success
|
||||
# path would have called loadSources): a reload runs the page
|
||||
# module's loadSources() and the new row lands.
|
||||
page.reload()
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#git-sources-content")).to_be_visible()
|
||||
row = _row(page, str(folder))
|
||||
expect(row).to_have_count(1, timeout=15_000)
|
||||
expect(row.locator("span.git-source-kind")).to_have_text("Local")
|
||||
|
||||
# Track the DELETEs; hold the one the modal sends so the in-flight
|
||||
# state is observable deterministically.
|
||||
deletes: list[str] = []
|
||||
page.on(
|
||||
"request",
|
||||
lambda r: deletes.append(r.url)
|
||||
if r.method == "DELETE" and "/api/git-sources/" in r.url
|
||||
else None,
|
||||
)
|
||||
_hold_delete_requests(page, hold_s=0.8)
|
||||
|
||||
# The modal: the upload path is named (textContent — the row's
|
||||
# value), focus on Cancel.
|
||||
_open_remove_modal(page, str(folder))
|
||||
|
||||
# "Remove source" → the §7.4 in-flight state (both buttons
|
||||
# disabled, the confirm relabeled) while the held DELETE is out.
|
||||
page.locator("#remove-confirm-remove").click()
|
||||
expect(page.locator("#remove-confirm-remove")).to_have_text("Removing…")
|
||||
expect(page.locator("#remove-confirm-remove")).to_be_disabled()
|
||||
expect(page.locator("#remove-confirm-cancel")).to_be_disabled()
|
||||
expect(page.locator("#remove-confirm-error")).to_be_hidden()
|
||||
|
||||
# Settled: the row is gone, the dialog closed and reset…
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
|
||||
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
|
||||
expect(page.locator("#remove-confirm-remove")).to_have_text("Remove source")
|
||||
expect(page.locator("#remove-confirm-remove")).to_be_enabled()
|
||||
# …the announcer carries the success line…
|
||||
expect(page.locator("#git-sources-announcer")).to_have_text(REMOVAL_ANNOUNCE)
|
||||
# …and exactly one DELETE went out.
|
||||
assert len(deletes) == 1, f"expected one DELETE, saw: {deletes}"
|
||||
|
||||
# The TOTAL removal, proven outside the UI: the registry is empty,
|
||||
# the document is pruned from the KB, and the folder is GONE from
|
||||
# the app's upload dir (same-host pathlib, the app's resolved dir).
|
||||
assert _stored_sources(page, app_url) == []
|
||||
assert _docs(page, app_url) == []
|
||||
assert not folder.exists(), f"{folder} still on disk after removal"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Git row → the checkout dir is removed from disk + the index pruned
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_git_source_removal_removes_checkout_and_index(
|
||||
page: Page, app_url: str, db_ready: None, sources_dir: Path
|
||||
) -> None:
|
||||
"""A seeded git row + a hand-made checkout dir (marker file, the
|
||||
sync's clone target layout) + a seeded document: the modal removal
|
||||
leaves the row gone, **the checkout dir gone from disk** (marker
|
||||
included), and the document pruned from the KB."""
|
||||
page.set_default_timeout(30_000)
|
||||
checkout = sources_dir / GONE_REPO
|
||||
shutil.rmtree(checkout, ignore_errors=True) # defensive: no leftover
|
||||
(checkout / "notes").mkdir(parents=True)
|
||||
(checkout / "notes" / "readme.md").write_text(
|
||||
f"# Readme\n\nCheckout marker for {GONE_REPO}.\n", encoding="utf-8"
|
||||
)
|
||||
_seed_git_row(GONE_URL)
|
||||
_seed_doc(
|
||||
GONE_REPO,
|
||||
"notes/readme.md",
|
||||
str(checkout / "notes" / "readme.md"),
|
||||
"Readme",
|
||||
"Readme content for the phase69 checkout-removal test.",
|
||||
)
|
||||
|
||||
_admin_git_sources_page(page, app_url)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
assert _docs(page, app_url) == [(GONE_REPO, "notes/readme.md")]
|
||||
|
||||
_open_remove_modal(page, GONE_URL)
|
||||
_confirm_removal(page)
|
||||
|
||||
# Row gone from the registry…
|
||||
assert _stored_sources(page, app_url) == []
|
||||
# …the checkout dir is GONE from disk (the marker with it)…
|
||||
assert not checkout.exists(), f"{checkout} still on disk after removal"
|
||||
# …and the document is pruned from the KB.
|
||||
assert _docs(page, app_url) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Git row, no checkout dir (never synced) → clean no-op removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_git_source_removal_without_checkout_succeeds(
|
||||
page: Page, app_url: str, db_ready: None, sources_dir: Path
|
||||
) -> None:
|
||||
"""The absent-dir path: a seeded git row whose checkout dir does
|
||||
not exist (the repo was never cloned/synced) still removes
|
||||
cleanly — row gone, and NO error state anywhere on the page."""
|
||||
page.set_default_timeout(30_000)
|
||||
shutil.rmtree(sources_dir / "phase69-nodir", ignore_errors=True) # ensure absent
|
||||
_seed_git_row(NO_CHECKOUT_URL)
|
||||
|
||||
_admin_git_sources_page(page, app_url)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
|
||||
_open_remove_modal(page, NO_CHECKOUT_URL)
|
||||
_confirm_removal(page)
|
||||
|
||||
assert _stored_sources(page, app_url) == []
|
||||
# No error state anywhere on the page (in-modal, load, or announcer
|
||||
# — the announcer carries the success line, not an error).
|
||||
expect(page.locator("#remove-confirm-error")).to_be_hidden()
|
||||
expect(page.locator("#git-sources-load-error")).to_be_hidden()
|
||||
expect(page.locator("#git-source-error")).to_be_hidden()
|
||||
expect(page.locator("#git-sources-announcer")).to_have_text(REMOVAL_ANNOUNCE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Foreign local directory → row + index go, the files NEVER do
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_local_directory_source_files_never_deleted(
|
||||
page: Page, app_url: str, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
"""A seeded kind=local row pointing at the owner's OWN directory
|
||||
(outside the app-managed roots) + a seeded document: the modal
|
||||
removal deletes the row and prunes the document, **but the dir and
|
||||
its marker file are still present** (locked decision: foreign
|
||||
local directories are never touched on disk)."""
|
||||
page.set_default_timeout(30_000)
|
||||
user_dir = tmp_path / "my-notes"
|
||||
user_dir.mkdir()
|
||||
marker = user_dir / "keep-me.md"
|
||||
marker.write_text("The owner's own file — removal must not touch it.\n", encoding="utf-8")
|
||||
_seed_local_row(str(user_dir))
|
||||
_seed_doc(
|
||||
user_dir.name,
|
||||
"keep-me.md",
|
||||
str(marker),
|
||||
"Keep me",
|
||||
"Document of the foreign local dir (the prune subject).",
|
||||
)
|
||||
|
||||
_admin_git_sources_page(page, app_url)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
assert _docs(page, app_url) == [(user_dir.name, "keep-me.md")]
|
||||
|
||||
_open_remove_modal(page, str(user_dir))
|
||||
_confirm_removal(page)
|
||||
|
||||
# Row gone, document pruned…
|
||||
assert _stored_sources(page, app_url) == []
|
||||
assert _docs(page, app_url) == []
|
||||
# …but the foreign directory + its file are STILL on disk.
|
||||
assert user_dir.is_dir(), f"{user_dir} was deleted — foreign dirs are never touched"
|
||||
assert marker.is_file(), f"{marker} was deleted — foreign files are never touched"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Cancel + Escape → zero DELETEs, everything stays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_modal_cancel_and_esc_keep_everything(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""(a) Remove → modal → Cancel: the dialog closes, focus returns
|
||||
to the row's Remove button, **zero DELETE requests**, row + doc
|
||||
remain. (b) Re-open → Escape: the same (the keyboard cancel path —
|
||||
the request tracker stays empty through both)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_seed_git_row(KEPT_URL)
|
||||
_seed_doc(
|
||||
KEPT_REPO,
|
||||
"a.md",
|
||||
f"/nonexistent-but-fine/{KEPT_REPO}/a.md",
|
||||
"A",
|
||||
"Document that must survive both cancels.",
|
||||
)
|
||||
|
||||
deletes: list[str] = []
|
||||
page.on(
|
||||
"request",
|
||||
lambda r: deletes.append(r.url)
|
||||
if r.method == "DELETE" and "/api/git-sources/" in r.url
|
||||
else None,
|
||||
)
|
||||
|
||||
_admin_git_sources_page(page, app_url)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
assert _docs(page, app_url) == [(KEPT_REPO, "a.md")]
|
||||
|
||||
# (a) Cancel click — no request, focus back on the trigger.
|
||||
_open_remove_modal(page, KEPT_URL)
|
||||
page.locator("#remove-confirm-cancel").click()
|
||||
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
|
||||
# Focus returned to the row's Remove button (the trigger)…
|
||||
assert page.evaluate(
|
||||
"() => document.activeElement.classList.contains('git-source-remove')"
|
||||
), "focus did not return to the row's Remove button"
|
||||
# Let the page settle (a cancel sends nothing) before the
|
||||
# "zero DELETEs" claim.
|
||||
page.wait_for_timeout(500)
|
||||
assert deletes == [], f"cancel sent a request: {deletes}"
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
assert _docs(page, app_url) == [(KEPT_REPO, "a.md")]
|
||||
|
||||
# (b) Escape — the keyboard cancel path, same guarantees.
|
||||
_open_remove_modal(page, KEPT_URL)
|
||||
page.keyboard.press("Escape")
|
||||
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
|
||||
assert page.evaluate(
|
||||
"() => document.activeElement.classList.contains('git-source-remove')"
|
||||
), "focus did not return to the row's Remove button after Escape"
|
||||
page.wait_for_timeout(500)
|
||||
assert deletes == [], f"Escape sent a request: {deletes}"
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
expect(_row(page, KEPT_URL)).to_have_count(1)
|
||||
assert _docs(page, app_url) == [(KEPT_REPO, "a.md")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Modal a11y (AGENTS.md rule 5) + no CDN (rule 6) + the success
|
||||
# announcer after a real removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_modal_a11y_and_no_cdn(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""The dialog's aria wiring (alertdialog + labelled + described),
|
||||
the 3px ``:focus-visible`` outline computable on BOTH buttons
|
||||
(keyboard focus), both buttons ≥44px tall, the success line on
|
||||
``#git-sources-announcer`` (role=status) after a real removal, and
|
||||
the page loading only same-origin resources (rule 6)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_seed_git_row(A11Y_URL)
|
||||
_seed_doc(
|
||||
A11Y_REPO,
|
||||
"a.md",
|
||||
f"/nonexistent-but-fine/{A11Y_REPO}/a.md",
|
||||
"A",
|
||||
"Document removed by the a11y test's successful removal.",
|
||||
)
|
||||
|
||||
_admin_git_sources_page(page, app_url)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
|
||||
# The static dialog wiring (the markup ships with the page).
|
||||
dialog = page.locator("#remove-confirm-dialog")
|
||||
assert dialog.get_attribute("role") == "alertdialog"
|
||||
assert dialog.get_attribute("aria-modal") == "true"
|
||||
assert dialog.get_attribute("aria-labelledby") == "remove-confirm-title"
|
||||
assert dialog.get_attribute("aria-describedby") == "remove-confirm-copy"
|
||||
expect(page.locator("#remove-confirm-title")).to_have_text("Remove this source?")
|
||||
assert page.locator("#remove-confirm-error").get_attribute("role") == "alert"
|
||||
expect(page.locator("#remove-confirm-error")).to_be_hidden()
|
||||
expect(page.locator("#remove-confirm-copy")).to_contain_text("permanently removes")
|
||||
|
||||
# Open: focus lands on Cancel (the safe default)…
|
||||
_row(page, A11Y_URL).locator(".git-source-remove").click()
|
||||
expect(dialog).to_be_visible(timeout=15_000)
|
||||
assert page.evaluate("() => document.activeElement.id") == "remove-confirm-cancel"
|
||||
|
||||
# …and KEYBOARD focus moves between the two buttons, each drawing
|
||||
# the house 3px :focus-visible outline (Tab Cancel→Remove,
|
||||
# Shift+Tab back).
|
||||
page.keyboard.press("Tab")
|
||||
assert page.evaluate("() => document.activeElement.id") == "remove-confirm-remove"
|
||||
outline = page.evaluate(
|
||||
"() => getComputedStyle(document.activeElement).outlineWidth"
|
||||
)
|
||||
assert outline == "3px", f"focus-visible outline missing on Remove: {outline!r}"
|
||||
page.keyboard.press("Shift+Tab")
|
||||
assert page.evaluate("() => document.activeElement.id") == "remove-confirm-cancel"
|
||||
outline = page.evaluate(
|
||||
"() => getComputedStyle(document.activeElement).outlineWidth"
|
||||
)
|
||||
assert outline == "3px", f"focus-visible outline missing on Cancel: {outline!r}"
|
||||
|
||||
# Touch targets ≥44px (both buttons).
|
||||
for btn in ("#remove-confirm-cancel", "#remove-confirm-remove"):
|
||||
box = page.locator(btn).bounding_box()
|
||||
assert box is not None and box["height"] >= 44, f"target too small: {box}"
|
||||
|
||||
# A real removal: the success line lands in the announcer
|
||||
# (role=status, aria-live=polite).
|
||||
page.locator("#remove-confirm-remove").click()
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
|
||||
expect(dialog).to_be_hidden()
|
||||
announcer = page.locator("#git-sources-announcer")
|
||||
assert announcer.get_attribute("role") == "status"
|
||||
assert announcer.get_attribute("aria-live") == "polite"
|
||||
expect(announcer).to_have_text(REMOVAL_ANNOUNCE)
|
||||
assert _stored_sources(page, app_url) == []
|
||||
assert _docs(page, app_url) == []
|
||||
|
||||
# No CDN (rule 6): no https:// asset tags; every script/link ref is
|
||||
# same-origin or a data: URI (the test_git_sources_admin.py pin).
|
||||
html = page.content()
|
||||
assert 'src="https://' not in html and 'href="https://' not in html
|
||||
refs = page.evaluate(
|
||||
"""() => [...document.querySelectorAll("script[src], link[href]")]
|
||||
.map((el) => el.src || el.href)"""
|
||||
)
|
||||
assert refs, "expected local asset references"
|
||||
for ref in refs:
|
||||
assert ref.startswith(app_url) or ref.startswith("data:"), (
|
||||
f"non-local asset reference: {ref}"
|
||||
)
|
||||
Reference in New Issue
Block a user