Files
brain-of-reese/tests/e2e/test_sync_peak_memory_visible.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

845 lines
37 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 187 (task 04) E2E (Playwright, mock-only): the sync's PEAK MEMORY
is visible on the page that started the run — and it is the truth.
Source: owner incident 2026-10-15. Reconstructing "how much RAM did that
sync use?" meant reading ``systemd`` unit-teardown lines from three boots
(``brain-of-code-app.service: … 3.3G memory peak``), and boot −1 — the run
that killed the box — had NO teardown record at all, because the VM never
shut down cleanly. The app had no opinion about its own memory: no log
line, no status field, nothing on the Sources page. Phase 187 made it a
number (``app/core/memory.py``, the lifecycle lines, the status keys) and
this module is the browser proof of the last mile.
What the phase put on the wire, in the operator's words:
* ``GET /api/sync/status`` carries the ADDITIVE pair ``rss_mb`` (the
process's CURRENT resident set, MB) and ``rss_peak_mb`` (its HIGH-WATER
mark) — ``int | null``, where ``null`` means "no reading" and NEVER 0;
sampled at the run start, at every ``phase`` transition, throttled to
one read per 5 s through the import's per-file hook, and FORCED in every
terminal, which KEEPS the final pair next to the final counts;
* the Sources page appends `` · peak <N> MB`` to the result line it
already spoke (the ``fmtSyncResult`` counts line, "Sync cancelled") —
trailing text on a line that already exists, never a new control, never
a new live region, and NOTHING rendered when the pair is null.
**Which number this is (read it before comparing it to a dashboard).**
Both fields are PROCESS readings — ``VmRSS`` from ``/proc/self/status`` and
``getrusage(RUSAGE_SELF).ru_maxrss`` — NOT the host's cgroup figure. A
cgroup counts PAGE CACHE too: the incident box's app cgroup showed ~2.16 GB
of ``file`` against ~211 MB of ``anon`` while idle, and ``systemd`` printed
``3.3G memory peak`` for the unit while the uvicorn process was a fraction
of that. Neither number is wrong; LOCKED A1 pins the app to the process
one, and `README.md` (*Operational notes: the sync's memory (phase 187)*)
owns the full caveat and the operator grep
(``grep ' :: ' server.log | grep ' rss_peak_mb='``). This module never
asserts a MAGNITUDE — only that the number is present, typed, terminal,
honest (``peak >= current``, with the one exception the sampler owns: a
kernel high-water refreshed lazily is lifted to the current reading, never
invented from it), and equal on both surfaces.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sync_peak_memory_visible.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the deterministic
mock plus the phase-64 ``tests/e2e/slow_llm.py`` delay proxy is what makes
the RUNNING observation a fact instead of a race: the run must outlive the
UI's 2 s status poll (and the tests' ~100 ms one) so the "no clause while
running" pin and the cancel window are both deterministic. Two files →
≈ 10 LLM calls (the two model probes, the per-file embed batches + the
summaries, the KB overview, the source-root folder summary) ≈ 5 s at
``SLOW_DELAY_S = 0.5``.
Isolation (the house shape): ONE module app on its own port (the conftest
session app owns 8123 in a combined run), the mock LLM on the conftest
port behind the proxy, a dedicated ``BOR_SOURCES_DIR`` /
``BOR_PDF_PAGE_DIR`` so no run writes into the operator's, the shared E2E
Postgres truncated BEFORE **and** AFTER every test (after waiting for the
app to hold no running sync), and the admin in through the real form login
(``tests/e2e/auth_helpers.py``). Each test seeds the same two-file
``kind=local`` source directory and registers it through the authenticated
API, so any single test re-runs alone with a fresh KB and the counts line
says ``2 added``.
Test → observable mapping (Playwright Mapping Rule — the rendered DOM is
the observable; the DOM number and the payload number are read as one
comparison, so "the page lies" cannot slip through):
1. ``test_result_line_shows_the_peak`` — a full run from the Sources page:
the box (``#sync-progress-text``) settles the counts line WITH the
trailing clause (``^2 added · peak \\d+ MB$``), the clause matches
``/peak \\d+ MB/``, the aria-live region IS the box (``role=status`` +
``aria-live=polite``, no ``aria-hidden``) and carries the SAME text,
the clause is TEXT and not an element (the box still holds exactly one
child span — no new control), and the megabytes in the DOM EQUAL the
terminal payload's ``rss_peak_mb``. Where the platform cannot know the
pair, the mirror contract is pinned instead: NO clause at all.
2. ``test_status_payload_reports_rss`` — the endpoint end to end: a tick
during ``running`` (a) reports the pair with the documented types
(``int | null``), (b) carries the pre-phase key set UNCHANGED (the
additive pin: exactly the pre-187 keys plus the memory pair) and (c)
shows NO ``peak`` clause on the page — the clause is terminal-only;
after the terminal ``rss_peak_mb`` is a POSITIVE int with
``peak >= current`` (the invariant the ONE sampler owns: the kernel
refreshes its high-water lazily, so ``_sample_rss`` lifts a lagging peak
to the current reading — a peak under the run's own footprint would be a
false number), and the same pair survives a later poll (the page
reloaded an hour later still answers).
3. ``test_anonymous_cannot_read_memory`` — the A4 exposure guard: a
logged-out ``GET /api/sync/status`` is 403 with the pre-phase body
``{"detail": "admin only"}`` — no memory number on an anonymous
surface, before or after a run — and the page shows the sign-in gate
with no sync button and no peak clause anywhere in the DOM.
4. ``test_cancelled_terminal_keeps_the_peak`` — the ``test_sync_cancel.py``
shape: cancel mid-run and the NEUTRAL "Sync cancelled" state still
renders (no banner, no modal, the button retry-ready) with the clause
present and equal to the cancelled payload's ``rss_peak_mb`` — the
run that stopped early is exactly the run whose memory matters.
5. ``test_peak_clause_fits_at_375`` — PLAN §7 at phone width: after a run
settled at 375 px, the clause is on screen, the box does not clip it
(``scrollWidth <= clientWidth`` for the box and its text), the box
stays inside the viewport and the PAGE has no horizontal overflow.
"""
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 httpx
import pytest
from playwright.sync_api import Browser, ConsoleMessage, Page, ViewportSize, expect
from sqlalchemy import text
from app.config import Settings as _Settings
from app.core.memory import rss_mb, rss_peak_mb
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
MOCK_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
#: Dedicated module ports (the house env-overridable pattern): the conftest
#: session app owns 8123 and the mock owns 8901 in a combined run.
APP_PORT = int(os.environ.get("E2E_APP_PORT_PEAKMEM", "8204"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The slow-LLM proxy's port (the conftest's mock stays on MOCK_PORT — the
#: proxy fronts it).
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT_PEAKMEM", "8910"))
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
#: Per-LLM-request delay (the phase-64 timing convention): the run spans
#: ≈ 10 calls ≈ 5 s, i.e. ≫ the UI's 2 s status poll — the running window
#: and the cancel window are both observable, not races.
SLOW_DELAY_S = "0.5"
SOURCES_URL = "/sources.html"
#: The fixture directory's basename — the ``source`` a ``kind=local`` row
#: records (``Path(path).name``), stable whatever temp root pytest picked.
#:
#: **Nothing here is allowed to contain the word "peak"** — not the source
#: name, not a path, no marker. Several pins below ask whether the page
#: says ANYTHING about memory (the running label must be clause-free, the
#: anonymous shell must be silent), and those asks read the rendered text;
#: a fixture path like ``peakmem/notes/peak-alpha.md`` rides the running
#: label verbatim and would answer "yes" from the file name alone. So the
#: fixture is ``memline-fixture/notes/mem-*.md`` and the clause stays the
#: only thing on the page that can say the word.
SOURCE_NAME = "memline-fixture"
N_FILES = 2
FIXTURE_FILES = {
"notes/mem-alpha.md": (
"# Memory line alpha\n"
"\n"
"Head marker RESE-MEMLINE-ALPHA-6d13 opens this note.\n"
"\n"
"## Alpha body\n"
"\n"
"A small note that exists so the phase-187 suite has a real run "
"to watch: a real walk, real embeds, a real KB overview, and the "
"run's resident-set clause on the line it settles.\n"
),
"notes/mem-beta.md": (
"# Memory line beta\n"
"\n"
"Head marker RESE-MEMLINE-BETA-9a74 opens this note — the second "
"file, the counts line's denominator.\n"
),
}
#: The clause sources.js appends (``fmtSyncPeakClause``): trailing text on
#: a result line that already exists. REQUIRED here — this suite's whole
#: point is that the number is there (the optional form, for the suites
#: that only pin their own counts, lives in ``e2e.sync_helpers``).
PEAK_CLAUSE = re.compile(r" · peak (\d+) MB$")
#: The clause SHAPE, for the "this surface says nothing about memory" pins
#: (the running label, the anonymous shell). Matched case-insensitively on
#: the word, because the clause is the only thing on these pages allowed to
#: use it — which the fixture naming rule above is what keeps true.
_ANY_PEAK = re.compile(r"peak", re.IGNORECASE)
#: The settled success line: the pre-phase ``fmtSyncResult`` counts line
#: (a fresh KB of two files → "2 added", the zero "updated"/"unchanged"
#: parts omitted) with the phase-187 clause trailing it.
SUCCESS_LINE = re.compile(rf"^{N_FILES} added{PEAK_CLAUSE.pattern}$")
#: The neutral cancel line (phase 134/150 wording, byte-identical) with
#: the clause trailing it.
CANCELLED_LINE = re.compile(rf"^Sync cancelled{PEAK_CLAUSE.pattern}$")
#: "Synced HH:MM" — the local-time last-result label (sources.js's
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
#: The status payload's key set BEFORE phase 187 — the additive pin's
#: left-hand side. The payload must stay exactly this set plus the memory
#: pair, forever (the same list the integration suite pins).
PRE_187_STATUS_KEYS = frozenset(
{
"state",
"started_at",
"finished_at",
"detail",
"error",
"current_file",
"files_done",
"files_total",
"current_chunk",
"chunks_total",
"images_done",
"images_total",
"phase",
"current_summary",
"summaries_done",
"summaries_total",
}
)
MEMORY_KEYS = frozenset({"rss_mb", "rss_peak_mb"})
#: The pre-phase integer count slots (their types are part of the
#: unchanged-shape pin in test 2 — never a float, never null).
_COUNT_KEYS = frozenset(
{
"files_done",
"files_total",
"current_chunk",
"chunks_total",
"images_done",
"images_total",
"summaries_done",
"summaries_total",
}
)
#: The invariant the page can rely on: ``peak >= current``. The raw kernel
#: counters do NOT give that for free (``VmRSS`` and ``ru_maxrss`` are two
#: different sources, both floored to whole MB, and the kernel refreshes the
#: high-water lazily — measured at ``rss_mb=164`` against
#: ``rss_peak_mb=162``), so the ONE sampling point
#: (:func:`app.api.sync._sample_rss`) lifts a lagging peak to the current
#: reading. Slack ZERO: a peak BELOW the run's own current footprint is a
#: false number, and the page must never show one.
MB_SLACK = 0
#: Does THIS platform answer the two helpers? Linux (the deploy target:
#: ``/proc`` + ``resource``) always does. The app under test is a
#: subprocess of this process's own OS, so this answer is the app's answer.
#: Where it is False the value pins step aside and the ABSENCE contract is
#: pinned instead (null renders nothing — never "peak ? MB", never 0).
MEMORY_KNOWABLE = rss_mb() is not None and rss_peak_mb() is not None
#: The house phone viewport at which the clause must still fit (the
#: PLAN §7 / AGENTS rule 5 width: no horizontal overflow at 375 px).
MOBILE_VIEWPORT: ViewportSize = {"width": 375, "height": 720}
#: Settle budgets: a two-file run under the proxy is ≈ 5 s and the UI's 2 s
#: poll settles at most one tick after the terminal — 90 s is an order of
#: magnitude of head-room (the house sync budget, no client timeout).
SETTLE_TIMEOUT_MS = 90_000
#: The browser logs "Failed to load resource: … 403" for the anonymous
#: probes (the whoami gate + the admin-only status fetch) — expected
#: noise, not a defect.
_CONSOLE_NOISE = re.compile(r"Failed to load resource", re.IGNORECASE)
# ---------------------------------------------------------------------------
# The fixture source
# ---------------------------------------------------------------------------
def _seed_source(root: Path) -> Path:
"""(Re-)write the two fixture files under ``root / SOURCE_NAME``.
Idempotent on purpose: every test calls it, so the tree is at its
ORIGINAL bytes whenever a test starts and a lone re-run of any single
test still sees a fresh KB with ``2 added``.
"""
base = root / SOURCE_NAME
for rel, body in FIXTURE_FILES.items():
target = base / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body, encoding="utf-8")
assert not (base / ".git").exists() # a plain directory — no git anywhere
return base
@pytest.fixture(scope="module")
def source_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The fixture tree the module app walks (the app server runs on the
same host, so the path is visible to it)."""
return _seed_source(tmp_path_factory.mktemp("bor_peak_memory"))
# ---------------------------------------------------------------------------
# The module app + the timing proxy
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def slow_llm(mock_llm: int) -> Iterator[int]:
"""The delay-injecting reverse proxy in front of the mock LLM
(``tests/e2e/slow_llm.py``) — the ONLY thing that makes the RUNNING
observation and the cancel window deterministic (module docstring)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
proc = subprocess.Popen(
[
sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning",
],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{SLOW_URL}/v1/models")
yield SLOW_PORT
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_server(
mock_llm: int,
slow_llm: int,
source_dir: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""The real app under test — per-module env (the conftest pattern):
``BOR_LLM_BASE_URL`` is the SLOW proxy in front of the deterministic
mock, ``BOR_GIT_SOURCES`` forced empty (each test registers its own
local row; an operator's gitignored ``.env`` must never leak a real
source into a run), and a dedicated ``BOR_SOURCES_DIR``.
"""
if USE_REAL_LLM:
pytest.skip(
"mock-only suite — the running window IS the slow proxy in "
"front of the deterministic mock (E2E_REAL_LLM unsupported)"
)
assert mock_llm and source_dir
scratch = tmp_path_factory.mktemp("bor_peak_memory_app")
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = f"{SLOW_URL}/v1"
# Mock-calibrated gate (the conftest pattern): both floors must stay
# <= the threshold or the app refuses to boot (app/config.py).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
# Instant retry waits + the code-default budget (the conftest leak
# guard — this suite never needs a retry, and never waits 5 s for one).
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
env["BOR_GIT_SOURCES"] = ""
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
env["BOR_PDF_PAGE_DIR"] = str(scratch / "pdf-pages")
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
# ---------------------------------------------------------------------------
# Isolation
# ---------------------------------------------------------------------------
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern, the phase-184 list:
``sources_meta`` and ``folder_summaries`` reset with the KB)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, kb_overview, "
"git_sources, sources_meta, folder_summaries"
)
)
db.commit() # without the commit the TRUNCATE rolls back
def _wait_sync_settled(base_url: str, timeout_s: float = 180.0) -> None:
"""No run may leak across a truncate: wait for a non-running status with
an own admin session (the endpoint is admin-only). Usually a no-op —
every test settles before it ends."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
with httpx.Client(base_url=base_url, timeout=5.0) as client:
client.post("/api/login", json={"password": ADMIN_PASSWORD})
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, source_dir: Path, db_ready: None) -> Iterator[None]:
"""Per-test KB isolation: the shared E2E Postgres is truncated before
AND after every test, the app holding no running sync at both
boundaries. The fixture tree is re-seeded first (idempotent) and the
local row is registered through the authenticated API, so every test
owns a fresh two-file source and its own ``2 added``."""
_wait_sync_settled(app_url)
_truncate_all()
_seed_source(source_dir.parent)
with httpx.Client(base_url=app_url, timeout=30.0) as client:
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.post(
"/api/git-sources", json={"kind": "local", "path": str(source_dir)}
)
assert r.status_code == 201, r.text
yield
_wait_sync_settled(app_url)
_truncate_all()
@pytest.fixture()
def page(browser: Browser) -> Iterator[Page]:
"""The conftest's page + the console-error collector: an uncaught JS
exception (pageerror) or a console error that is not the browser's
expected 403 "Failed to load resource" noise fails the owning test at
teardown — a clause builder that threw inside ``setSyncProgress`` would
otherwise settle a blank box and read as a passing test.
The conftest's ``page`` is NOT reused here because this suite's whole
subject is the sync result line: a JS error inside the clause builder
must fail the owning test, not leak (the ``test_sync_cancel.py``
pattern)."""
pg = browser.new_page(viewport={"width": 1280, "height": 800})
errors: list[str] = []
def _on_console(msg: ConsoleMessage) -> None:
if msg.type == "error" and not _CONSOLE_NOISE.search(msg.text):
errors.append(msg.text)
pg.on("console", _on_console)
pg.on("pageerror", lambda err: errors.append(str(err)))
try:
yield pg
finally:
pg.close()
assert not errors, f"console/page errors: {errors}"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _status(page: Page, base_url: str) -> dict[str, Any]:
"""The status payload through the ADMIN browser context (the cookie
rides ``page.request`` — the same surface the page's own poll uses)."""
r = page.request.get(f"{base_url}/api/sync/status")
assert r.status == 200, r.text
body: dict[str, Any] = r.json()
return body
def _wait_sync_state(
page: Page, base_url: str, states: set[str], timeout_s: float = 90.0
) -> dict[str, Any]:
"""Poll the status endpoint at ~100 ms until the run's state is in
*states* (the deterministic layer: the UI's own poll ticks at 2 s)."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
body = _status(page, base_url)
if body["state"] in states:
return body
time.sleep(0.1)
raise AssertionError(f"sync did not reach {sorted(states)}: {body}")
def _open_sources(page: Page, base_url: str) -> None:
"""The real form login, landing on the Sources view (the sync button's
home) with the module graph booted."""
login(page, base_url) # lands on /sources.html
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
expect(btn).to_be_enabled()
def _click_sync(page: Page) -> None:
"""Click **Sync sources** and pin the 202 moment: the button disables
(the run is in flight), the box reveals with the bare running label —
which carries NO peak clause (the clause is terminal-only)."""
btn = page.locator("#sync-btn")
btn.click()
expect(btn).to_be_disabled()
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
def _start_from_page(page: Page, base_url: str) -> None:
"""Login on the Sources view, then the click (the two-step form of the
same helper, so a test can change the viewport BETWEEN them)."""
_open_sources(page, base_url)
_click_sync(page)
def _clause_number(text: str) -> int:
"""The MB value the clause carries (loudly absent when there is none)."""
m = PEAK_CLAUSE.search(text)
assert m, f"no peak clause in {text!r}"
return int(m.group(1))
# ---------------------------------------------------------------------------
# 1. The success terminal: the clause on the line that already existed
# ---------------------------------------------------------------------------
def test_result_line_shows_the_peak(page: Page, app_url: str, db_ready: None) -> None:
"""A real sync started from the Sources page ends with the number on
the line the page already spoke — ``2 added · peak <N> MB`` — inside
the sole aria-live region, as TEXT (no new control), and equal to the
terminal payload's ``rss_peak_mb`` (the page and the log can never
disagree, because they read the one forced terminal sample)."""
page.set_default_timeout(30_000)
_start_from_page(page, app_url)
terminal = _wait_sync_state(page, app_url, {"success", "failed", "cancelled"})
assert terminal["state"] == "success", terminal
assert terminal["error"] is None, terminal
# The UI settle (the page's own 2 s poll, one tick after the terminal).
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SETTLE_TIMEOUT_MS)
box = page.locator("#sync-progress")
expect(box).to_be_visible()
text_el = page.locator("#sync-progress-text")
line = text_el.inner_text()
if not MEMORY_KNOWABLE:
# The mirror contract: no reading → NO clause at all (never
# "peak ? MB", never "peak 0 MB"), and the counts line alone.
assert line == f"{N_FILES} added", line
assert not _ANY_PEAK.search(box.inner_text()), box.inner_text()
return
# The clause is there AND the pre-phase wording is intact (the counts
# line keeps its exact bytes; the clause trails it).
assert SUCCESS_LINE.fullmatch(line), line
assert PEAK_CLAUSE.search(line), line
assert line.startswith(f"{N_FILES} added"), line
# The clause is TEXT on a line that already exists — the box still
# holds its ONE child span (no new control, no new live region).
expect(box.locator("> *")).to_have_count(1)
# The box IS the sole aria-live region and it announces the clause
# (phase 150: one live region, one surface — and the clause rides it).
expect(box).to_have_attribute("role", "status")
expect(box).to_have_attribute("aria-live", "polite")
expect(box).not_to_have_attribute("aria-hidden")
assert PEAK_CLAUSE.search(box.inner_text()), box.inner_text()
assert box.inner_text().strip() == line.strip(), (box.inner_text(), line)
# HONESTY: the megabytes on the page ARE the terminal payload's
# ``rss_peak_mb`` — one forced reading, two surfaces.
assert terminal["rss_peak_mb"] is not None, terminal
assert _clause_number(line) == terminal["rss_peak_mb"], (line, terminal)
# And the number survives the page: a reload re-attaches the terminal
# (initSyncButton) and re-renders the SAME clause.
page.reload()
expect(page.locator("#sync-progress-text")).to_have_text(
SUCCESS_LINE, timeout=SETTLE_TIMEOUT_MS
)
assert _clause_number(page.locator("#sync-progress-text").inner_text()) == (
terminal["rss_peak_mb"]
)
# No error surface on a success settle (unchanged contract).
expect(page.locator("#sync-error-banner")).to_be_hidden()
assert page.locator(".sync-modal-backdrop").count() == 0
# ---------------------------------------------------------------------------
# 2. The payload: additive, typed, terminal-kept — and silent while running
# ---------------------------------------------------------------------------
def test_status_payload_reports_rss(page: Page, app_url: str, db_ready: None) -> None:
"""The additive pin end to end, through the browser's own authenticated
request surface: while the run is in flight the payload already carries
the pair (``int | null``) and the pre-phase keys are byte-for-byte
unchanged, while the PAGE shows no clause at all; after the terminal the
peak is a positive integer that stays with the run, and the pair keeps
the invariant the page relies on (``peak >= current`` — the sampling
point lifts the lazily-refreshed high-water, ``MB_SLACK`` is 0).
"""
page.set_default_timeout(30_000)
_start_from_page(page, app_url)
# --- during `running` ------------------------------------------------
running = _wait_sync_state(page, app_url, {"running"})
assert running["state"] == "running", running
# The additive contract on the wire: exactly the pre-phase keys plus
# the memory pair — nothing renamed, nothing dropped.
assert set(running) == PRE_187_STATUS_KEYS | MEMORY_KEYS, set(running)
for key, value in running.items():
if key in MEMORY_KEYS:
# The documented type: int | null (null is "no reading").
assert value is None or isinstance(value, int), (key, value)
elif key == "detail":
assert isinstance(value, dict), (key, value)
elif key in _COUNT_KEYS:
assert isinstance(value, int), (key, value)
else:
assert value is None or isinstance(value, (str, int, bool)), (key, value)
# The types are ints on a platform that knows them (the run start
# FORCES the first sample, so a running tick is never null here).
if MEMORY_KNOWABLE:
assert running["rss_mb"] is not None, running
assert running["rss_peak_mb"] is not None, running
assert running["rss_peak_mb"] >= running["rss_mb"] - MB_SLACK, running
# The RUNNING state shows NO clause: the box carries the live label and
# says nothing about memory (the clause is terminal-only in the UI). The
# word is the tell (the fixture names never carry it), so a clause that
# leaked into a running label fails even when its number is null-ish.
live_text = page.locator("#sync-progress-text").inner_text()
assert live_text.startswith("Syncing…"), live_text
assert not _ANY_PEAK.search(live_text), live_text
assert not _ANY_PEAK.search(page.locator("#sync-progress").inner_text())
# --- after the terminal ---------------------------------------------
terminal = _wait_sync_state(page, app_url, {"success", "failed", "cancelled"})
assert terminal["state"] == "success", terminal
assert set(terminal) == PRE_187_STATUS_KEYS | MEMORY_KEYS, set(terminal)
current, peak = terminal["rss_mb"], terminal["rss_peak_mb"]
if MEMORY_KNOWABLE:
assert peak is not None and isinstance(peak, int), terminal
assert peak > 0, terminal # a real reading, never a placeholder 0
assert current is not None and current > 0, terminal
assert peak >= current - MB_SLACK, (current, peak)
else:
assert peak is None and current is None, terminal
# The pair SURVIVES the run (no reset between the terminal and a later
# poll — the page can be reloaded an hour later and still answers).
again = _status(page, app_url)
assert (again["rss_mb"], again["rss_peak_mb"]) == (current, peak), again
# …and the pre-existing values rode the terminal unchanged too.
assert again["state"] == "success"
assert again["error"] is None
assert isinstance(again["detail"], dict) and again["detail"]["added"] == N_FILES
# ---------------------------------------------------------------------------
# 3. Anonymous: no memory number on any anonymous surface (A4)
# ---------------------------------------------------------------------------
def test_anonymous_cannot_read_memory(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
"""The exposure guard is the status route's own admin gate: a logged-out
visitor gets the pre-phase 403 with the pre-phase body, before or after
a run — and the Sources shell shows the sign-in gate with no sync button
and no memory clause anywhere in the DOM.
The probe runs in a SEPARATE browser context (``browser.new_context()``)
— this test's own page is logged in by the run above, and
``page.context.new_page()`` would SHARE that session's cookies, which
would make the "anonymous" probe an admin one."""
# A run FIRST, so the guard is proven with real numbers in existence.
_start_from_page(page, app_url)
terminal = _wait_sync_state(page, app_url, {"success"})
if MEMORY_KNOWABLE:
assert terminal["rss_peak_mb"] is not None, terminal
ctx = browser.new_context(viewport={"width": 1280, "height": 800})
try:
anon = ctx.new_page()
r = anon.request.get(f"{app_url}/api/sync/status")
assert r.status == 403, r.text
assert r.json() == {"detail": "admin only"}
# The anonymous shell: the gate is the anonymity proof, the sync
# controls are never revealed, and nothing about memory renders.
anon.goto(f"{app_url}/sources.html")
expect(anon.locator("#sources-gate")).to_be_visible(timeout=30_000)
expect(anon.locator("#sync-btn")).to_be_hidden()
expect(anon.locator("#sync-progress")).to_be_hidden()
assert not _ANY_PEAK.search(anon.locator("body").inner_text())
finally:
ctx.close()
# ---------------------------------------------------------------------------
# 4. The cancelled terminal keeps the peak (the test_sync_cancel shape)
# ---------------------------------------------------------------------------
def test_cancelled_terminal_keeps_the_peak(
page: Page, app_url: str, db_ready: None
) -> None:
"""Cancel mid-run: the run lands in the NEUTRAL ``cancelled`` terminal
(no banner, no modal, retry-ready) and the box still carries the
clause — "Sync cancelled · peak <N> MB" — equal to the cancelled
payload's ``rss_peak_mb``. A cancelled run's memory is still data: the
phase-64 keep-final-values convention covers it like the counts."""
page.set_default_timeout(30_000)
_start_from_page(page, app_url)
_wait_sync_state(page, app_url, {"running"})
cancel = page.locator("#sync-cancel-btn")
expect(cancel).to_be_visible()
expect(cancel).to_be_enabled()
cancel.click()
expect(cancel).to_be_disabled() # never stale, one request at a time
body = _wait_sync_state(page, app_url, {"cancelled"}, timeout_s=30.0)
assert body["state"] == "cancelled"
assert body["error"] is None # a cancel is intent, not failure
assert body["detail"] == {}
# The neutral settle, clause and all (the 2 s poll, one tick later).
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=30_000)
expect(page.locator("#sync-btn")).to_be_enabled()
expect(cancel).to_be_hidden()
expect(page.locator("#sync-error-banner")).to_be_hidden()
assert page.locator(".sync-modal-backdrop").count() == 0
line = page.locator("#sync-progress-text").inner_text()
if MEMORY_KNOWABLE:
assert body["rss_peak_mb"] is not None, body
assert body["rss_peak_mb"] > 0, body
assert CANCELLED_LINE.fullmatch(line), line
assert _clause_number(line) == body["rss_peak_mb"], (line, body)
else:
assert line == "Sync cancelled", line
# The pair survives the cancel exactly like the counts do.
again = _status(page, app_url)
assert (again["rss_mb"], again["rss_peak_mb"]) == (
body["rss_mb"],
body["rss_peak_mb"],
), again
# ---------------------------------------------------------------------------
# 5. PLAN §7 at 375 px: the clause fits, the page never scrolls sideways
# ---------------------------------------------------------------------------
def test_peak_clause_fits_at_375(page: Page, app_url: str, db_ready: None) -> None:
"""The clause at phone width: the result line wraps INSIDE the bordered
box (``overflow-wrap: anywhere``) — nothing clipped, no horizontal page
overflow, the box inside the viewport (AGENTS rule 5 / PLAN §7). The
clause is a few characters of trailing text, so a layout shift or a
clipped tail at 375 px is exactly what this pins out."""
_open_sources(page, app_url)
page.set_viewport_size(MOBILE_VIEWPORT) # the phone width, then the click
page.wait_for_timeout(150) # the reflow settles before the run paints
_click_sync(page)
terminal = _wait_sync_state(page, app_url, {"success"})
text_el = page.locator("#sync-progress-text")
expect(text_el).to_have_text(
SUCCESS_LINE if MEMORY_KNOWABLE else f"{N_FILES} added",
timeout=SETTLE_TIMEOUT_MS,
)
line = text_el.inner_text()
if MEMORY_KNOWABLE:
assert PEAK_CLAUSE.search(line), line
assert _clause_number(line) == terminal["rss_peak_mb"], (line, terminal)
# The box + its text do not clip, the box stays in the viewport, and
# the PAGE itself never scrolls sideways at 375 px.
page.wait_for_timeout(150) # the reflow settles before the measure
geometry = page.evaluate(
"""() => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const b = box.getBoundingClientRect();
return {
boxScrollWidth: box.scrollWidth,
boxClientWidth: box.clientWidth,
textScrollWidth: textEl.scrollWidth,
textClientWidth: textEl.clientWidth,
left: b.left,
right: b.right,
height: b.height,
docScroll: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
text: textEl.textContent,
};
}"""
)
assert geometry["height"] > 0, geometry
assert geometry["boxScrollWidth"] <= geometry["boxClientWidth"], geometry
assert geometry["textScrollWidth"] <= geometry["textClientWidth"], geometry
assert geometry["left"] >= 0, geometry
assert geometry["right"] <= MOBILE_VIEWPORT["width"] + 1, geometry
assert geometry["docScroll"] <= geometry["innerWidth"] + 1, geometry
assert PEAK_CLAUSE.search(geometry["text"]) or not MEMORY_KNOWABLE, geometry