**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/`.
771 lines
34 KiB
Python
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
|