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

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

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

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

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

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

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

413 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 147 E2E (Playwright): only what changed is synced.
Source: ``TODO.md:4`` — "Local sources, like PDFs, are synced every
time even if they haven't changed. This results in really long sync
times for PDFs that are mostly pictoral. Only sync documents if they've
changed since the last sync (hash the document contents to check)."
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_changed_only.py -v --no-cov
End-to-end proof through the real UI + the real sync path (a real
``git clone`` of a local ``file://`` fixture repo, a real import
against the mock LLM): the SECOND sync of an unchanged source reports
everything unchanged and touches NO document row (``indexed_at`` /
``content_hash`` / ``file_hash`` all stay still — a re-index would
bump ``indexed_at``); a source change syncs ONLY the changed part (new
file → ``1 added``; modified file → ``1 updated`` with only its own
row moving). NO PDF here — the unit matrix (task 02) carries the
vision-skip proof; this suite pins the WIRE (the owner's visible
``#sync-progress`` result line — phase 150: the box is the single sync
text surface) + the UI settle + the row-level proof, fast and
deterministic.
Per-module app env (the E2E conftest pattern, module-scoped): this
suite's app boots with ``BOR_GIT_SOURCES=file://<fixture repo>`` and
its own ``BOR_SOURCES_DIR`` on its own port (env-overridable, the
house port-constant pattern). The session app (no git sources) is
never started in this isolated run, so no port clash.
The three tests SHARE the settled state on purpose (one module-scoped
truncate, not per-test — the suite's point is the run 2 → run 3 →
run 4 sequence):
1. ``test_second_sync_unchanged`` — run 1 (clone + import) indexes
``N_DOCS`` documents and stamps every row's ``file_hash`` non-NULL;
run 2 (unchanged bytes) settles ``0 added · N unchanged`` and moves
NO row (the load-bearing assertion).
2. ``test_change_syncs_only_the_change`` — commit a NEW doc to the
fixture repo; run 3 settles ``1 added · N unchanged``; the new row
appears stamped, the pre-existing rows do not move.
3. ``test_changed_file_reindexes`` — modify the sentinel doc; run 4
settles ``0 added · 1 updated · N unchanged``; exactly the
sentinel row moves (``indexed_at`` bumped, ``content_hash`` and
``file_hash`` re-stamped).
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import select, text
from app.db import SessionLocal
from app.models import Document
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]
# The house port-constant pattern (module app, env-overridable) — the
# session app (8123) is never started in this isolated run.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCCHANGED", "8157"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The fixture repo's directory name — the source name the import
# records for it (the repo_name basename rule, phase 28).
GIT_SOURCE_NAME = "changed-notes"
# The three importable fixture docs (task 03: a few importable docs,
# NO PDF — the unit matrix covers the vision-skip proof).
SENTINEL_DOC = "notes/sync-fixture.md"
SECOND_DOC = "notes/second-note.md"
THIRD_DOC = "notes/third-note.md"
#: The doc test 2 commits AFTER run 2 (``1 added``).
NEW_DOC = "notes/new-note.md"
N_DOCS = 3
#: "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/pull + import against the mock LLM (+ the KB
#: overview regeneration after a KB-changing run) — generous budget,
#: no client timeout (the test_sync_button.py pattern).
SYNC_TIMEOUT_MS = 90_000
#: One document row's row-level proof snapshot (phase 147: the
#: ``file_hash`` stamp joins the pre-phase pair).
DocRow = tuple[datetime | None, str | None, str | None]
def _git(cwd: Path, *args: str) -> None:
"""Run git in *cwd*; a non-zero exit fails the fixture/test 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 _commit(cwd: Path, message: str) -> None:
"""The house fixture commit — identity inline, never signed."""
_git(
cwd,
"-c", "user.email=e@x", "-c", "user.name=t",
"-c", "commit.gpgsign=false", # the fixture commits never sign
"commit", "-qm", message,
)
@pytest.fixture(scope="module")
def sync_git_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""A real one-commit git repo with ``N_DOCS`` importable docs that
the sync must clone (the test_sync_button.py recipe, module-safe
``tmp_path_factory`` + real ``git`` subprocess calls)."""
root = tmp_path_factory.mktemp("sync_changed_git")
repo = root / GIT_SOURCE_NAME
(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 changed-only sync\n"
"end to end: a real git clone, a real import, an idempotent\n"
"second run — and the sentinel test 3 modifies.\n"
"\n"
"Marker: RESE-SYNC-CHANGED-SENTINEL-7f41\n",
encoding="utf-8",
)
(repo / "notes" / "second-note.md").write_text(
"# Second note\n"
"\n"
"A second unchanged doc — the sync's unchanged count is over\n"
"the whole source, so it must stay still through runs 2–4.\n"
"\n"
"Marker: RESE-SECOND-NOTE-5c0b\n",
encoding="utf-8",
)
(repo / "notes" / "third-note.md").write_text(
"# Third note\n"
"\n"
"A third unchanged doc (rounds out the source — the settle\n"
"line's unchanged count is the KB size, not a singleton).\n"
"\n"
"Marker: RESE-THIRD-NOTE-91de\n",
encoding="utf-8",
)
_git(repo, "init", "-q")
_git(repo, "add", "-A")
_commit(repo, "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 147: the sync'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
@pytest.fixture(scope="module", autouse=True)
def _clean_kb_once(app_url: str) -> Iterator[None]:
"""Fresh KB ONCE per module — deliberately NOT per test: the suite's
point is the run 2 → run 3 → run 4 sequence, and tests 2–3 build on
the settled state test 1 leaves behind. 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."""
body = httpx.get(f"{app_url}/api/health", timeout=5).json()
if body["db"] != "up":
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources")
)
db.commit()
yield
def _doc_rows() -> dict[str, DocRow]:
"""Every document row of the fixture source, keyed by path — the
row-level proof (the test_sync_button.py DB-assertion idiom,
extended with the phase-147 ``file_hash`` stamp)."""
with SessionLocal() as db:
docs = db.execute(
select(Document).where(Document.source == GIT_SOURCE_NAME)
).scalars().all()
return {d.path: (d.indexed_at, d.content_hash, d.file_hash) for d in docs}
def _click_sync_and_wait_settle(page: Page) -> None:
"""Click the admin sync button and wait for the settle — the
"Syncing…" → "Synced HH:MM" transition with the button re-enabled
at the terminal state (§7.4 never-stale, the test_sync_button.py
idiom)."""
btn = page.locator("#sync-btn")
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-label")).to_have_text(
SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS
)
expect(btn).to_be_enabled()
def _expect_settle_line(page: Page, expected: str) -> None:
"""The owner's surface: the VISIBLE ``#sync-progress`` box text in
its house count shape (sources.js's fmtSyncResult — the zero
"updated" part is omitted: ``0 added · N unchanged``,
``1 added · N unchanged``, ``0 added · 1 updated · N unchanged``).
Phase 150: the box IS the result line's surface (the retired
announcer span is gone)."""
expect(page.locator("#sync-progress-text")).to_have_text(
counts_line(expected), timeout=SYNC_TIMEOUT_MS
)
def _expect_rows_moved(
before: dict[str, DocRow], after: dict[str, DocRow], *, moved: set[str]
) -> None:
"""Exactly the rows in *moved* may differ — every other row's
``indexed_at``/``content_hash``/``file_hash`` is byte-still (a
re-index would bump ``indexed_at``; the changed-only sync must not
leak into neighbors). New rows may appear only within *moved*; the
suite never expects a prune."""
gained = set(after) - set(before)
assert gained <= moved, f"unexpected rows added: {sorted(gained)}"
lost = set(before) - set(after)
assert not lost, f"unexpected rows pruned: {sorted(lost)}"
for path, snapshot in before.items():
if path in moved:
continue
assert after[path] == snapshot, (
f"{path} moved though only {sorted(moved)} may move: "
f"{snapshot} -> {after[path]}"
)
# --- 1. Run 1 stamps, run 2 re-indexes nothing -----------------------------
def test_second_sync_unchanged(page: Page, app_url: str, db_ready: None) -> None:
"""Run 1 (the real clone + import) indexes ``N_DOCS`` docs and
stamps EVERY row's ``file_hash`` non-NULL (the first sync's job);
run 2 (unchanged bytes) settles ``0 added · N unchanged`` and
touches NO document row — ``indexed_at``/``content_hash``/
``file_hash`` all still (the load-bearing assertion: a re-index
would bump ``indexed_at`` to now and the owner's long syncs would
be back)."""
login(page, app_url) # lands on /sources.html (the button's home)
# --- run 1: clone + import (everything is new) -----------------------
_click_sync_and_wait_settle(page)
_expect_settle_line(page, f"{N_DOCS} added")
after_run1 = _doc_rows()
assert set(after_run1) == {SENTINEL_DOC, SECOND_DOC, THIRD_DOC}, after_run1
for path, (indexed_at, content_hash, file_hash) in after_run1.items():
assert indexed_at is not None and content_hash, path
# The first sync stamped the hash on every row (the fast
# path's precondition — NULL would mean run 2 re-indexed).
assert file_hash is not None and len(file_hash) == 64, (
f"{path} must carry a non-NULL file_hash after run 1"
)
# --- run 2: unchanged bytes → zero re-index --------------------------
_click_sync_and_wait_settle(page)
_expect_settle_line(page, f"0 added · {N_DOCS} unchanged")
after_run2 = _doc_rows()
_expect_rows_moved(after_run1, after_run2, moved=set())
# --- 2. A new file syncs as `1 added`, nothing else moves ------------------
def test_change_syncs_only_the_change(
page: Page, app_url: str, db_ready: None, sync_git_repo: Path
) -> None:
"""From the settled state (test 1), commit a NEW doc to the fixture
repo's working tree: run 3 syncs ONLY the change — the settle line
reads ``1 added · N unchanged`` (the unchanged count is still run
1's doc count), the new row exists stamped, and the pre-existing
rows' ``indexed_at``/``content_hash`` are untouched."""
login(page, app_url)
before = _doc_rows()
assert len(before) == N_DOCS, "the settled state from test 1 is required"
(sync_git_repo / "notes" / "new-note.md").write_text(
"# New note\n"
"\n"
"Committed after run 2 — only this file may move in run 3.\n"
"\n"
"Marker: RESE-NEW-DOC-3d8a\n",
encoding="utf-8",
)
_git(sync_git_repo, "add", "-A")
_commit(sync_git_repo, "add new-note")
_click_sync_and_wait_settle(page)
_expect_settle_line(page, f"1 added · {N_DOCS} unchanged")
after = _doc_rows()
assert set(after) - set(before) == {NEW_DOC}, after.keys()
_, new_content_hash, new_file_hash = after[NEW_DOC]
assert new_content_hash, "the added row must carry its content hash"
assert new_file_hash is not None and len(new_file_hash) == 64, (
"the added row must carry its file_hash stamp"
)
# The pre-existing rows are byte-still (the added file must not
# drag them into a re-index).
_expect_rows_moved(before, after, moved={NEW_DOC})
# --- 3. A modified file syncs as `1 updated`, only its own row -------------
def test_changed_file_reindexes(
page: Page, app_url: str, db_ready: None, sync_git_repo: Path
) -> None:
"""Modify the fixture sentinel doc (append a line) + commit: run 4
syncs ONLY the change — the settle line reads
``0 added · 1 updated · N unchanged`` (N = the run-1 count — the
test-2 doc is unchanged too), and EXACTLY the sentinel row moves:
its ``indexed_at`` is bumped, its ``content_hash`` and
``file_hash`` re-stamped; every other row is byte-still."""
login(page, app_url)
before = _doc_rows()
assert len(before) == N_DOCS + 1, "the settled state from test 2 is required"
sentinel = sync_git_repo / "notes" / "sync-fixture.md"
sentinel.write_text(
sentinel.read_text(encoding="utf-8")
+ "\nAmended: the sentinel was touched — exactly this row may move.\n",
encoding="utf-8",
)
_git(sync_git_repo, "add", "-A")
_commit(sync_git_repo, "modify sentinel")
_click_sync_and_wait_settle(page)
_expect_settle_line(page, f"0 added · 1 updated · {N_DOCS} unchanged")
after = _doc_rows()
_expect_rows_moved(before, after, moved={SENTINEL_DOC})
old_indexed_at, old_content_hash, old_file_hash = before[SENTINEL_DOC]
new_indexed_at, new_content_hash, new_file_hash = after[SENTINEL_DOC]
assert new_content_hash != old_content_hash, (
"the content changed — content_hash must be re-stamped"
)
assert new_file_hash != old_file_hash, (
"the bytes changed — file_hash must be re-stamped"
)
assert (
old_indexed_at is not None
and new_indexed_at is not None
and new_indexed_at > old_indexed_at
), "the re-index must bump indexed_at (run 1 stamped it; run 4 re-indexed)"