All 4 task files exist in `complete/`, and only `00_phase.md` remains in `todo/`. This is the final verification pass. I'll read through the task files and check the current state of the code.
770 lines
34 KiB
Python
770 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,
|
|
)
|
|
|
|
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(
|
|
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/m)"
|
|
counts, 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
|