feat(admin): one-click sources sync — admin-only button triggers git clone/pull + re-import + KB overview refresh with polled live status
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"""Phase 32 E2E (Playwright): the admin-only "Sync sources" button.
|
||||
|
||||
Story: ``.agent/user_stories/admin-sync-button.md``
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``; git on
|
||||
PATH — a documented environment prerequisite, phase 28):
|
||||
|
||||
uv run pytest tests/e2e/test_sync_button.py -v --no-cov
|
||||
|
||||
The story gate runs the **real** sync path end to end — a real
|
||||
``git clone`` of a local ``file://`` fixture repo (deterministic, no
|
||||
network), a real import against the mock LLM, a real KB-overview
|
||||
regeneration — plus the admin-only visibility and the full button
|
||||
lifecycle (§7.4: "Syncing…" → "Synced HH:MM" + counts, the idempotent
|
||||
second run, the 409 double trigger).
|
||||
|
||||
Per-module app env (the E2E conftest pattern, module-scoped): this
|
||||
story's app boots with ``BOR_GIT_SOURCES=file://<fixture repo>`` and
|
||||
its own ``BOR_SOURCES_DIR`` — the session app (no git sources) is
|
||||
never started in this isolated run, so no port clash.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_anonymous_sees_no_button`` → AC 1 + 3 (hidden button, 403s)
|
||||
2. ``test_admin_sync_lifecycle`` → AC 2 + 4 (full lifecycle + idempotent
|
||||
second run + the fresh ``kb_overview`` row)
|
||||
3. ``test_double_trigger_409`` → AC 2 (one sync at a time)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import KbOverview
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
APP_PORT,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
#: The unique sentinel inside the fixture doc (task 03 step 1) — its
|
||||
#: import into the Sources table proves the REAL clone was indexed.
|
||||
SENTINEL = "RESE-SYNC-SENTINEL-9b2c"
|
||||
FIXTURE_DOC = "notes/sync-fixture.md"
|
||||
|
||||
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
||||
#: fmtSyncTime), any hour/minute.
|
||||
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
|
||||
|
||||
#: Real git clone + embed against the mock LLM — generous budget
|
||||
#: (task 03: the sync can legitimately take a while, no client timeout).
|
||||
SYNC_TIMEOUT_MS = 60_000
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> None:
|
||||
"""Run git in *cwd*; a non-zero exit fails the fixture loudly."""
|
||||
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise AssertionError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sync_git_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""A real one-commit git repo the sync must clone (task 03 step 1).
|
||||
|
||||
``tmp_path`` is function-scoped while the module-scoped app fixture
|
||||
needs the repo for the module's lifetime, so it is built under
|
||||
``tmp_path_factory`` (the same pytest-managed temp area, module-safe)
|
||||
via real ``git`` subprocess calls.
|
||||
"""
|
||||
root = tmp_path_factory.mktemp("sync_git")
|
||||
repo = root / "homelab-notes"
|
||||
(repo / "notes").mkdir(parents=True)
|
||||
(repo / "notes" / "sync-fixture.md").write_text(
|
||||
"# Sync fixture note\n"
|
||||
"\n"
|
||||
"One small note that exists only to prove the admin sync button\n"
|
||||
"end to end: a real git clone, a real import, a real KB overview\n"
|
||||
"regeneration.\n"
|
||||
"\n"
|
||||
f"Marker: {SENTINEL}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_git(repo, "init", "-q")
|
||||
_git(repo, "add", "-A")
|
||||
_git(
|
||||
repo,
|
||||
"-c", "user.email=e@x", "-c", "user.name=t",
|
||||
"-c", "commit.gpgsign=false", # the fixture commit never signs
|
||||
"commit", "-qm", "one",
|
||||
)
|
||||
assert (repo / ".git").is_dir()
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, sync_git_repo: Path) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: the sync's subject is a
|
||||
real ``file://`` git source with its own checkout dir (the conftest
|
||||
session app boots without ``BOR_GIT_SOURCES`` and is never started
|
||||
in this isolated run)."""
|
||||
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) — keeps every story
|
||||
# suite's deterministic gate behavior; production default stays 0.62.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
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
|
||||
# Phase 32: the sync button's subject — one real local repo.
|
||||
env["BOR_GIT_SOURCES"] = f"file://{sync_git_repo}"
|
||||
env["BOR_SOURCES_DIR"] = str(sync_git_repo.parent / "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")
|
||||
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_kb() -> None:
|
||||
"""Fresh KB per test (the E2E isolation pattern): the sync's counts
|
||||
and the ``kb_overview`` row must be the sync's own doing."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_kb(db_ready: None) -> Iterator[None]:
|
||||
_truncate_kb()
|
||||
yield
|
||||
|
||||
|
||||
def _overview_row() -> KbOverview | None:
|
||||
with SessionLocal() as db:
|
||||
return db.get(KbOverview, 1)
|
||||
|
||||
|
||||
def _wait_sync_done(page: Page, app_url: str, timeout_s: float = 60.0) -> dict[str, Any]:
|
||||
"""Poll the (cookie-authenticated) status endpoint until the run
|
||||
reaches a terminal state — exactly what the UI's 2 s poll loop
|
||||
observes."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = page.request.get(f"{app_url}/api/sync/status")
|
||||
assert r.status == 200
|
||||
body = r.json()
|
||||
if body["state"] in ("success", "failed"):
|
||||
return body
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
||||
|
||||
|
||||
# --- 1. Anonymous: no button, locked endpoints ----------------------------
|
||||
|
||||
|
||||
def test_anonymous_sees_no_button(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC 1 + 3 — anonymous: ``#sync-btn`` never leaves ``hidden`` (the
|
||||
whoami reveal is admin-only, so for a logged-out visitor it is
|
||||
absent from the *revealed* DOM) and both sync endpoints answer
|
||||
403 — the admin-only surface (A10 extended, phase 16 pattern)."""
|
||||
page.goto(f"{app_url}/sources.html")
|
||||
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||
# The same whoami gate that hides the button also hides the admin
|
||||
# nav link and shows the catalog sign-in gate.
|
||||
expect(page.locator("#nav-sources")).to_be_hidden()
|
||||
expect(page.locator("#sources-gate")).to_be_visible()
|
||||
assert page.request.get(f"{app_url}/api/sync/status").status == 403
|
||||
assert page.request.post(f"{app_url}/api/sync").status == 403
|
||||
|
||||
|
||||
# --- 2. Admin: the full lifecycle against the real sync path --------------
|
||||
|
||||
|
||||
def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC 2 + 4 — click → "Syncing…" (disabled) → "Synced HH:MM" + the
|
||||
counts, against the REAL pipeline (git clone of the ``file://``
|
||||
fixture → import with prune → KB overview regeneration, mock LLM).
|
||||
Then the idempotent second run: fast-forward pull + sha256 hash
|
||||
skip → "0 added · 1 unchanged"."""
|
||||
login(page, app_url) # lands on /sources.html (the button's home)
|
||||
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible()
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
|
||||
# --- run 1: clone + import + overview --------------------------------
|
||||
btn.click()
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
|
||||
# The fixture doc (one A9-format file) is the only change.
|
||||
expect(page.locator("#sync-result")).to_have_text("1 added")
|
||||
|
||||
# The REAL clone was imported: the fixture path is in the Sources
|
||||
# table (the sentinel lives inside it).
|
||||
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
|
||||
|
||||
# Phase-31 regeneration ran (the import changed the KB): the single
|
||||
# kb_overview row is fresh and non-empty (DB check — truncated
|
||||
# before this test, so it is the sync's own doing).
|
||||
row = _overview_row()
|
||||
assert row is not None and row.content.strip(), (
|
||||
"kb_overview must be regenerated by a successful, KB-changing sync"
|
||||
)
|
||||
|
||||
# --- run 2: idempotent pull + hash skip -------------------------------
|
||||
btn.click()
|
||||
expect(btn).to_be_disabled()
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||
expect(btn).to_be_enabled()
|
||||
# Nothing re-embedded (sha256 delta) — the no-op run announces the
|
||||
# unchanged count instead of an empty live region.
|
||||
expect(page.locator("#sync-result")).to_have_text("0 added · 1 unchanged")
|
||||
# The doc survived the prune re-import (its file is still in the repo).
|
||||
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
|
||||
|
||||
|
||||
# --- 3. Concurrency: one sync at a time ------------------------------------
|
||||
|
||||
|
||||
def test_double_trigger_409(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC 2 — a second trigger while a run is in flight gets 409
|
||||
("a sync is already running"); the UI adopts the in-flight run
|
||||
instead of starting a second one. The E2E path adds that the REAL
|
||||
run behind the 409 still completes (the adopted run is the one and
|
||||
only run)."""
|
||||
login(page, app_url)
|
||||
|
||||
first = page.request.post(f"{app_url}/api/sync")
|
||||
assert first.status == 202, first.text
|
||||
second = page.request.post(f"{app_url}/api/sync")
|
||||
assert second.status == 409, second.text
|
||||
assert second.json()["detail"] == "a sync is already running"
|
||||
|
||||
# The adopted (single) run still completes successfully.
|
||||
body = _wait_sync_done(page, app_url)
|
||||
assert body["state"] == "success", body
|
||||
assert body["detail"]["added"] == 1 # the fixture doc, fresh after the truncate
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Integration: the admin sources-sync API (phase 32, task 01).
|
||||
|
||||
Covers the in-process sync runner end to end over HTTP: anonymous 403s
|
||||
on both endpoints; admin idle → 202 → ``success`` with the full
|
||||
ImportSummary detail; 409 on a double trigger while a run is in flight;
|
||||
``GitSyncError`` → ``failed`` with the failing repo named and **zero**
|
||||
import attempts; empty ``BOR_GIT_SOURCES`` → ``failed`` loudly; an
|
||||
embedding failure → ``failed`` with any credentials masked; the import
|
||||
always runs with ``prune=True``; and the phase-31 overview trigger is
|
||||
change-gated (no ``lite`` call on an unchanged KB).
|
||||
|
||||
The git / import / overview layers are monkeypatched in ``app.api.sync``
|
||||
(same fake style as ``test_import_docs_git.py``) — no real git, no real
|
||||
DB, no LLM: the runner's state machine and HTTP surface are under test.
|
||||
|
||||
The admin client is used **as a context manager** on purpose: the
|
||||
background sync task lives on the app's event loop, so the loop must
|
||||
survive across requests — exactly how the app runs under uvicorn.
|
||||
(A TestClient without the context manager starts a fresh loop per
|
||||
request and would cancel the task on request exit.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import sync as sync_api
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.rag.importer import ImportSummary
|
||||
from app.rag.llm import EmbeddingError, LLMClient
|
||||
from scripts.git_sync import GitSyncError
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_sync_state() -> Iterator[None]:
|
||||
"""The module-level status object + task are process-global: reset them
|
||||
around every test (both before — a previous test's terminal state
|
||||
would leak into the idle assertion — and after)."""
|
||||
sync_api._status = sync_api.SyncStatus()
|
||||
sync_api._task = None
|
||||
yield
|
||||
sync_api._status = sync_api.SyncStatus()
|
||||
sync_api._task = None
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sync_client() -> Iterator[TestClient]:
|
||||
"""Context-managed TestClient — one app event loop across requests
|
||||
(the background task must survive between the POST and the polls)."""
|
||||
with TestClient(fastapi_app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def _settings(git_sources: str = "", sources_dir: str = "~/bor-sources") -> Settings:
|
||||
"""Fresh settings (no .env file); explicit kwargs beat any env leaks."""
|
||||
return Settings(_env_file=None, git_sources=git_sources, sources_dir=sources_dir) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def _login(client: TestClient) -> None:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
||||
|
||||
|
||||
def _poll(client: TestClient, want: str, timeout: float = 5.0) -> dict:
|
||||
"""Poll ``GET /api/sync/status`` until ``state == want`` (terminal).
|
||||
|
||||
Any state other than ``running`` before the deadline fails loudly —
|
||||
an unexpected ``failed`` must never be masked by the wait.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
body = client.get("/api/sync/status").json()
|
||||
if body["state"] == want:
|
||||
return body
|
||||
assert body["state"] == "running", (
|
||||
f"unexpected state {body['state']!r} while waiting for {want!r}: {body}"
|
||||
)
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f"sync did not reach {want!r} within {timeout}s")
|
||||
|
||||
|
||||
class FakeImportSources:
|
||||
"""Records every ``import_sources`` call; returns a canned summary."""
|
||||
|
||||
def __init__(self, summary: ImportSummary, delay: float = 0.0) -> None:
|
||||
self.summary = summary
|
||||
self.delay = delay
|
||||
self.sources: list[list[Path]] = []
|
||||
self.llms: list[LLMClient] = []
|
||||
self.prune_flags: list[bool] = []
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
sources: list[Path],
|
||||
llm: LLMClient,
|
||||
*,
|
||||
prune: bool = False,
|
||||
limit: int | None = None,
|
||||
session: Session | None = None,
|
||||
) -> ImportSummary:
|
||||
self.sources.append(list(sources))
|
||||
self.llms.append(llm)
|
||||
self.prune_flags.append(prune)
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
return self.summary
|
||||
|
||||
|
||||
class FakeOverview:
|
||||
"""Records every ``regenerate_overview`` call; canned result."""
|
||||
|
||||
def __init__(self, ok: bool = True) -> None:
|
||||
self.ok = ok
|
||||
self.llms: list[LLMClient] = []
|
||||
|
||||
async def __call__(self, llm: LLMClient, session: Session | None = None) -> bool:
|
||||
self.llms.append(llm)
|
||||
return self.ok
|
||||
|
||||
|
||||
def _fake_clone() -> tuple[list[tuple[str, Path]], object]:
|
||||
"""A ``clone_or_pull`` that materialises a checkout with one .md file."""
|
||||
calls: list[tuple[str, Path]] = []
|
||||
|
||||
def fake_clone_or_pull(url: str, dest: Path | str) -> Path:
|
||||
dest = Path(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
(dest / "notes.md").write_text(f"# {dest.name}\ncontent for the KB\n", encoding="utf-8")
|
||||
calls.append((url, dest))
|
||||
return dest
|
||||
|
||||
return calls, fake_clone_or_pull
|
||||
|
||||
|
||||
# --- anonymous -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_gets_403_on_both_endpoints(client: TestClient) -> None:
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
r = client.post("/api/sync")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
# --- admin: success --------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_sync_success_reports_full_detail(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
clone_calls, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
summary = ImportSummary(
|
||||
files=5, added=1, updated=2, unchanged=2, pruned=3, errors=0,
|
||||
chunks=11, embed_batches=4, summaries=1, summary_errors=0,
|
||||
)
|
||||
fake_import = FakeImportSources(summary)
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
fake_overview = FakeOverview(ok=True)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview)
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.get("/api/sync/status").json() == {
|
||||
"state": "idle",
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"detail": {},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
r = sync_client.post("/api/sync")
|
||||
assert r.status_code == 202
|
||||
assert r.json() == {"detail": "sync started"}
|
||||
|
||||
body = _poll(sync_client, "success")
|
||||
assert body["error"] is None
|
||||
# ISO-8601 timestamps round-trip; finished after started.
|
||||
started = datetime.fromisoformat(body["started_at"])
|
||||
finished = datetime.fromisoformat(body["finished_at"])
|
||||
assert finished >= started
|
||||
# Every ImportSummary field + the overview flag, verbatim.
|
||||
assert body["detail"] == {
|
||||
"files": 5, "added": 1, "updated": 2, "unchanged": 2, "pruned": 3,
|
||||
"errors": 0, "chunks": 11, "summaries": 1, "summary_errors": 0,
|
||||
"overview": True,
|
||||
}
|
||||
# Git: the configured repo was cloned into BOR_SOURCES_DIR/<repo-name>/.
|
||||
assert clone_calls == [(repo_url, tmp_path / "bor" / "repo")]
|
||||
# Import: exactly the checkouts, with prune=True (the button is the
|
||||
# canonical "mirror the repos" action) and a real LLMClient.
|
||||
assert fake_import.sources == [[tmp_path / "bor" / "repo"]]
|
||||
assert fake_import.prune_flags == [True]
|
||||
assert len(fake_import.llms) == 1
|
||||
assert isinstance(fake_import.llms[0], LLMClient)
|
||||
# Overview: refreshed (added + updated > 0) with the same client.
|
||||
assert fake_overview.llms == [fake_import.llms[0]]
|
||||
|
||||
|
||||
def test_unchanged_kb_skips_overview_refresh(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Phase-31 trigger is change-gated: added + updated == 0 → no ``lite`` call."""
|
||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
summary = ImportSummary(files=7, added=0, updated=0, unchanged=7, pruned=0)
|
||||
fake_import = FakeImportSources(summary)
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
fake_overview = FakeOverview(ok=True)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview)
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
|
||||
assert body["detail"]["overview"] is False
|
||||
assert fake_overview.llms == [] # no wasted model call
|
||||
assert len(fake_import.llms) == 1 # the import itself ran
|
||||
|
||||
|
||||
# --- admin: concurrency ----------------------------------------------------
|
||||
|
||||
|
||||
def test_double_trigger_while_running_returns_409(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
# The in-flight run takes a while (asyncio.sleep) so the second POST
|
||||
# lands while it is still running.
|
||||
fake_import = FakeImportSources(ImportSummary(files=1, added=1), delay=0.5)
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
|
||||
r = sync_client.post("/api/sync") # second trigger while running
|
||||
assert r.status_code == 409
|
||||
assert r.json() == {"detail": "a sync is already running"}
|
||||
|
||||
body = sync_client.get("/api/sync/status").json()
|
||||
assert body["state"] == "running"
|
||||
assert body["started_at"] is not None
|
||||
assert body["finished_at"] is None
|
||||
assert body["error"] is None
|
||||
|
||||
# The (single) run completes; the import ran exactly once.
|
||||
_poll(sync_client, "success")
|
||||
assert len(fake_import.sources) == 1
|
||||
|
||||
|
||||
# --- admin: failures -------------------------------------------------------
|
||||
|
||||
|
||||
def test_git_failure_marks_failed_and_skips_import(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
repo_url = f"file://{tmp_path / 'bad.git'}"
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
|
||||
def failing_clone(url: str, dest: Path | str) -> Path:
|
||||
raise GitSyncError(
|
||||
f"git clone --depth 1 {url} failed (exit 128): "
|
||||
"fatal: repository not found"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", failing_clone)
|
||||
fake_import = FakeImportSources(ImportSummary())
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
fake_overview = FakeOverview(ok=True)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", fake_overview)
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
|
||||
body = _poll(sync_client, "failed")
|
||||
assert "bad.git" in body["error"] # the failing repo is named
|
||||
assert "fatal: repository not found" in body["error"]
|
||||
assert body["detail"] == {}
|
||||
assert body["finished_at"] is not None
|
||||
assert fake_import.sources == [] # no partial import
|
||||
assert fake_overview.llms == []
|
||||
|
||||
# A failed run leaves the system restartable: a new POST is accepted.
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
_poll(sync_client, "failed")
|
||||
|
||||
|
||||
def test_no_git_sources_configured_fails_loudly(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
# Whitespace-only is just as unconfigured as empty.
|
||||
lambda: _settings(git_sources=" , ", sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
clone_calls, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
fake_import = FakeImportSources(ImportSummary())
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
|
||||
body = _poll(sync_client, "failed")
|
||||
assert body["error"] == "no git sources configured (BOR_GIT_SOURCES)"
|
||||
assert clone_calls == [] # git is never touched
|
||||
assert fake_import.sources == []
|
||||
|
||||
|
||||
def test_import_error_is_reported_with_credentials_masked(
|
||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
|
||||
async def failing_import(
|
||||
sources: list[Path],
|
||||
llm: LLMClient,
|
||||
*,
|
||||
prune: bool = False,
|
||||
limit: int | None = None,
|
||||
session: Session | None = None,
|
||||
) -> ImportSummary:
|
||||
raise EmbeddingError(
|
||||
"embeddings request to https://user:secret@aipi.reeseapps.com/v1 "
|
||||
"failed: connection refused"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sync_api, "import_sources", failing_import)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
|
||||
body = _poll(sync_client, "failed")
|
||||
assert "*****@aipi.reeseapps.com" in body["error"] # credentials masked
|
||||
assert "user:secret" not in body["error"]
|
||||
assert "connection refused" in body["error"] # the reason survives
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Unit: the admin "Sync sources" button contract (phase 32, task 02).
|
||||
|
||||
The browser behavior is E2E-covered (tests/e2e/test_sync_button.py,
|
||||
task 03); here we pin the source-level wiring — the anonymous-safe
|
||||
ship-hidden button markup, the header.js admin reveal on the SAME
|
||||
cached whoami (no extra fetch), the sources.js sync state machine
|
||||
(2 s poll, 202 start / 409 adoption / 403 hide, terminal labels,
|
||||
the aria-live result, the single-poll-loop guard, no client-side hard
|
||||
timeout), the §7.4 never-stale CSS (spin + reduced-motion opt-out,
|
||||
disabled state, 44px floor, contrast pair) — so a silent regression is
|
||||
caught without a browser.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
SOURCES_JS = ASSETS / "sources.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
assert path.is_file(), f"missing frontend file: {path}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _body(js: str, fn_name: str) -> str:
|
||||
"""The source of the first top-level `function <fn_name>` in js."""
|
||||
fn = js.find(f"function {fn_name}")
|
||||
assert fn != -1, f"{fn_name} must be defined"
|
||||
return js[fn : js.find("\n}", fn)]
|
||||
|
||||
|
||||
# ---------- sources.html: anonymous-safe ship-hidden markup ----------
|
||||
|
||||
|
||||
def test_sync_button_ships_hidden_and_labeled() -> None:
|
||||
"""#sync-btn SHIPS with the hidden attribute (anonymous-safe —
|
||||
header.js reveals it for the admin), is a real <button type=
|
||||
"button">, and carries aria-label="Sync sources" so the accessible
|
||||
name stays stable across the label states."""
|
||||
tag = re.search(r"<button[^>]*id=\"sync-btn\"[^>]*>", _text(SOURCES_HTML))
|
||||
assert tag, "sources.html must carry the #sync-btn button"
|
||||
attrs = tag.group(0)
|
||||
assert 'class="sync-btn"' in attrs
|
||||
assert 'type="button"' in attrs
|
||||
assert re.search(r"\bhidden\b", attrs), "#sync-btn must ship hidden"
|
||||
assert 'aria-label="Sync sources"' in attrs
|
||||
|
||||
|
||||
def test_sync_button_has_icon_and_label_span() -> None:
|
||||
"""The button body is a refresh-cycle svg (aria-hidden — decorative,
|
||||
the spin is the visible running state) + the #sync-label span with
|
||||
the idle text, so the label can be swapped by sources.js."""
|
||||
text = _text(SOURCES_HTML)
|
||||
btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))]
|
||||
assert re.search(r'<svg[^>]*class="sync-icon"[^>]*aria-hidden="true"', btn)
|
||||
assert 'id="sync-label"' in btn
|
||||
assert re.search(r'<span[^>]*id="sync-label"[^>]*>Sync sources</span>', btn)
|
||||
|
||||
|
||||
def test_sync_result_is_the_aria_live_announcer() -> None:
|
||||
"""#sync-result sits right after the button and is a polite live
|
||||
region (role="status" + aria-live="polite") — the last-result /
|
||||
counts announcement for screen readers."""
|
||||
text = _text(SOURCES_HTML)
|
||||
tag = re.search(r'<span[^>]*id="sync-result"[^>]*>', text)
|
||||
assert tag, "sources.html must carry the #sync-result announcer"
|
||||
attrs = tag.group(0)
|
||||
assert 'role="status"' in attrs
|
||||
assert 'aria-live="polite"' in attrs
|
||||
assert text.find('id="sync-result"') > text.find("</button>", text.find('id="sync-btn"'))
|
||||
|
||||
|
||||
def test_sync_error_banner_is_a_hidden_alert() -> None:
|
||||
"""The failure banner uses the chat error-banner markup style
|
||||
(kb-banner + is-error) and role="alert", shipping hidden —
|
||||
sources.js un-hides it with the error text on a failed run."""
|
||||
text = _text(SOURCES_HTML)
|
||||
tag = re.search(r'<div[^>]*id="sync-error-banner"[^>]*>', text)
|
||||
assert tag, "sources.html must carry the #sync-error-banner"
|
||||
attrs = tag.group(0)
|
||||
assert "kb-banner" in attrs and "is-error" in attrs
|
||||
assert 'role="alert"' in attrs
|
||||
assert re.search(r"\bhidden\b", attrs)
|
||||
assert 'id="sync-error-text"' in text
|
||||
# The banner lives in the page content, not the 64px header bar.
|
||||
assert text.find('id="sync-error-banner"') > text.find('<main id="main"')
|
||||
|
||||
|
||||
def test_page_sub_copy_mentions_the_button() -> None:
|
||||
"""The page-sub copy still points at the import CLI and now names
|
||||
the header button as the one-click alternative (task 02 step 1)."""
|
||||
sub = re.search(r'<p class="page-sub">(.*?)</p>', _text(SOURCES_HTML), re.DOTALL)
|
||||
assert sub, "sources.html must keep the .page-sub copy"
|
||||
assert "Re-run the import" in sub.group(1)
|
||||
assert "Sync sources" in sub.group(1)
|
||||
assert "header" in sub.group(1)
|
||||
|
||||
|
||||
def test_sources_page_stays_cdn_free() -> None:
|
||||
"""No-CDN rule (PLAN §7.3, A11): the new button markup adds no
|
||||
external references — same-origin assets only (the integration
|
||||
test_index_html_served_locally re-checks this on the served page)."""
|
||||
text = _text(SOURCES_HTML)
|
||||
assert 'src="https://' not in text
|
||||
assert 'href="https://' not in text
|
||||
|
||||
|
||||
# ---------- header.js: the admin reveal ----------
|
||||
|
||||
|
||||
def test_header_reveals_sync_btn_on_the_admin_branch() -> None:
|
||||
"""initSharedHeader reveals #sync-btn in the SAME admin branch as
|
||||
#nav-sources (querySelector + hidden = !admin) — one cached whoami,
|
||||
no extra whoami call; anonymous users never leave the hidden
|
||||
default."""
|
||||
js = _text(HEADER_JS)
|
||||
fn = js.find("function initSharedHeader")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert 'querySelector("#sync-btn")' in body, "#sync-btn must join the admin reveal"
|
||||
assert "syncBtn.hidden = !admin" in body
|
||||
# The reveal must not introduce a second whoami call site.
|
||||
assert js.count('fetch("/api/whoami")') == 1
|
||||
|
||||
|
||||
# ---------- sources.js: the sync state machine ----------
|
||||
|
||||
|
||||
def test_sources_js_calls_the_sync_api() -> None:
|
||||
"""The click posts to POST /api/sync and the poll loop GETs
|
||||
/api/sync/status — both through the same-origin API (A10)."""
|
||||
js = _text(SOURCES_JS)
|
||||
assert 'fetch("/api/sync", { method: "POST" })' in js
|
||||
assert 'fetch("/api/sync/status")' in js
|
||||
|
||||
|
||||
def test_sources_js_polls_every_2000ms() -> None:
|
||||
"""The feedback loop is a 2000 ms poll of the status endpoint,
|
||||
re-scheduled one tick at a time (setTimeout, not setInterval — an
|
||||
in-flight fetch can never overlap the next tick)."""
|
||||
js = _text(SOURCES_JS)
|
||||
assert "SYNC_POLL_MS = 2000" in js
|
||||
assert "setTimeout(tick, SYNC_POLL_MS)" in js
|
||||
assert "setInterval" not in js
|
||||
|
||||
|
||||
def test_sources_js_adopts_409_and_starts_on_202() -> None:
|
||||
"""202 (started) and 409 (a run started elsewhere — e.g. a second
|
||||
tab) both enter the running state and start polling: the UI never
|
||||
starts a second run, it adopts the in-flight one."""
|
||||
js = _text(SOURCES_JS)
|
||||
assert "r.status === 202 || r.status === 409" in js
|
||||
idx = js.find("r.status === 202 || r.status === 409")
|
||||
branch = js[idx : idx + 200]
|
||||
assert "enterRunningState()" in branch
|
||||
assert "startSyncPolling()" in branch
|
||||
|
||||
|
||||
def test_sources_js_hides_the_button_on_403() -> None:
|
||||
"""A 403 anywhere (POST or poll) is treated as not-admin: the
|
||||
button hides — defense in depth behind header.js's whoami reveal."""
|
||||
js = _text(SOURCES_JS)
|
||||
for occurrence in re.finditer(r"r\.status === 403", js):
|
||||
window = js[occurrence.start() : occurrence.start() + 400]
|
||||
assert "syncBtn.hidden = true" in window, "every 403 branch must hide the button"
|
||||
assert len(re.findall(r"r\.status === 403", js)) >= 3, (
|
||||
"POST, the status poll, and the load re-attach must all handle 403"
|
||||
)
|
||||
|
||||
|
||||
def test_sources_js_running_state_is_never_stale() -> None:
|
||||
"""Entering the running state disables the button, sets aria-busy,
|
||||
spins the icon, and swaps the label to 'Syncing…' (the §7.4
|
||||
feedback while the poll waits)."""
|
||||
js = _text(SOURCES_JS)
|
||||
fn = js.find("function enterRunningState")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert "syncBtn.disabled = true" in body
|
||||
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
|
||||
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
||||
assert '"Syncing…"' in body
|
||||
|
||||
|
||||
def test_sources_js_terminal_states() -> None:
|
||||
"""Terminal rendering: success → enabled + 'Synced HH:MM' (local
|
||||
time of finished_at) + the last-result counts ('added' always
|
||||
announced, zero terms omitted — a no-op re-sync reads '0 added ·
|
||||
1 unchanged', never an empty live region) + a live catalog refresh
|
||||
(the KB just changed — never a stale table); failed → enabled +
|
||||
retry-ready 'Sync sources' label + the role='alert' banner with
|
||||
the error; the result is cleared on a failure."""
|
||||
js = _text(SOURCES_JS)
|
||||
|
||||
success = _body(js, "applySyncSuccess")
|
||||
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
|
||||
assert "fmtSyncResult(status.detail)" in success
|
||||
# A successful sync just changed the KB: the catalog re-fetches live
|
||||
# (table / stats / empty state never sit stale under "Synced").
|
||||
assert "loadDocs()" in success
|
||||
|
||||
failure = _body(js, "applySyncFailure")
|
||||
assert 'settleSyncButton("Sync sources")' in failure # retry-ready
|
||||
assert "showSyncError(status.error)" in failure
|
||||
|
||||
result = _body(js, "fmtSyncResult")
|
||||
# "added" is the always-announced headline term; "unchanged" covers
|
||||
# the no-op case ("0 added · 1 unchanged"); updated/pruned are
|
||||
# zero-omitted.
|
||||
assert "added" in result and "unchanged" in result
|
||||
assert " · " in result
|
||||
assert "> 0" in result, "zero terms must be omitted"
|
||||
|
||||
time = _body(js, "fmtSyncTime")
|
||||
assert "getHours()" in time and "getMinutes()" in time, "local HH:MM of finished_at"
|
||||
|
||||
|
||||
def test_sources_js_settles_the_button_on_terminal() -> None:
|
||||
"""settleSyncButton re-enables the control, drops aria-busy, and
|
||||
un-spins the icon — the button can never sit disabled after a run
|
||||
reaches a terminal state (failed included: retry-ready)."""
|
||||
js = _text(SOURCES_JS)
|
||||
fn = js.find("function settleSyncButton")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert "syncBtn.disabled = false" in body
|
||||
assert 'syncBtn.removeAttribute("aria-busy")' in body
|
||||
assert "syncIcon.classList.remove(\"is-spinning\")" in body
|
||||
|
||||
|
||||
def test_sources_js_never_starts_a_second_poll_loop() -> None:
|
||||
"""startSyncPolling is guarded by the module-level timer: a 409
|
||||
adoption, a reload re-attach, or a stray call can never run two
|
||||
poll loops at once (phase completion criterion)."""
|
||||
js = _text(SOURCES_JS)
|
||||
fn = js.find("function startSyncPolling")
|
||||
assert fn != -1
|
||||
head = js[fn : js.find("const tick", fn)]
|
||||
assert re.search(r"if\s*\(\s*syncPollTimer\s*!==\s*null\s*\)\s*return", head), (
|
||||
"the single-loop guard must be the first statement"
|
||||
)
|
||||
assert "clearTimeout(syncPollTimer)" in _body(js, "stopSyncPolling")
|
||||
|
||||
|
||||
def test_sources_js_has_no_client_side_hard_timeout() -> None:
|
||||
"""Phase locked decision: a sync can legitimately run for minutes,
|
||||
so there is NO client-side hard timeout — the 2 s poll is the
|
||||
feedback loop and the server state is authoritative (the 120 s
|
||||
LLM-turn guard must not leak into the sync path)."""
|
||||
js = _text(SOURCES_JS)
|
||||
assert "TURN_TIMEOUT" not in js
|
||||
assert "120" not in js[js.find("Phase 32") :], (
|
||||
"no turn-timeout constant in the sync section"
|
||||
)
|
||||
|
||||
|
||||
def test_sources_js_reattaches_on_load() -> None:
|
||||
"""initSyncButton (run from the IIFE on the admin path, after
|
||||
initSharedHeader) fetches the status once and re-enters the running
|
||||
state on 'running' (reload mid-sync) or renders the last result on
|
||||
a terminal state; the click binding wires startSync to the button."""
|
||||
js = _text(SOURCES_JS)
|
||||
fn = js.find("function initSyncButton")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'fetch("/api/sync/status")' in body
|
||||
assert 'status.state === "running"' in body
|
||||
assert 'status.state === "success"' in body
|
||||
assert 'status.state === "failed"' in body
|
||||
assert "syncBtn.addEventListener(\"click\", startSync)" in js
|
||||
# The IIFE runs it on the admin path only (after the whoami gate).
|
||||
iife = js[js.find("(async () => {") :]
|
||||
admin_idx = iife.find("await isAdmin()")
|
||||
init_idx = iife.find("initSyncButton();")
|
||||
assert -1 < admin_idx < init_idx, "re-attach must run only for the admin"
|
||||
|
||||
|
||||
# ---------- styles.css: the §7.4 states ----------
|
||||
|
||||
|
||||
def test_sync_button_css_ghost_pill_and_disabled_state() -> None:
|
||||
""".sync-btn is the same ghost pill as .new-chat-btn (contrast pair
|
||||
ink-soft on surface ≈6.9:1 ≥ 4.5:1), with a ≥44px touch floor and a
|
||||
:disabled state (never stale — the busy look is visible)."""
|
||||
css = _text(STYLES_CSS)
|
||||
block = re.search(r"\.sync-btn\s*\{([^}]*)\}", css)
|
||||
assert block, "styles.css must define .sync-btn"
|
||||
body = block.group(1)
|
||||
assert "min-height: 44px" in body
|
||||
assert "color: var(--ink-soft)" in body
|
||||
assert "border-radius: 999px" in body
|
||||
disabled = re.search(r"\.sync-btn:disabled\s*\{([^}]*)\}", css)
|
||||
assert disabled, ".sync-btn:disabled must be styled"
|
||||
assert "cursor: wait" in disabled.group(1)
|
||||
|
||||
|
||||
def test_sync_icon_spins_and_respects_reduced_motion() -> None:
|
||||
"""The running state spins the refresh icon on the shared spin
|
||||
keyframes (1s linear infinite), and prefers-reduced-motion stills
|
||||
it — the existing opt-out pattern."""
|
||||
css = _text(STYLES_CSS)
|
||||
spin = re.search(r"\.sync-btn \.sync-icon\.is-spinning\s*\{([^}]*)\}", css)
|
||||
assert spin, "the .is-spinning state must be styled"
|
||||
assert "animation: spin 1s linear infinite" in spin.group(1)
|
||||
spinner = r"\.sync-btn \.sync-icon\.is-spinning\s*\{([^}]*)\}"
|
||||
reduced = re.search(r"@media \(prefers-reduced-motion: reduce\)\s*\{\s*" + spinner, css)
|
||||
assert reduced, "the spin must opt out under prefers-reduced-motion"
|
||||
assert "animation: none" in reduced.group(1)
|
||||
assert "@keyframes spin" in css, "the spin keyframes are shared (pre-existing)"
|
||||
|
||||
|
||||
def test_sync_result_is_styled() -> None:
|
||||
"""#sync-result (the aria-live last-result line) is styled in the
|
||||
theme tokens — soft ink, small mono, no wrap in the header bar."""
|
||||
css = _text(STYLES_CSS)
|
||||
block = re.search(r"\.sync-result\s*\{([^}]*)\}", css)
|
||||
assert block, "styles.css must define .sync-result"
|
||||
assert "var(--ink-soft)" in block.group(1)
|
||||
Reference in New Issue
Block a user