Files
brain-of-reese/tests/e2e/test_sync_changed_only.py
T
ducoterra edf6048a0d
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 150_drop_sync_result
**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass.

- Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP.
- Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests).
- Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`).
- Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed.
- Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched.
- Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
2026-10-02 02:35:03 -04:00

412 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,
)
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(
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)"