Files
brain-of-reese/tests/e2e/test_sync_status_freshness.py
ducoterra ae080ac7f3
Build and Push Containers / build-and-push-app (push) Successful in 2m3s
Build and Push Containers / build-and-push-db (push) Successful in 16s
phase: 193_sync_remaining_count
**Phase 193 — final verification pass: all gates green.**

- **Verified (all 3 tasks already complete in `complete/193_sync_remaining_count/`):** `fmtSyncLabel`'s count part is `total > 0 && total - done > 0 ? \`${total - done} remaining\` : ""` (plain int, hidden at `N==0`, `total===0` → no part); docstrings updated; chunk/image/summaries parts + `counts_line` terminal + `app/` byte-untouched (`git diff --stat`: no `app/` files).
- **Completion criteria:** `N remaining` shape + marker pins ✅ · upload shares the builder (E2E upload arm) ✅ · chunk/image/summaries pins green ✅ · terminal `counts_line` green unedited + A4 title/`#sync-progress` pin ✅ · suite green, coverage 99% ✅ · lint/types clean ✅ · commit + `todo/→complete/` left to the harness (no git index/history commands run).
- `uv run pytest -q --cov=app --cov-report=term-missing` → **exit 0**, junit: **4792 tests, 0 failures/errors/skipped**, `TOTAL 7181 16 → 99%` (>90%)
- `uv run pytest tests/e2e/test_sync_remaining_count.py -v --no-cov` → **3 passed** (isolated, 53 s)
- Isolated `--no-cov` sweep of the 14 sync-lineage suites (label_mobile, upload_progress, chunk_progress, image_progress, progress_box, summary_visibility, archive_upload_sources, status_freshness, upload_no_scan, peak_memory_visible, session_reset, fast_path_deferred_content, git_freeze, smoke) → **14/14 exit 0**
- `uv run ruff check .` → All checks passed · `uv run pyright` → **0 errors**
- **Docs:** README.md reviewed in full (all 2408 lines) + AGENTS.md. Fixed 2 inconsistencies: the sync running-label clause claimed the upload label shows the count — now states it shares the ONE builder/shape but reports no counts since phase 90 (renders bare); phase-190's "the other ~72 rows" → "the other 83 rows of the panel's 86-row catalogue" (mechanically: 86 CATALOG rows, 84 overridable). `N remaining` already in the sync paragraph; task-01's `(n/m)` examples gone. AGENTS.md verified consistent (no clause names the running count part → unchanged per the phase's "do not add if absent"). Mechanically re-verified: all 86 `BOR_*` settings documented + defaults match, `.env.example` 88 vars covered, 0 broken README anchors, all cited file paths exist, 17 palette colors, migrations head `0030`, pages/shell routes, ports 8123/8901, E2E port allocations (new suite's 8211/8914 unique).
- **Notable:** task 02's documented deviation stands — `files_done` is the importer's 1-based *current* file, so the phase doc's `[1-4]` range is unreachable; count-bearing frames are `1 … N−1` (pinned). Pre-existing, untouched: `E2E_APP_PORT_TOOLBUDGET`/`SAVEDOC` both 8129 and `SYNCUP`/`SYCNCEL` both 8133 — harmless under the one-suite-at-a-time rule.
- **Next pending phase:** none — `193_sync_remaining_count` is the last entry in `.agents/phases/todo/`.
2026-10-19 17:30:03 -04:00

771 lines
34 KiB
Python

