**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`.
328 lines
13 KiB
Python
328 lines
13 KiB
Python
"""Phase 32 E2E (Playwright): the admin-only "Sync sources" button.
|
|
|
|
Story: ``.agents/user_stories/admin-sync-button.md``
|
|
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_button.py -v --no-cov
|
|
|
|
The story gate runs the **real** sync path end to end — a real
|
|
``git clone`` of a local ``file://`` fixture repo (deterministic, no
|
|
network), a real import against the mock LLM, a real KB-overview
|
|
regeneration — plus the admin-only visibility and the full button
|
|
lifecycle (§7.4: "Syncing…" → "Synced HH:MM" + counts, the idempotent
|
|
second run, the 409 double trigger). Phase 149 (task 01): the
|
|
RUNNING-state pins (the 202's "Syncing…") ride the detail box
|
|
(``#sync-progress`` — the running label left the pill; the button is
|
|
the spinner), while every SETTLED pin (the idle "Sync sources", the
|
|
"Synced HH:MM" label + counts line) is unchanged.
|
|
|
|
Per-module app env (the E2E conftest pattern, module-scoped): this
|
|
story's app boots with ``BOR_GIT_SOURCES=file://<fixture repo>`` and
|
|
its own ``BOR_SOURCES_DIR`` — the session app (no git sources) is
|
|
never started in this isolated run, so no port clash.
|
|
|
|
Test → story mapping (Playwright Mapping Rule):
|
|
1. ``test_anonymous_sees_no_button`` → AC 1 + 3 (hidden button, 403s)
|
|
2. ``test_admin_sync_lifecycle`` → AC 2 + 4 (full lifecycle + idempotent
|
|
second run + the fresh ``kb_overview`` row)
|
|
3. ``test_double_trigger_409`` → AC 2 (one sync at a time)
|
|
"""
|
|
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 pytest
|
|
from playwright.sync_api import Page, expect
|
|
from sqlalchemy import text
|
|
|
|
from app.db import SessionLocal
|
|
from app.models import KbOverview
|
|
from e2e.auth_helpers import login
|
|
from e2e.conftest import (
|
|
ADMIN_PASSWORD,
|
|
SESSION_SECRET,
|
|
USE_REAL_LLM,
|
|
_wait_http,
|
|
)
|
|
from e2e.sync_helpers import 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_SYNCBTN", "8132"))
|
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
|
|
|
#: The unique sentinel inside the fixture doc (task 03 step 1) — its
|
|
#: import into the Sources table proves the REAL clone was indexed.
|
|
SENTINEL = "RESE-SYNC-SENTINEL-9b2c"
|
|
FIXTURE_DOC = "notes/sync-fixture.md"
|
|
|
|
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
|
#: fmtSyncTime), any hour/minute.
|
|
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
|
|
|
|
#: Real git clone + embed against the mock LLM — generous budget
|
|
#: (task 03: the sync can legitimately take a while, no client timeout).
|
|
SYNC_TIMEOUT_MS = 60_000
|
|
|
|
|
|
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()}")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def sync_git_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""A real one-commit git repo the sync must clone (task 03 step 1).
|
|
|
|
``tmp_path`` is function-scoped while the module-scoped app fixture
|
|
needs the repo for the module's lifetime, so it is built under
|
|
``tmp_path_factory`` (the same pytest-managed temp area, module-safe)
|
|
via real ``git`` subprocess calls.
|
|
"""
|
|
root = tmp_path_factory.mktemp("sync_git")
|
|
repo = root / "homelab-notes"
|
|
(repo / "notes").mkdir(parents=True)
|
|
(repo / "notes" / "sync-fixture.md").write_text(
|
|
"# Sync fixture note\n"
|
|
"\n"
|
|
"One small note that exists only to prove the admin sync button\n"
|
|
"end to end: a real git clone, a real import, a real KB overview\n"
|
|
"regeneration.\n"
|
|
"\n"
|
|
f"Marker: {SENTINEL}\n",
|
|
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 app_server(mock_llm: int, sync_git_repo: Path) -> Iterator[str]:
|
|
"""The real app under test — per-module env: the sync's subject is a
|
|
real ``file://`` git source with its own checkout dir (the conftest
|
|
session app boots without ``BOR_GIT_SOURCES`` and is never started
|
|
in this isolated run)."""
|
|
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) — keeps every story
|
|
# 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
|
|
# Phase 32: the sync button's subject — one real local repo.
|
|
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_kb() -> None:
|
|
"""Fresh KB per test (the E2E isolation pattern): the sync's counts
|
|
and the ``kb_overview`` row must be the sync's own doing."""
|
|
with SessionLocal() as db:
|
|
# git_sources too: the shared Postgres may carry leftover
|
|
# registry rows from other suites, and effective_sources prefers
|
|
# the DB list over this app's BOR_GIT_SOURCES env fallback.
|
|
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_kb(db_ready: None) -> Iterator[None]:
|
|
_truncate_kb()
|
|
yield
|
|
|
|
|
|
def _overview_row() -> KbOverview | None:
|
|
with SessionLocal() as db:
|
|
return db.get(KbOverview, 1)
|
|
|
|
|
|
# The git fixture repo's directory name — the source name the import
|
|
# records for it (the repo_name basename rule, phase 28).
|
|
GIT_SOURCE_NAME = "homelab-notes"
|
|
|
|
|
|
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 imported — the fixture path is listed. Phase
|
|
97: the catalog is the drill-down tree, so drill source → folder
|
|
``notes`` first (the flat all-docs row is the same row, one level
|
|
deeper)."""
|
|
_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)
|
|
|
|
|
|
def _wait_sync_done(page: Page, app_url: str, timeout_s: float = 60.0) -> dict[str, Any]:
|
|
"""Poll the (cookie-authenticated) status endpoint until the run
|
|
reaches a terminal state — exactly what the UI's 2 s poll loop
|
|
observes."""
|
|
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
|
|
body = r.json()
|
|
if body["state"] in ("success", "failed"):
|
|
return body
|
|
time.sleep(0.5)
|
|
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
|
|
|
|
|
# --- 1. Anonymous: no button, locked endpoints ----------------------------
|
|
|
|
|
|
def test_anonymous_sees_no_button(page: Page, app_url: str, db_ready: None) -> None:
|
|
"""AC 1 + 3 — anonymous: ``#sync-btn`` never leaves ``hidden`` (the
|
|
whoami reveal is admin-only, so for a logged-out visitor it is
|
|
absent from the *revealed* DOM) and both sync endpoints answer
|
|
403 — the admin-only surface (A10 extended, phase 16 pattern)."""
|
|
page.goto(f"{app_url}/sources.html")
|
|
expect(page.locator("#sync-btn")).to_be_hidden()
|
|
# The same whoami gate that hides the button 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").status == 403
|
|
|
|
|
|
# --- 2. Admin: the full lifecycle against the real sync path --------------
|
|
|
|
|
|
def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
|
"""AC 2 + 4 — click → "Syncing…" (disabled; phase 149: the
|
|
running label is in the detail box — the button is the spinner)
|
|
→ "Synced HH:MM" + the counts, against the REAL pipeline (git
|
|
clone of the ``file://`` fixture → import with prune → KB overview
|
|
regeneration, mock LLM). Then the idempotent second run:
|
|
fast-forward pull + sha256 hash skip → "0 added · 1 unchanged"."""
|
|
login(page, app_url) # lands on /sources.html (the button's home)
|
|
|
|
btn = page.locator("#sync-btn")
|
|
expect(btn).to_be_visible()
|
|
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
|
|
|
# --- run 1: clone + import + overview --------------------------------
|
|
btn.click()
|
|
expect(btn).to_be_disabled()
|
|
# Phase 149 (task 01): the running detail rides the box under the
|
|
# button row (the button is the spinner) — the 202's bare label.
|
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
|
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
|
|
# The fixture doc (one A9-format file) is the only change — the
|
|
# settle's counts line rides the box (phase 150: the box is the
|
|
# sole sync text surface).
|
|
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("1 added"))
|
|
|
|
# The REAL clone was imported: the fixture path is in the Sources
|
|
# table (the sentinel lives inside it) — via the drill-down tree
|
|
# (phase 97).
|
|
_expect_fixture_row(page)
|
|
|
|
# Phase-31 regeneration ran (the import changed the KB): the single
|
|
# kb_overview row is fresh and non-empty (DB check — truncated
|
|
# before this test, so it is the sync's own doing).
|
|
row = _overview_row()
|
|
assert row is not None and row.content.strip(), (
|
|
"kb_overview must be regenerated by a successful, KB-changing sync"
|
|
)
|
|
|
|
# --- run 2: idempotent pull + hash skip -------------------------------
|
|
btn.click()
|
|
expect(btn).to_be_disabled()
|
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
|
expect(btn).to_be_enabled()
|
|
# Nothing re-embedded (sha256 delta) — the no-op run announces the
|
|
# unchanged count instead of an empty live region (the box keeps
|
|
# its result line — phase 150).
|
|
expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 unchanged"))
|
|
# The doc survived the prune re-import (its file is still in the
|
|
# repo) — via the drill-down tree (phase 97).
|
|
_expect_fixture_row(page)
|
|
|
|
|
|
# --- 3. Concurrency: one sync at a time ------------------------------------
|
|
|
|
|
|
def test_double_trigger_409(page: Page, app_url: str, db_ready: None) -> None:
|
|
"""AC 2 — a second trigger while a run is in flight gets 409
|
|
("a sync is already running"); the UI adopts the in-flight run
|
|
instead of starting a second one. The E2E path adds that the REAL
|
|
run behind the 409 still completes (the adopted run is the one and
|
|
only run)."""
|
|
login(page, app_url)
|
|
|
|
first = page.request.post(f"{app_url}/api/sync")
|
|
assert first.status == 202, first.text
|
|
second = page.request.post(f"{app_url}/api/sync")
|
|
assert second.status == 409, second.text
|
|
assert second.json()["detail"] == "a sync is already running"
|
|
|
|
# The adopted (single) run still completes successfully.
|
|
body = _wait_sync_done(page, app_url)
|
|
assert body["state"] == "success", body
|
|
assert body["detail"]["added"] == 1 # the fixture doc, fresh after the truncate
|