"""Phase 127 task 04 E2E (Playwright, mock-only): a one-file change rewrites ONLY that folder's summary chain. The dedicated story suite for ``127_sync_incremental_folder_summaries`` (A16 — one Playwright file per phase, run in isolation): the owner's report was that "on sync brain of reese rewrites all the summaries for every directory — so even if just one file changed this can result in 700+ directory summary rewrites in production which can take a very long time" (``TODO.md`` L3). The fix under test: a KB-changing sync passes the import's changed-document set (``ImportSummary.changed_paths``) into ``generate_folder_summaries(changed_docs=…)``, so ONLY the folders a changed document touches — each document's folder chain up to its source root (``affected_folder_keys``) — are re-stamped; every other stored row keeps its summary text AND ``updated_at`` byte-identical. Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_incremental_folder_summaries.py -v --no-cov MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the deterministic mock (the phase-94 ``FOLDER_SUMMARY_MODE`` canned ``Fixture folder summary for .`` lines): the stored rows are a pure function of the request, so the byte-identical assertions are exact only against the mock. No slow-LLM proxy: the assertions are on the POST-terminal DB state (the rows' ``summary`` AND ``updated_at``) plus the terminal status (the keep-final-counts convention — the terminal keeps ``summaries_done == summaries_total == ``), never on in-flight sampling, so the fast mock is dense enough by construction. KB fixture — one host temp dir (``tmp_path_factory``; the app runs on the same host) registered as a ``kind=local`` source (the ``test_local_directory_sources.py`` / phase-94 API-registration pattern; no git anywhere): ``incsum/`` with FOUR ≥ 2-doc folders — ``alpha/``, ``bravo/``, ``charlie/``, ``delta/`` (2 docs each) — plus ONE root-level doc (9 documents total). The generator's candidate set is therefore exactly 5: the source root (``folder_path ""``) + the four folders (the recursive-subtree ≥ 1 rule). Autouse cleanup (the phase-96 pattern): before each test wait for no running sync on the module app, then truncate the shared registry + KB tables (documents/chunks/query_log/steering_notes/kb_overview/ git_sources/folder_summaries) — each test seeds its own source and runs its own sync(s); after each test the same truncate. (The temp tree itself persists for the module; the tests' disk mutations are ordered by test definition and each test re-syncs the state it finds.) Test → observable mapping (Playwright Mapping Rule): 1. ``test_first_sync_generates_all_rows`` — a first (full) sync settles ``success``; the ``folder_summaries`` table holds EXACTLY the 5 rows (source root + the four folders) with the mock's canned text naming each folder, each ``updated_at`` non-null, and the terminal keeps ``summaries_done == summaries_total == 5`` (the full regeneration — a changed KB with no prior rows, the pre-127 behavior preserved end to end). 2. ``test_one_file_change_restamps_only_that_folders_chain`` — re-seed (autouse truncated) + sync #1, capture every row's ``(summary, updated_at)``; change ONE doc inside ``alpha/`` and sync #2 settles ``success``: the ``alpha`` AND the source-root rows carry a NEW ``updated_at`` (strictly after the captured stamp) with the canned text, while the ``bravo``/``charlie``/``delta`` rows are BYTE-IDENTICAL (same ``summary`` AND same ``updated_at``); the terminal keeps ``summaries_done == summaries_total == 2`` (the AFFECTED count — alpha + root, NOT the table's candidate count — the keep-final-counts convention) and the detail shows the single-file change (1 updated, 8 unchanged, 0 added/pruned). 3. ``test_pruned_file_restamps_only_its_chain_and_prunes_the_vanished_ folder`` — re-seed + sync #1, capture; delete ``bravo/``'s last docs (the folder VANISHES — 0 documents, no longer a candidate) and change one doc in ``charlie/``; the sync settles ``success``: the ``bravo`` row is PRUNED (gone — the prune pass owns vanished folders' rows), the source-root AND ``charlie`` rows are re-stamped (new ``updated_at`` + canned text), the ``alpha``/``delta`` rows are byte-identical, and the terminal keeps ``summaries_done == summaries_total == 2`` (the affected CANDIDATE count — charlie + root; bravo is no longer a candidate). """ from __future__ import annotations import json import os import subprocess import sys import time from collections.abc import Iterator from datetime import datetime from pathlib import Path from typing import Any import httpx import pytest from sqlalchemy import select, text from app.config import Settings as _Settings from app.db import SessionLocal from app.models import FolderSummary from e2e.conftest import ( ADMIN_PASSWORD, 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's 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_INCSUM", "8147")) APP_URL = f"http://127.0.0.1:{APP_PORT}" # -------------------------------------------------------------------------- # Fixture constants (deterministic, token-controlled) # -------------------------------------------------------------------------- #: The local source — the temp directory's basename (``kind=local`` → #: the directory's basename is the source name, phase 38). SOURCE = "incsum" #: The four ≥ 2-doc folders (path order). FOLDER_A = "alpha" FOLDER_B = "bravo" FOLDER_C = "charlie" FOLDER_D = "delta" FOLDERS = (FOLDER_A, FOLDER_B, FOLDER_C, FOLDER_D) ROOT_NOTE = "root-note.md" # the one root-level doc N_FILES = 9 # four folders × 2 docs + the root-level doc N_CANDIDATES = 5 # the source root + the four folders (the ≥ 1-doc rule) #: The mock's byte-stable ``FOLDER_SUMMARY_MODE`` lines for this fixture #: (the phase-94 template — the label is the ``FOLDER_HEADER_PREFIX`` #: tail: ```` for the root, ``/`` for a folder). SUMMARY_FOR = "Fixture folder summary for {}." EXPECTED: dict[str, str] = { "": SUMMARY_FOR.format(SOURCE), FOLDER_A: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_A}"), FOLDER_B: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_B}"), FOLDER_C: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_C}"), FOLDER_D: SUMMARY_FOR.format(f"{SOURCE}/{FOLDER_D}"), } #: The single-file change (test 2) — a revised body whose hash differs #: from the fixture's (the importer's sha256 delta reports ``updated``) #: and which no other fixture text contains. ALPHA_A_REVISED = ( "Incsum alpha fixture note A, REVISED: the only file test 2 changed " "— the incremental regeneration must touch exactly this file's chain " "(alpha + the source root), and nothing else. (RESE- INCSUM-01)" ) #: The charlie change (test 3) — the same shape on the other side. CHARLIE_A_REVISED = ( "Incsum charlie fixture note A, REVISED: the one changed file of " "test 3's sync, while bravo vanishes entirely. (RESE- INCSUM-02)" ) def _md(title: str, body: str) -> str: return f"# {title}\n\n{body}\n" # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def incsum_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: """The one-source temp tree (see the module docstring): the app server runs on the same host, so the paths are visible to it. The directory NAME is the source name (``kind=local``, phase 38).""" root = tmp_path_factory.mktemp("bor_incsum") src = root / SOURCE for folder in FOLDERS: (src / folder).mkdir(parents=True) for letter in ("a", "b"): (src / folder / f"{folder}-{letter}.md").write_text( _md( f"Incsum {folder.title()} {letter.upper()}", f"Incsum {folder} fixture note {letter.upper()}: covers " f"topic {letter.upper()} of the {SOURCE} source tree.", ), encoding="utf-8", ) (src / ROOT_NOTE).write_text( _md( "Incsum Root Note", f"This file sits directly under the {SOURCE} source, not in " "any folder.", ), encoding="utf-8", ) assert len(list(src.rglob("*.md"))) == N_FILES return src def _app_env( base_url: str, tmp_path_factory: pytest.TempPathFactory ) -> dict[str, str]: """The per-module app env (the conftest pattern, cf. ``test_kb_tree.py`` / ``test_sync_summary_visibility.py``): the ``base_url`` is the mock LLM directly (the fast leg is the only leg — see the module docstring), ``BOR_GIT_SOURCES`` forced empty (the sync source is this suite's own DB-registered local row), and the leak-guarded code defaults.""" 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 base_url ) # Mock-calibrated threshold (conftest pattern): this suite never # asks the chat model anything — the gate is never on a path. env["BOR_RELEVANCE_THRESHOLD"] = "0.30" # Phase 67: instant retry waits + the code-default budget (the # conftest leak-guard pattern). env["BOR_LLM_RETRY_DELAY"] = "0" env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default) 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 repo's .env file carries the owner's BOR_GIT_SOURCES (the app # reads it from cwd) — override it with an EMPTY value (the env var # beats the .env file): the registry must hold EXACTLY the local # directory this suite registers. env["BOR_GIT_SOURCES"] = "" # Leak guards (conftest pattern): an operator's local (gitignored) # .env cannot leak corpus-specific settings into the app under test. env["BOR_DOCS_REPO"] = "" env["BOR_SUGGESTIONS"] = json.dumps( _Settings.model_fields["suggestions"].default ) env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts")) return env @pytest.fixture(scope="module") def app_server( mock_llm: int, incsum_dir: Path, tmp_path_factory: pytest.TempPathFactory, ) -> Iterator[str]: """The app under test (all three tests): ``BOR_LLM_BASE_URL`` is the mock directly — the canned ``FOLDER_SUMMARY_MODE`` lines are byte-stable there, and the byte-identity assertions key on that exact text (no slow proxy: the assertions are post-terminal, see the module docstring). ``incsum_dir`` is a dependency only for the fixture ordering (the temp tree exists before the app boots — the sync reads it).""" env = _app_env(f"http://127.0.0.1:{mock_llm}/v1", tmp_path_factory) 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 E2E suites share one Postgres, so a leftover git_sources row or document would pollute the candidate count and the counts the syncs and the folder-rows assertions pin exactly.""" 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(base_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=base_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: {base_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 — each test seeds its own source and runs its own sync(s), so the KB rows + folder_summaries rows of the temp source are exactly this test's own. 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() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _register_source(base_url: str, path: str) -> None: """Register the temp directory through the authenticated API (the ``test_local_directory_sources.py`` / phase-94 pattern — a plain directory is an API/DB-only operation).""" with httpx.Client(base_url=base_url, timeout=30.0) as client: r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, r.text r = client.post( "/api/git-sources", json={"kind": "local", "path": path} ) assert r.status_code == 201, r.text def _run_sync(base_url: str, timeout_s: float = 120.0) -> dict[str, Any]: """Login + ``POST /api/sync`` + poll the status endpoint until THIS run reaches a terminal state (the ``test_local_directory_sources`` pattern, over plain httpx — with the stale-terminal freshness gate: the module app is shared, so the PREVIOUS run's terminal lives in the app's memory until this run supersedes it; a terminal is accepted only after a running tick was observed or its ``started_at`` post-dates this POST).""" with httpx.Client(base_url=base_url, timeout=30.0) as client: r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, r.text t0 = time.time() r = client.post("/api/sync") assert r.status_code == 202, r.text saw_running = False deadline = time.monotonic() + timeout_s body: dict[str, Any] = {} while time.monotonic() < deadline: r = client.get("/api/sync/status") assert r.status_code == 200, r.text body = r.json() state = body["state"] if state == "running": saw_running = True elif state in ("success", "failed") and ( saw_running or _started_at(body) >= t0 - 1.0 # same-host clock; 1 s tolerance ): return body time.sleep(0.1) raise AssertionError(f"sync did not reach a terminal state: {body}") def _started_at(body: dict[str, Any]) -> float: """The status body's ``started_at`` as a wall-clock epoch (the server stamps it in UTC; this process runs on the same host — same clock, the phase-64 recorder's freshness convention).""" started = body.get("started_at") if not started: return 0.0 try: return datetime.fromisoformat(str(started)).timestamp() except ValueError: return 0.0 def _folder_rows() -> dict[str, tuple[str, bool, datetime]]: """The source's stored folder summaries ``{folder_path: (summary, manually_edited, updated_at)}`` (``""`` = the source root) — the test process's direct DB access (the E2E's other established lens).""" with SessionLocal() as db: rows = db.execute( select( FolderSummary.folder_path, FolderSummary.summary, FolderSummary.manually_edited, FolderSummary.updated_at, ).where(FolderSummary.source == SOURCE) ).all() return { folder: (summary, manually, updated) for folder, summary, manually, updated in rows } # --------------------------------------------------------------------------- # 1. The first sync (a changed KB with no prior rows) is the FULL # regeneration: exactly the 5 candidate rows, canned text, the # terminal keeps the full candidate count # --------------------------------------------------------------------------- def test_first_sync_generates_all_rows( app_url: str, incsum_dir: Path, db_ready: None ) -> None: """A first sync of the temp source settles ``success`` with all 9 docs added; the ``folder_summaries`` table holds EXACTLY the 5 candidate rows (the source root + the four folders — the recursive-subtree ≥ 1 rule) with the mock's canned text naming each folder, every row AI-written and freshly stamped, and the terminal keeps ``summaries_done == summaries_total == 5`` (the full-regeneration count — a first sync passes no prior rows, so this is the pre-127 behavior preserved end to end).""" _register_source(app_url, str(incsum_dir)) body = _run_sync(app_url) assert body["state"] == "success", body detail = body["detail"] assert detail["added"] == N_FILES, detail assert detail["updated"] == 0 and detail["unchanged"] == 0, detail assert detail["pruned"] == 0, detail assert detail["overview"] is True, detail # Terminal (the keep-final-counts convention): the FULL candidate # count — the pre-127 shape a first sync still reports. assert body["summaries_done"] == N_CANDIDATES, body assert body["summaries_total"] == N_CANDIDATES, body assert body["phase"] is None, body assert body["current_summary"] is None, body # The DB pins the full regeneration: exactly one AI row per # candidate, the deterministic canned line, a fresh stamp. rows = _folder_rows() assert set(rows) == {""} | set(FOLDERS), rows for folder, (summary, manually, updated_at) in rows.items(): assert summary == EXPECTED[folder], (folder, summary) assert manually is False, (folder, manually) assert updated_at is not None, (folder, updated_at) # --------------------------------------------------------------------------- # 2. ONE changed file in alpha/ → exactly alpha's chain (alpha + the # source root) is re-stamped; every other row is byte-identical # (text AND updated_at); the terminal keeps the AFFECTED count # --------------------------------------------------------------------------- def test_one_file_change_restamps_only_that_folders_chain( app_url: str, incsum_dir: Path, db_ready: None ) -> None: """Re-seed (the autouse truncate) + sync #1 (full), capturing every row's ``(summary, updated_at)``; then ONE doc inside ``alpha/`` changes content and sync #2 settles ``success``: the ``alpha`` AND the source-root rows carry a NEW ``updated_at`` (strictly after the captured stamp) with the canned text, while the ``bravo``/``charlie``/``delta`` rows are byte-identical — same ``summary`` AND same ``updated_at`` (an unaffected folder's documents, and hence its summary, are untouched — no re-stamp). The terminal keeps ``summaries_done == summaries_total == 2`` (the AFFECTED count — alpha + root, NOT the table's candidate count — the keep-final-counts convention), and the detail shows the single-file change.""" _register_source(app_url, str(incsum_dir)) body = _run_sync(app_url) assert body["state"] == "success", body assert body["detail"]["added"] == N_FILES, body["detail"] before = _folder_rows() assert set(before) == {""} | set(FOLDERS), before # The single-file change: ONE alpha doc gets new content (the # importer's sha256 delta reports ``updated`` — no add, no prune). target = incsum_dir / FOLDER_A / f"{FOLDER_A}-a.md" target.write_text( _md("Incsum Alpha A (revised)", ALPHA_A_REVISED), encoding="utf-8" ) body = _run_sync(app_url) assert body["state"] == "success", body detail = body["detail"] assert detail["added"] == 0, detail assert detail["updated"] == 1, detail assert detail["unchanged"] == N_FILES - 1, detail assert detail["pruned"] == 0, detail assert detail["overview"] is True, detail # Terminal (the keep-final-counts convention): the AFFECTED count — # alpha + the source root — the phase-127 observable at the wire. assert body["summaries_done"] == 2, body assert body["summaries_total"] == 2, body assert body["phase"] is None, body assert body["current_summary"] is None, body after = _folder_rows() assert set(after) == {""} | set(FOLDERS), after # The chain (alpha + the source root): RE-STAMPED — a new stamp # strictly after the captured one, the canned text, still # AI-written (the mock labels from the folder header only, so the # text is the same deterministic line). for folder in ("", FOLDER_A): summary, manually, updated_at = after[folder] assert summary == EXPECTED[folder], (folder, summary) assert manually is False, (folder, manually) assert updated_at > before[folder][2], ( folder, before[folder], after[folder], ) # The siblings: BYTE-IDENTICAL — same summary text AND same # updated_at (never re-stamped, never even re-fetched for # generation). for folder in FOLDERS: if folder == FOLDER_A: continue assert after[folder] == before[folder], ( folder, before[folder], after[folder], ) # --------------------------------------------------------------------------- # 3. A vanished folder (bravo/ loses its last docs) has its row PRUNED # by the prune pass; the changed sibling's chain (charlie + the # source root) is re-stamped; the other rows are byte-identical # --------------------------------------------------------------------------- def test_pruned_file_restamps_only_its_chain_and_prunes_the_vanished_folder( app_url: str, incsum_dir: Path, db_ready: None ) -> None: """Re-seed + sync #1 (full), capturing every row; then ``bravo/`` loses its last docs (the folder VANISHES — 0 documents, no longer a candidate) and ONE doc in ``charlie/`` changes content; the sync settles ``success``: the ``bravo`` row is PRUNED (gone — the prune pass owns vanished folders' rows, independent of the generation's affected set), the source-root AND ``charlie`` rows are re-stamped (new ``updated_at`` + canned text), the ``alpha``/``delta`` rows are byte-identical (text AND ``updated_at``), and the terminal keeps ``summaries_done == summaries_total == 2`` (the affected CANDIDATE count — charlie + root; bravo is no longer a candidate, so it is neither generated nor counted).""" _register_source(app_url, str(incsum_dir)) body = _run_sync(app_url) assert body["state"] == "success", body assert body["detail"]["added"] == N_FILES, body["detail"] before = _folder_rows() assert set(before) == {""} | set(FOLDERS), before # bravo/ loses its last docs — the folder vanishes from the # catalogue (0 documents → below the ≥ 1 minimum → not a # candidate → the prune pass owns its row). for letter in ("a", "b"): (incsum_dir / FOLDER_B / f"{FOLDER_B}-{letter}.md").unlink() # One charlie doc gets new content (``updated``). target = incsum_dir / FOLDER_C / f"{FOLDER_C}-a.md" target.write_text( _md("Incsum Charlie A (revised)", CHARLIE_A_REVISED), encoding="utf-8" ) body = _run_sync(app_url) assert body["state"] == "success", body detail = body["detail"] assert detail["added"] == 0, detail assert detail["updated"] == 1, detail assert detail["unchanged"] == N_FILES - 3, detail assert detail["pruned"] == 2, detail assert detail["overview"] is True, detail # Terminal (the keep-final-counts convention): the affected # CANDIDATE count — charlie + the source root (bravo is gone from # the candidates, so it is not in the affected set's intersection). assert body["summaries_done"] == 2, body assert body["summaries_total"] == 2, body assert body["phase"] is None, body assert body["current_summary"] is None, body after = _folder_rows() # The vanished folder's row is PRUNED (the prune pass — it runs in # both modes, manual rows exempt — bravo's row is AI-written). assert FOLDER_B not in after, after assert set(after) == ({""} | set(FOLDERS)) - {FOLDER_B}, after # charlie + the source root: RE-STAMPED — a new stamp strictly # after the captured one, the canned text, still AI-written. for folder in ("", FOLDER_C): summary, manually, updated_at = after[folder] assert summary == EXPECTED[folder], (folder, summary) assert manually is False, (folder, manually) assert updated_at > before[folder][2], ( folder, before[folder], after[folder], ) # alpha + delta: BYTE-IDENTICAL — same summary text AND same # updated_at (neither folder is in the changed set's chains). for folder in (FOLDER_A, FOLDER_D): assert after[folder] == before[folder], ( folder, before[folder], after[folder], )