"""Phase 154 task 04 E2E (Playwright, mock-only): the two visible
FRESHNESS-STAMP contracts on the live UI (the phase-154 date-qualified
stamps — the same-day shape is pinned here; the non-same-day shapes
are unit-pinned on a fixed clock, so this suite never depends on the
wall clock beyond "the run finishes today").
1. **The RAG sync badge settles same-day-shaped**: a fresh mock sync
(a small registered local source, the mock LLM keeps it short)
settles the ``#sync-btn`` label to ``Synced HH:MM`` — the
DATE-QUALIFIER (phase 154, task 01, ``fmtSyncTime``'s widened
contract) must yield the same-day ``HH:MM`` shape for a run that
finished "now". The assertion is a SHAPE pin (the regex
``^Synced \\d{2}:\\d{2}$`` — the zero-padded 24-hour form
``fmtSyncTime`` produces for the same local calendar day), never a
comparison against the wall clock's actual value (the
date-qualified forms are unit-pinned on a fixed clock, phase 154,
task 03 — the E2E never depends on the wall clock beyond "the
run finishes today").
2. **The upload line's fresh settle carries the name AND the stamp**:
a real small fixture archive through the page's upload form → the
202 "Successfully uploaded — <source>" toast → the 2 s poll's
settle renders the ready-for-sync line
``Uploaded <name> (<stamp>) — press Sync sources to import it.``
(the phase-154 task 02 shape, the page-local safe name +
``fmtUploadStamp``'s completion stamp off the server payload's
``finished_at`` — the terminal status pin carries the non-null
``finished_at`` the stamp is computed from). The stamp matches the
shape pin (same-day ``HH:MM`` in practice; the shape regex also
covers the date-qualified forms, so the pin never breaks across a
midnight boundary).
3. **The boot re-render is NAMELESS but still STAMPED** (the phase's
actual fix — the owner's "the line can sit there indefinitely and
mislead"): after the settled fresh line, a page reload boots
``initUploadStatus``'s success branch — the safe name was
page-local (``lastUploadName`` is null after a reload — the
phase-90 contract), so the line is the NAMELESS
``Uploaded <stamp> — press Sync sources to import it.`` — but the
stamp SURVIVES because it comes from the SERVER payload
(``finished_at``), not from page state. The run's identity pin
(``started_at`` unchanged across the reload) proves the re-render
is the same run's line, not a second upload's.
4. **A running upload never renders the ready-for-sync line** (the
phase-90 regression guard, extended): at the 202 moment the button
is the BARE ``Processing…`` (phase 90, A2 — no file, no counts,
title cleared) and ``#archive-upload-result`` is HIDDEN — the line
appears ONLY on settle (the poll's success tick). The
deterministic in-flight window: the 202 handler synchronously
enters the processing state and schedules the first poll tick at
+2 s, so the ``Processing…`` state holds for ≥ 2 s regardless of
how fast the sub-second background run finishes — the
in-flight observation needs no delay proxy (the upload makes zero
LLM calls; the sync leg of test 1 is 2 files and settles within
the generous budget).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the whole
point is the deterministic mock (no real-LLM dependency), and the
time assertions are shape pins, never wall-clock value comparisons.
Per-module app env (the conftest pattern — mirroring
``test_upload_no_scan.py``'s local helpers, NOT importing that
module): ``BOR_UPLOAD_DIR`` points at a scratch dir (the app runs on
the same host), ``BOR_GIT_SOURCES`` is forced empty (this suite's
own registered rows / upload rows are the only sync sources),
``BOR_LLM_BASE_URL`` is the mock (the sync leg's embeds + overview),
the retry knobs pinned (the sync leg never burns a 5 s retry wait),
and the autouse ``_clean`` truncates the shared Postgres (plus waits
for no running background job — a leaked run must not survive into
the next test's truncated KB).
"""
from __future__ import annotations
import io
import os
import re
import subprocess
import sys
import tarfile
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings as _Settings
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
from e2e.sync_helpers import counts_line
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): in a combined session run the
# conftest session app already owns its port — a second uvicorn on it
# dies on bind and this suite would silently drive the wrong server.
# The module app binds its own port instead (env-overridable).
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCFRESH", "8172"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
SOURCES_URL = "/sources.html"
GIT_SOURCES_URL = "/git-sources.html"
#: The sync-leg fixture source (a host temp dir, ``kind=local``) —
#: exactly two small markdown files, so the counts line is "2 added"
#: and the run is short against the fast mock LLM.
SYNC_SOURCE = "freshness-sync"
SYNC_FILES: dict[str, str] = {
"alpha.md": (
"# Freshness alpha\n"
"\n"
"A small note that exists only to give the phase-154 sync\n"
"fixture a real import.\n"
"\n"
"Marker: FRESHNESS-SYNC-ALPHA-7f31\n"
),
"beta.md": (
"# Freshness beta\n"
"\n"
"The second small note — the counts line's denominator.\n"
"\n"
"Marker: FRESHNESS-SYNC-BETA-2c90\n"
),
}
#: The upload-leg archive basenames (one per upload test — distinct
#: names keep the in-place-replace identity out of the assertions).
UPLOAD_A = "e2e-freshness-a"
UPLOAD_B = "e2e-freshness-b"
UPLOAD_C = "e2e-freshness-c"
def _targz_files(name: str) -> dict[str, str]:
"""One small deterministic markdown file per archive (a valid
replacement archive — what the KB indexes with it is the sync's
call, phase 90)."""
return {
"note.md": (
f"# Freshness {name}\n"
"\n"
"A small note carried by this suite's archive.\n"
f"\nMarker: FRESHNESS-{name.upper()}-9d16\n"
)
}
# ---------------------------------------------------------------------------
# Shape pins (the task's contract: shapes, never wall-clock values)
# ---------------------------------------------------------------------------
#: The date-qualified stamp's shape (phase 154, A1 rule): the
#: same-day ``HH:MM`` (zero-padded — ``fmtSyncTime`` /
#: ``fmtUploadStamp`` pad both fields) OR the date-qualified forms
#: (``M/D(, YYYY), h:MM AM/PM`` — no leading zero on month/day/hour).
#: In practice a run that settles "now" always lands on the same-day
#: branch; the shape regex covers both so the pin never breaks across
#: a midnight boundary. The date-qualified VALUES are unit-pinned on a
#: fixed clock (phase 154, task 03) — this suite never compares a
#: stamp against the actual time.
STAMP = (
r"\d{2}:\d{2}"
r"|\d{1,2}/\d{1,2}(?:/\d{4})?, \d{1,2}:\d{2} [AP]M"
)
#: ``Synced HH:MM`` — the same-day settled sync label (task 01: the
#: qualifier yields the zero-padded ``HH:MM`` for a run that finished
#: on the same local calendar day — byte-identical to the pre-phase
#: shape). The date-qualified ``M/D(, YYYY), h:MM AM/PM`` forms are
#: unit-pinned on a fixed clock (phase 154, task 03) — this pin is
#: the same-day shape the task pins; a run that settles "now" always
#: lands on the same-day branch.
SYNCED_SAME_DAY = re.compile(r"^Synced \d{2}:\d{2}$")
#: The fresh-settle ready-for-sync line WITH the safe name and the
#: stamp (phase 154, task 02 — the page-local ``lastUploadName`` +
#: ``fmtUploadStamp(status)`` off the server payload's
#: ``finished_at``).
def _ready_line(name: str) -> re.Pattern[str]:
return re.compile(
rf"^Uploaded {re.escape(name)} \(({STAMP})\)"
r" — press Sync sources to import it\.$"
)
#: The boot re-render ready-for-sync line — NAMELESS (the safe name
#: was page-local, ``lastUploadName`` is null after a reload — the
#: phase-90 contract) but STAMPED (the stamp comes from the SERVER
#: payload — the phase-154 fix: a line that outlives its upload is
#: self-dating).
NAMELESS_READY_LINE = re.compile(
rf"^Uploaded ({STAMP}) — press Sync sources to import it\.$"
)
#: Generous settle budgets: the upload run settles in milliseconds
#: (phase 90 — unpack + register only, zero LLM calls) and the UI's
#: 2 s poll lands one tick after; the sync leg is 2 files against the
#: fast mock LLM and may settle between the 2 s poll ticks — the
#: settled-state assertions retry until then.
UPLOAD_TIMEOUT_MS = 30_000
SYNC_TIMEOUT_MS = 90_000
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _build_targz(path: Path, files: dict[str, str]) -> Path:
"""A deterministic ``.tar.gz`` (mtime 0) over the given files."""
with tarfile.open(path, "w:gz") as tf:
for rel, content in files.items():
data = content.encode("utf-8")
info = tarfile.TarInfo(rel)
info.size = len(data)
info.mtime = 0
tf.addfile(info, io.BytesIO(data))
return path
@pytest.fixture(scope="module")
def sync_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The sync-leg's local source (``kind=local`` — the directory's
basename is the source name, phase 38): EXACTLY the two fixture
files (the counts line reads ``2 added``)."""
root = tmp_path_factory.mktemp("bor_freshness_sync")
src = root / SYNC_SOURCE
src.mkdir()
for rel, content in SYNC_FILES.items():
(src / rel).write_text(content, encoding="utf-8")
assert len(list(src.rglob("*"))) == len(SYNC_FILES)
return src
@pytest.fixture(scope="module")
def upload_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The app's ``BOR_UPLOAD_DIR`` for this suite — a scratch dir
the host-side assertions inspect (the app server runs on the same
machine). The app creates it on the first upload."""
return tmp_path_factory.mktemp("bor_freshness_uploads") / "uploads"
@pytest.fixture(scope="module")
def tarball_a(tmp_path_factory: pytest.TempPathFactory) -> Path:
root = tmp_path_factory.mktemp("bor_freshness_a")
return _build_targz(root / f"{UPLOAD_A}.tar.gz", _targz_files(UPLOAD_A))
@pytest.fixture(scope="module")
def tarball_b(tmp_path_factory: pytest.TempPathFactory) -> Path:
root = tmp_path_factory.mktemp("bor_freshness_b")
return _build_targz(root / f"{UPLOAD_B}.tar.gz", _targz_files(UPLOAD_B))
@pytest.fixture(scope="module")
def tarball_c(tmp_path_factory: pytest.TempPathFactory) -> Path:
root = tmp_path_factory.mktemp("bor_freshness_c")
return _build_targz(root / f"{UPLOAD_C}.tar.gz", _targz_files(UPLOAD_C))
@pytest.fixture(scope="module")
def app_server(
mock_llm: int,
upload_dir: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""The real app under test — per-module env: uploads unpack into
a scratch dir, the env git list is forced empty (this suite's own
rows are the only sync sources), and the LLM base URL is the mock
(the upload makes zero LLM calls — phase 90; the mock serves the
sync leg's embeds + overview). No ``slow_llm`` proxy: the upload
run is sub-second and the 2 s poll window is what the in-flight
assertion leans on (see the module docstring's test 4 paragraph).
Skips the module (clear instructions) when Postgres is down —
the sync needs the KB tables."""
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): 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 — the sync leg never burns a 5 s
# retry wait).
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 this
# suite's own rows.
env["BOR_GIT_SOURCES"] = ""
# Phase 49: unpack uploads into the suite's scratch dir (host-
# inspectable) and keep the (unused) git checkouts out of the dev
# location.
env["BOR_UPLOAD_DIR"] = str(upload_dir)
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_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")
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")
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 per test (the E2E isolation pattern): the
row-count and doc-list assertions must be this test's own doing.
The E2E suites share one Postgres, and a leftover git_sources row
or document would corrupt them (a leftover row would be a SECOND
sync source, skewing the sync's counts). ``sources_meta`` (the KB
generation counter) resets with the KB — the sync re-creates the
row; ``folder_summaries`` / ``steering_notes`` ride along (the
phase-96 pattern)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, sources_meta, folder_summaries"
)
)
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
def _wait_no_running_jobs(app_url: str) -> None:
"""No background job may leak across tests (the run states live in
the app's memory, and a still-running sync would keep importing
into the NEXT test's truncated KB, and a still-running upload
would 409 the next test's upload): wait for BOTH status endpoints
to be non-running BEFORE the truncate. Own admin session (the
endpoints are admin-only) — usually a no-op: the tests settle
only after their terminal states."""
with httpx.Client(timeout=5.0) as client:
client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
for path in ("/api/sync/status", "/api/git-sources/upload/status"):
body: dict[str, Any] = {}
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
r = client.get(f"{app_url}{path}")
if r.status_code == 200:
body = r.json()
if body["state"] != "running":
break
time.sleep(0.2)
assert body["state"] != "running", (
f"a background job was still running at test boundary: {path} {body}"
)
@pytest.fixture(autouse=True)
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
_wait_no_running_jobs(app_url)
_truncate_all()
yield
_truncate_all()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _register_local_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 _status(page: Page, app_url: str, path: str) -> dict[str, Any]:
"""The status endpoint as the signed-in page (admin cookie)."""
r = page.request.get(f"{app_url}{path}")
assert r.status == 200, r.text
return r.json()
def _admin_git_sources_page(page: Page, app_url: str) -> None:
"""Real form login landing on the git sources page (admin settled:
Sign out visible, the manager revealed by the page module)."""
login(page, app_url, next=GIT_SOURCES_URL)
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#git-sources-gate")).to_be_hidden()
expect(page.locator("#git-sources-content")).to_be_visible()
def _upload_via_page(page: Page, archive: Path) -> None:
"""Pick the archive and submit the form (the 202 path: the toast
fires at the 202, then the button's status polling settles the
line — the caller asserts the line's shape). Every upload in this
suite succeeds (the failure path is another suite's subject), so
any non-202 outcome here is a test error."""
page.set_input_files("#archive-upload-file", str(archive))
page.click("#archive-upload-btn")
toast = page.locator(".toast")
expect(toast).to_have_count(1, timeout=UPLOAD_TIMEOUT_MS)
expect(toast).to_have_class(re.compile(r"\bis-visible\b"))
# ---------------------------------------------------------------------------
# 1. The RAG sync badge settles in the same-day shape (the qualifier's
# E2E half — the date-qualified values are unit-pinned, the shape
# is pinned here on a run that finished "now")
# ---------------------------------------------------------------------------
def test_fresh_sync_settles_same_day_shape(
page: Page, app_url: str, db_ready: None, sync_dir: Path
) -> None:
"""A fresh mock sync (the small registered local source — the mock
LLM keeps the run short) settles the RAG ``#sync-btn`` label to
the same-day ``Synced HH:MM`` shape (phase 154, task 01 — the
qualifier must yield ``HH:MM`` for a run that finished on the
same local calendar day — the SHAPE pin, never a wall-clock value
comparison). The run is real (the counts line ``2 added`` + the
terminal status's non-null ``finished_at`` — the ISO the
badge's stamp is computed from — + 2 of 2 files), the settled
button is re-enabled with the ship aria value restored, and no
error UI is present."""
page.set_default_timeout(30_000)
_register_local_source(app_url, str(sync_dir))
login(page, app_url, next=SOURCES_URL)
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
# The settled boot label — the fresh app's idle state (this is the
# module's first test; the app's in-memory sync state is idle).
expect(page.locator("#sync-label")).to_have_text("Sync sources")
expect(btn).to_be_enabled()
expect(page.locator("#sync-error-banner")).to_be_hidden()
btn.click()
# The click's immediate state (the 202 moment — the bare prefix in
# the box — phase 149: the running label rides the box, the
# button is the spinner).
expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true")
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
# The settle (phase 154, task 01 — the SAME-DAY SHAPE pin):
# "Synced HH:MM" — the date-qualified ``M/D(, YYYY), h:MM AM/PM``
# forms are unit-pinned on a fixed clock (phase 154, task 03);
# this run finished "now", so the qualifier must yield the
# same-day shape (the shape is asserted; the actual time value is
# never compared against the wall clock).
expect(page.locator("#sync-label")).to_have_text(
SYNCED_SAME_DAY, timeout=SYNC_TIMEOUT_MS
)
# The run was real (the counts line + the terminal status agree —
# the status is the source of truth for the settle; the UI's 2 s
# poll renders it at most one tick later).
expect(page.locator("#sync-progress-text")).to_have_text(
counts_line(f"{len(SYNC_FILES)} added"), timeout=SYNC_TIMEOUT_MS
)
btn = page.locator("#sync-btn")
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
# Phase 150: the settle restores the ship aria value (the running
# full label is gone — the badge is the pill's text).
assert btn.get_attribute("aria-label") == "Sync sources", (
btn.get_attribute("aria-label")
)
expect(page.locator("#sync-error-banner")).to_be_hidden()
terminal = _status(page, app_url, "/api/sync/status")
assert terminal["state"] == "success", terminal
# The run was a fresh import of exactly the two fixture files —
# and its ``finished_at`` (non-null) is the ISO the badge's stamp
# is computed from (the stamp's server-side half).
assert terminal["detail"]["added"] == len(SYNC_FILES), terminal
assert terminal["detail"]["pruned"] == 0, terminal
assert terminal["files_done"] == len(SYNC_FILES), terminal
assert terminal["files_total"] == len(SYNC_FILES), terminal
assert terminal["finished_at"], terminal
# ---------------------------------------------------------------------------
# 2. The upload line's fresh settle carries the name AND the stamp
# (phase 154, task 02 — the phase-90 no-count contract extended)
# ---------------------------------------------------------------------------
def test_upload_line_fresh_settle_carrys_name_and_stamp(
page: Page,
app_url: str,
db_ready: None,
tarball_a: Path,
upload_dir: Path,
) -> None:
"""One real upload through the page: the 202 fires the
"Successfully uploaded — <source>" toast (phase 64, A2); the
sub-second background run (unpack + register only — phase 90)
settles at the next 2 s poll tick into the ready-for-sync line
``Uploaded <name> (<stamp>) — press Sync sources to import it.``
— the SAFE name (the 202's ``UploadAccepted.name`` — the
phase-90 contract) + the phase-154 COMPLETION STAMP
(``fmtUploadStamp`` off the server payload's ``finished_at`` —
the shape pin: same-day ``HH:MM`` in practice, the date-qualified
forms covered by the shape regex). The never-stale restore
(button + input) lands with the line, the terminal status is the
no-count payload (``{"message": "uploaded"}``, null/0/0 — the
phase-90 A2 key set) with a NON-NULL ``finished_at`` (the stamp's
source), the source row is present (Local badge), and the
unpacked folder exists on the host (the run did its unpack +
register work — zero documents indexed: the scan is the Sync
button's job, phase 90, A1)."""
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
btn = page.locator("#archive-upload-btn")
expect(btn).to_be_enabled()
expect(btn).to_have_text("Upload")
_upload_via_page(page, tarball_a)
# The 202's toast names the safe source name (phase 64, A2)…
toast = page.locator(".toast")
expect(toast).to_have_attribute("role", "status")
expect(toast).to_have_text(f"Successfully uploaded — {UPLOAD_A}")
# …and the settle: the ready-for-sync line WITH the name and the
# completion stamp (phase 154, task 02 — the shape pin).
result = page.locator("#archive-upload-result")
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
expect(result).to_have_text(_ready_line(UPLOAD_A))
# The stamp is present and non-empty (the regex's capture is the
# stamp itself — the pre-phase string has no "(…)" at all, so a
# match IS the stamp's proof).
m = _ready_line(UPLOAD_A).fullmatch(result.text_content() or "")
assert m is not None, "the settled line did not match the named + stamped shape"
assert m.group(1), "the stamp is empty in the settled line"
# The never-stale restore (the poll's success tick owns it)…
expect(btn).to_be_enabled()
expect(btn).to_have_text("Upload")
expect(page.locator("#archive-upload-file")).to_have_value("")
# …and the terminal status: the no-count payload (phase 90, A2)
# with the NON-NULL finished_at the stamp is computed from.
status = _status(page, app_url, "/api/git-sources/upload/status")
assert status["state"] == "success", status
assert status["detail"] == {"message": "uploaded"}, status
assert status["current_file"] is None, status
assert status["files_done"] == 0 and status["files_total"] == 0, status
assert status["finished_at"], status
assert status["started_at"], status
# The row landed (Local badge) and the folder is on the host —
# but the upload indexed NOTHING (phase 90, A1 — the scan is the
# Sync button's job).
row = page.locator("#git-sources-tbody tr", has_text=UPLOAD_A)
expect(row).to_have_count(1, timeout=30_000)
expect(row.locator("span.git-source-kind")).to_have_text("Local")
assert (upload_dir / UPLOAD_A).is_dir(), (
f"the unpacked folder is missing: {upload_dir / UPLOAD_A}"
)
r = page.request.get(f"{app_url}/api/docs")
assert r.status == 200, r.text
assert r.json()["documents"] == []
# ---------------------------------------------------------------------------
# 3. The boot re-render is nameless but STAMPED — the phase's actual
# fix (the stale "Uploaded" line is self-dating after a reload)
# ---------------------------------------------------------------------------
def test_upload_line_boot_rerender_stamped_nameless(
page: Page,
app_url: str,
db_ready: None,
tarball_b: Path,
) -> None:
"""Seed a settled upload (the fresh named line — the run's
identity pinned on the status's ``started_at``), then RELOAD the
page: the boot re-attach (``initUploadStatus``'s success branch —
phase 64, task 05) re-renders the ready-for-sync line — the SAFE
NAME IS GONE (``lastUploadName`` was page-local, null after a
reload — the phase-90 contract) but the STAMP SURVIVES
(``fmtUploadStamp`` reads the SERVER payload's ``finished_at`` —
phase 154, task 02 — the line is self-dating even with the name
unknown; THIS is the regression the phase fixes: a line that
outlives its upload can no longer masquerade as fresh). The page
boots honest (no error banner, no toast — it fired at the 202 on
the previous document life — the button restored), the list shows
exactly one row for the archive, and the terminal status is the
SAME run (the ``started_at`` is unchanged — no second upload)."""
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
# The seed: a settled upload (the fresh named line with the
# stamp — test 2's shape, re-proven here as the pre-reload state).
_upload_via_page(page, tarball_b)
expect(page.locator(".toast")).to_have_text(
f"Successfully uploaded — {UPLOAD_B}"
)
result = page.locator("#archive-upload-result")
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
expect(result).to_have_text(_ready_line(UPLOAD_B))
# The run's identity (a second upload would reset it — the
# single-run claim is pinned on it).
before = _status(page, app_url, "/api/git-sources/upload/status")
assert before["state"] == "success", before
started_at = before["started_at"]
assert started_at, before
# The reload (the session cookie survives — the boot re-attach is
# the admin's).
page.reload()
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#git-sources-content")).to_be_visible()
# The boot re-render: the NAMELESS line WITH the stamp (phase
# 154, task 02 — the stamp from the server payload). The
# nameless shape has NO "(…)" and NO <name> — the regex's match
# IS the nameless+stamped proof (the pre-phase nameless string
# "Uploaded — press …" has no stamp at all).
expect(result).to_have_text(NAMELESS_READY_LINE, timeout=UPLOAD_TIMEOUT_MS)
m = NAMELESS_READY_LINE.fullmatch(result.text_content() or "")
assert m is not None, "the boot re-render line did not match the nameless + stamped shape"
assert m.group(1), (
"the stamp is empty in the boot re-render line"
)
# The page boots honest: no error banner, no toast at boot (A2 —
# the toast fired at the 202 on the previous document life), the
# never-stale restore (button re-enabled / "Upload").
expect(page.locator("#archive-upload-error")).to_be_hidden()
expect(page.locator(".toast")).to_have_count(0)
expect(page.locator("#archive-upload-btn")).to_be_enabled()
expect(page.locator("#archive-upload-btn")).to_have_text("Upload")
# The list shows exactly one row for the archive (the in-place
# identity survived the reload).
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
row = page.locator("#git-sources-tbody tr", has_text=UPLOAD_B)
expect(row).to_have_count(1)
expect(row.locator("span.git-source-kind")).to_have_text("Local")
# No second upload: the terminal status is the SAME run — its
# started_at is the one from before the reload.
terminal = _status(page, app_url, "/api/git-sources/upload/status")
assert terminal["state"] == "success", terminal
assert terminal["started_at"] == started_at, (
f"the run's identity changed (a second upload ran): {terminal['started_at']}"
)
assert terminal["detail"] == {"message": "uploaded"}, terminal
assert terminal["finished_at"], terminal
# ---------------------------------------------------------------------------
# 4. A running upload never renders the ready-for-sync line (the
# phase-90 regression guard, extended — the line appears ONLY on
# settle)
# ---------------------------------------------------------------------------
def test_running_upload_line_unchanged(
page: Page,
app_url: str,
db_ready: None,
tarball_c: Path,
) -> None:
"""At the 202 moment the upload run is in flight: the button is
the BARE "Processing…" (phase 90, A2 — no file, no `N remaining`
count, the title cleared) and ``#archive-upload-result`` is
HIDDEN — the ready-for-sync line NEVER renders during the run
(the phase-90 contract: the line appears ONLY on settle). The
deterministic in-flight window: the 202 handler synchronously
enters the processing state and schedules the first poll tick at
+2 s, so the "Processing…" state holds for ≥ 2 s regardless of
how fast the sub-second background run finishes — no delay proxy
is needed (the upload makes zero LLM calls). Then the poll's
success tick settles the line (the NAMED shape with the
completion stamp — the line appears only on settle), with the
never-stale restore and no error UI throughout."""
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
btn = page.locator("#archive-upload-btn")
expect(btn).to_be_enabled()
expect(btn).to_have_text("Upload")
expect(page.locator("#archive-upload-error")).to_be_hidden()
# The 202 moment (the toast fires at the 202 — the run is in
# flight from here until the poll's success tick). The submit
# handler hid the result line before the fetch (a new attempt
# starts clean), so at the in-flight moment the line is hidden
# even though the shared module app's boot re-render may have
# re-shown the PREVIOUS test's settled line at page load (the
# phase-154 boot re-attach — the line's self-dating contract,
# test 3's subject — not a partial-state regression).
_upload_via_page(page, tarball_c)
expect(page.locator(".toast")).to_have_text(
f"Successfully uploaded — {UPLOAD_C}"
)
# The IN-FLIGHT state (the ≥ 2 s window — deterministic): the
# bare "Processing…" label (phase 90, A2), the button disabled
# (the poll owns it — PLAN §7.4), the title cleared, and the
# ready-for-sync line HIDDEN (the line appears ONLY on settle).
expect(btn).to_be_disabled()
expect(btn).to_have_text("Processing…")
expect(btn).to_have_attribute("title", "")
expect(page.locator("#archive-upload-result")).to_be_hidden()
expect(page.locator("#archive-upload-error")).to_be_hidden()
# The in-flight status: the bare payload (phase 90, A2 — the
# run's unpack + register is in flight or just finished; either
# way the line is the poll tick's job, not the in-flight state's).
running = _status(page, app_url, "/api/git-sources/upload/status")
assert running["state"] in ("running", "success"), running
# The SETTLE (the poll's success tick — the line appears ONLY
# here, and the line is THIS run's — the named shape with the
# completion stamp; the distinct safe name also proves the
# previous test's boot re-render line was replaced, not carried
# through) + the never-stale restore, no error UI.
expect(page.locator("#archive-upload-result")).to_have_text(
_ready_line(UPLOAD_C), timeout=UPLOAD_TIMEOUT_MS
)
expect(btn).to_be_enabled()
expect(btn).to_have_text("Upload")
expect(page.locator("#archive-upload-error")).to_be_hidden()
status = _status(page, app_url, "/api/git-sources/upload/status")
assert status["state"] == "success", status
assert status["detail"] == {"message": "uploaded"}, status
assert status["finished_at"], status