**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass. - Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP. - Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests). - Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`). - Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed. - Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched. - Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
590 lines
25 KiB
Python
590 lines
25 KiB
Python
"""Phase 136 (task 02) E2E (Playwright): a force push no longer breaks
|
|
the sync — the checkout resets to the head of the tracked branch.
|
|
|
|
Source: ``TODO.md:5`` — the owner's verbatim repro: "Force pushes
|
|
break brain of reese git pull. Brain of reese should always have the
|
|
head of the branch specified, so if pull fails due to force pushes it
|
|
should just reset origin/<branch>."
|
|
|
|
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_force_push_resync.py -v --no-cov
|
|
|
|
The owner's exact flow is pinned end to end: sync (clone + import) →
|
|
force-push the origin (its history rewritten to an UNRELATED root
|
|
commit) → sync again → SUCCESS, and the KB holds the NEW head's
|
|
content. Pre-phase, that second sync landed ``failed``: the legacy
|
|
``git pull --ff-only`` (the ``branch=None`` env row) refused the
|
|
divergent head and the run died in the ``failed`` state with a
|
|
user-facing error. This module is the regression pin — the pin has
|
|
TEETH: against the pre-phase ``clone_or_pull`` (verified by
|
|
temporarily reverting the phase-136 change on a scratch branch, then
|
|
restoring), run 2 lands ``failed`` and the terminal-state assertion
|
|
fails, exactly as the pre-phase behavior demanded.
|
|
|
|
Per-module app env (the ``test_sync_button.py`` module-app pattern):
|
|
this module's app boots on its own port (a same-port second uvicorn
|
|
dies on bind and would drive the wrong server) with
|
|
``BOR_GIT_SOURCES=file://<fixture repo>`` (an env-fallback git row —
|
|
``branch=None``, the default-branch path the owner's repro exercises;
|
|
the registry is truncated per test, so the fallback is the effective
|
|
source list) and a dedicated ``BOR_SOURCES_DIR``. The session app (no
|
|
git sources) is never started in this isolated run, so no port clash.
|
|
|
|
The fixture (module-scoped, real ``git`` — the
|
|
``test_sync_button.sync_git_repo`` pattern): one branch (``main``),
|
|
one commit, ``notes/doc.md`` with ``Marker: RESE-FPPUSH-V1-5e8a``.
|
|
``_force_push_new_head`` rewrites the origin IN PLACE to a NEW
|
|
ORPHAN root commit (the branch ref deleted and recreated at the new
|
|
commit — full divergence, not just a new commit on top): the
|
|
``file://`` equivalent of a force push, whose OBSERVABLE contract is
|
|
that the origin's single branch now points at a commit with UNRELATED
|
|
history (no common ancestor with the old head — asserted) and the new
|
|
content. The app's next ``git fetch`` sees the divergent head.
|
|
|
|
Test → contract mapping (two tests):
|
|
|
|
1. ``test_sync_survives_a_force_push`` — the owner's exact flow,
|
|
through the UI: run 1 (clone + import: "1 added", the catalog
|
|
holds ``doc.md``) → the force push (V2) → run 2 (``state ==
|
|
"success"`` — NOT ``failed``; the UI settles to "Synced HH:MM"
|
|
with the error banner HIDDEN and no error modal — a reset is a
|
|
LOGGED event, not a user-facing failure; the ``#sync-progress``
|
|
box shows "0 added · 1 updated" (phase 150 — the single sync text
|
|
surface) and the document modal serves the V2
|
|
sentinel — the reset's content flowed through the import; the
|
|
counts are the secondary signal, the content is primary) → run 3
|
|
(idempotent: "0 added · 1 unchanged" — the checkout is stably at
|
|
the new head; the reset was not a one-off fluke, and "unchanged"
|
|
rules out drift back to V1, which would have re-reported as
|
|
"updated").
|
|
2. ``test_failed_pull_is_not_the_outcome`` — the negative pin at the
|
|
API seam (the task's PREFERRED direct-API leg, kept as its own
|
|
test rather than folded into test 1 as step 4b: it costs one
|
|
small API-only sync against the same module app — cheap while the
|
|
fixture exists — and keeps the UI test focused): a FRESH force
|
|
push (V3 — its own orphan root: test 1's checkout already sits at
|
|
V2, so a second rewrite is needed to make THIS sync the
|
|
post-force-push one) → ``POST /api/sync`` (202) + poll →
|
|
``state == "success"`` (the same contract as run 2 without the
|
|
UI), and the KB row's content carries the V3 sentinel. Purely the
|
|
status endpoint + the DB: the UI's 2 s poll loop starts only on a
|
|
click or a boot-while-running, so it does NOT adopt an
|
|
API-triggered run (the house rule — the status object, not the
|
|
request, is authoritative — the test_sync_button API-trigger
|
|
pattern asserts the endpoint, never the label).
|
|
|
|
Wall-time guard: the whole module < 120 s (app boot + four small
|
|
syncs against the mock LLM — no ``slow_llm`` proxy needed: the point
|
|
is the TERMINAL state, not a running window).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from datetime import datetime
|
|
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 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): the conftest session app owns its
|
|
# port in a combined run — this module app binds its own port instead
|
|
# (a same-port second uvicorn dies on bind and would drive the wrong
|
|
# server). Env-overridable.
|
|
APP_PORT = int(os.environ.get("E2E_APP_PORT_FPPUSH", "8135"))
|
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
|
|
|
#: The fixture repo's directory name — the source name the import
|
|
#: records for it (the repo_name basename rule, phase 28).
|
|
FIXTURE_NAME = "fppush-fixture"
|
|
FIXTURE_DOC = "notes/doc.md"
|
|
|
|
#: The per-version sentinels inside the fixture doc — the V2/V3
|
|
#: sentinels in the KB after their force push are the reset's
|
|
#: observable end-to-end effect (the NEW head's content is what the
|
|
#: KB serves).
|
|
SENTINEL_V1 = "RESE-FPPUSH-V1-5e8a"
|
|
SENTINEL_V2 = "RESE-FPPUSH-V2-2b7f"
|
|
SENTINEL_V3 = "RESE-FPPUSH-V3-4a9d"
|
|
|
|
V1_DOC = (
|
|
"# Force-push fixture note\n"
|
|
"\n"
|
|
"One small note that exists only to pin the force-push resync end\n"
|
|
"to end: a real git clone, a real import, a force-pushed origin,\n"
|
|
"and the reset back to the head of the tracked branch.\n"
|
|
"\n"
|
|
f"Marker: {SENTINEL_V1}\n"
|
|
)
|
|
V2_DOC = (
|
|
"# Force-push fixture note (rewritten root)\n"
|
|
"\n"
|
|
"The origin's history was rewritten to a NEW orphan root commit —\n"
|
|
"the old commit is gone and the histories share no ancestor. The\n"
|
|
"next sync must reset to this head, not fail.\n"
|
|
"\n"
|
|
f"Marker: {SENTINEL_V2}\n"
|
|
)
|
|
V3_DOC = (
|
|
"# Force-push fixture note (rewritten root, second time)\n"
|
|
"\n"
|
|
"A second unrelated rewrite — the API-level resync pin needs THIS\n"
|
|
"sync to be the post-force-push one, so the origin gets a fresh\n"
|
|
"orphan root again.\n"
|
|
"\n"
|
|
f"Marker: {SENTINEL_V3}\n"
|
|
)
|
|
|
|
#: "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}")
|
|
|
|
#: The module's wall budget (the task's wall-time guard): app boot +
|
|
#: four small syncs against the mock LLM stay under 120 s. A breach is
|
|
#: a regression canary (the sync pipeline slowed), not a flake
|
|
#: tolerance.
|
|
WALL_BUDGET_S = 120.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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()}")
|
|
|
|
|
|
def _rev_parse(repo: Path, *args: str) -> str:
|
|
"""``git rev-parse`` in *repo* — stripped stdout, loud on failure."""
|
|
proc = subprocess.run(
|
|
["git", "rev-parse", *args], cwd=repo, capture_output=True, text=True
|
|
)
|
|
if proc.returncode != 0:
|
|
raise AssertionError(
|
|
f"git rev-parse {' '.join(args)} failed: {proc.stderr.strip()}"
|
|
)
|
|
return proc.stdout.strip()
|
|
|
|
|
|
def _commit(repo: Path, message: str) -> None:
|
|
"""A fixture commit (never signed — the house ``commit.gpgsign``
|
|
flag, the test_sync_button.py idiom)."""
|
|
_git(
|
|
repo,
|
|
"-c", "user.email=e@x", "-c", "user.name=t",
|
|
"-c", "commit.gpgsign=false",
|
|
"commit", "-qm", message,
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def fppush_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""A real one-commit, one-branch git repo the sync must clone
|
|
(the ``test_sync_button.sync_git_repo`` pattern): ``main``, one
|
|
commit, ``notes/doc.md`` with ``Marker: RESE-FPPUSH-V1-5e8a``.
|
|
|
|
Built under ``tmp_path_factory`` (``tmp_path`` is function-scoped
|
|
while the module-scoped app fixture needs the repo for the
|
|
module's lifetime) via real ``git`` subprocesses. ``init -b
|
|
main`` pins the default branch NAME (the system's
|
|
``init.defaultBranch`` may differ).
|
|
"""
|
|
root = tmp_path_factory.mktemp("bor_fppush")
|
|
repo = root / FIXTURE_NAME
|
|
(repo / "notes").mkdir(parents=True)
|
|
(repo / "notes" / "doc.md").write_text(V1_DOC, encoding="utf-8")
|
|
_git(repo, "init", "-q", "-b", "main")
|
|
_git(repo, "add", "-A")
|
|
_commit(repo, "one")
|
|
assert (repo / ".git").is_dir()
|
|
return repo
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def app_server(mock_llm: int, fppush_repo: Path) -> Iterator[str]:
|
|
"""The real app under test — per-module env: the sync's subject is
|
|
a real ``file://`` git source (env-fallback row — ``branch=None``,
|
|
the default-branch path the owner's repro exercises) 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
|
|
# This suite's subject: one real local repo via the env fallback
|
|
# (the registry is truncated per test, so the fallback is the
|
|
# effective source list — the test_sync_button.py idiom).
|
|
env["BOR_GIT_SOURCES"] = f"file://{fppush_repo}"
|
|
env["BOR_SOURCES_DIR"] = str(fppush_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 + registry per test (the E2E isolation pattern): each
|
|
test's sync counts are its own. ``git_sources`` too — the shared
|
|
Postgres may carry leftover registry rows from other suites, and
|
|
``effective_sources`` prefers the DB list over this app's
|
|
``BOR_GIT_SOURCES`` env fallback. ``sources_meta`` (the KB
|
|
generation counter) resets with the KB — the bump is an upsert, so
|
|
the sync re-creates the row."""
|
|
with SessionLocal() as db:
|
|
db.execute(text(
|
|
"TRUNCATE chunks, documents, query_log, kb_overview, "
|
|
"git_sources, sources_meta"
|
|
))
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_kb(db_ready: None) -> Iterator[None]:
|
|
_truncate_kb()
|
|
yield
|
|
_truncate_kb()
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def _wall_time_guard() -> Iterator[None]:
|
|
"""The module's wall-time guard (the task's 120 s budget): the
|
|
whole module — app boot + four small syncs of a one-file repo
|
|
against the mock LLM — stays under 120 s. A breach is a
|
|
regression canary (the sync pipeline slowed), not a flake
|
|
tolerance."""
|
|
start = time.monotonic()
|
|
yield
|
|
elapsed = time.monotonic() - start
|
|
assert elapsed < WALL_BUDGET_S, (
|
|
f"the module took {elapsed:.1f}s — over the {WALL_BUDGET_S:.0f}s wall "
|
|
"budget (the small-fixture syncs against the mock LLM are a "
|
|
"speed canary)"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _force_push_new_head(repo: Path, content: str) -> None:
|
|
"""Rewrite the origin's history to a NEW root commit (a full
|
|
divergence — not just a new commit on top): delete the branch
|
|
ref and recreate it at a new orphan commit with *content*.
|
|
|
|
The ``file://`` equivalent of a force push: the app's next
|
|
``git fetch`` sees the divergent head. The OBSERVABLE contract is
|
|
asserted here — the branch now points at a commit with UNRELATED
|
|
history (no ``merge-base`` with the old head) and the new
|
|
content. The fixture repo is left with EXACTLY ONE branch (the
|
|
original, at the new root), so the helper is repeatable (the
|
|
second test force-pushes again).
|
|
"""
|
|
branch = _rev_parse(repo, "--abbrev-ref", "HEAD")
|
|
old_head = _rev_parse(repo, "HEAD")
|
|
# A unique orphan branch name per call (a repeatable helper must
|
|
# not collide with a leftover ref); it is deleted again at the end
|
|
# so the fixture is left with exactly one branch.
|
|
orphan = f"fppush-root-{uuid.uuid4().hex[:8]}"
|
|
_git(repo, "checkout", "-q", "--orphan", orphan)
|
|
(repo / "notes" / "doc.md").write_text(content, encoding="utf-8")
|
|
_git(repo, "add", "-A")
|
|
_commit(repo, "fppush: rewritten root")
|
|
new_head = _rev_parse(repo, "HEAD")
|
|
# The force push proper: the branch ref is DELETED and recreated
|
|
# at the orphan root — the old history is no longer reachable from
|
|
# any ref (a real force push's observable effect).
|
|
_git(repo, "update-ref", "-d", f"refs/heads/{branch}")
|
|
_git(repo, "update-ref", f"refs/heads/{branch}", new_head)
|
|
_git(repo, "checkout", "-q", "-B", branch, new_head)
|
|
_git(repo, "update-ref", "-d", f"refs/heads/{orphan}")
|
|
# The OBSERVABLE contract: the branch at the new head, and
|
|
# UNRELATED history (no common ancestor with the old root).
|
|
assert _rev_parse(repo, branch) == new_head
|
|
proc = subprocess.run(
|
|
["git", "merge-base", old_head, new_head], cwd=repo,
|
|
capture_output=True, text=True,
|
|
)
|
|
assert proc.returncode != 0, (
|
|
"the rewrite must be UNRELATED history (no common ancestor); "
|
|
f"merge-base found: {proc.stdout.strip()!r}"
|
|
)
|
|
|
|
|
|
def _go_top(page: Page) -> None:
|
|
"""Back to the catalog's top level (no-op when already there —
|
|
the breadcrumb is hidden at the top, the test_sync_button.py
|
|
idiom)."""
|
|
if not page.locator("#kb-crumb").is_hidden():
|
|
page.locator("#kb-crumb a.kb-crumb-link").first.click()
|
|
|
|
|
|
def _expect_fixture_row(page: Page) -> None:
|
|
"""The REAL clone was imported — the fixture path is listed. The
|
|
catalog is the drill-down tree (phase 97), so drill source →
|
|
folder ``notes`` first (the ``test_sync_button._expect_fixture_row``
|
|
pattern)."""
|
|
_go_top(page)
|
|
page.click(f'#folders-tbody a.folder-link:text-is("{FIXTURE_NAME}")')
|
|
page.click('#folders-tbody a.folder-link:text-is("notes")')
|
|
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
|
|
|
|
|
|
def _started_at(body: dict[str, Any]) -> float:
|
|
"""The status body's ``started_at`` as a wall-clock epoch (the
|
|
server stamps it in UTC; this process runs on the same host —
|
|
same clock, the test_git_source_branch idiom)."""
|
|
started = body.get("started_at")
|
|
if not started:
|
|
return 0.0
|
|
try:
|
|
return datetime.fromisoformat(str(started)).timestamp()
|
|
except ValueError:
|
|
return 0.0
|
|
|
|
|
|
def _wait_sync_state(
|
|
page: Page,
|
|
app_url: str,
|
|
*,
|
|
clicked_at: float,
|
|
timeout_s: float = 90.0,
|
|
settle_ui: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Poll the (cookie-authenticated) status endpoint until the
|
|
current run reaches a TERMINAL state, then settle the UI.
|
|
|
|
The ``test_sync_button._wait_sync_done`` pattern — the terminal
|
|
set is ``{"success", "failed"}`` (a cancel is never issued in this
|
|
module) — plus the UI-level settle wait on ``#sync-label``
|
|
(``settle_ui=True``): a success waits for the "Synced HH:MM"
|
|
last-result label (the UI's 2 s poll loop settles it; the
|
|
never-stale contract, §7.4), a ``failed`` terminal returns
|
|
WITHOUT settling (the label never leaves "Syncing…" on a
|
|
failure — the caller's terminal-state assertion is what pins
|
|
it). ``settle_ui=False`` (the API-triggered negative pin) skips
|
|
the label settle: the UI's poll loop starts only on a click or a
|
|
boot-while-running, so an API-triggered run is never adopted
|
|
visually — the status endpoint is the authoritative surface.
|
|
|
|
The module app is shared across the test's sequential runs, so
|
|
the PREVIOUS run's terminal lives in the app's memory until this
|
|
run supersedes it: a terminal is accepted only after a running
|
|
tick was observed or its ``started_at`` post-dates the click (the
|
|
stale-terminal freshness gate, the ``test_git_source_branch``
|
|
idiom) — the caller captures ``clicked_at`` just before the
|
|
trigger (the click or the ``POST /api/sync``).
|
|
"""
|
|
deadline = time.monotonic() + timeout_s
|
|
saw_running = False
|
|
body: dict[str, Any] = {}
|
|
while time.monotonic() < deadline:
|
|
r = page.request.get(f"{app_url}/api/sync/status")
|
|
assert r.status == 200, r.text
|
|
body = r.json()
|
|
state = body["state"]
|
|
if state == "running":
|
|
saw_running = True
|
|
elif state in ("success", "failed") and (
|
|
saw_running or _started_at(body) >= clicked_at - 1.0
|
|
):
|
|
if state == "success" and settle_ui:
|
|
expect(page.locator("#sync-label")).to_have_text(
|
|
SYNCED_LABEL, timeout=15_000
|
|
)
|
|
return body
|
|
time.sleep(0.25)
|
|
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. THE owner's exact flow: sync → force push → sync again → success
|
|
# with the NEW head's content
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sync_survives_a_force_push(
|
|
page: Page, app_url: str, db_ready: None, fppush_repo: Path
|
|
) -> None:
|
|
"""The owner's exact reproduction, through the UI: run 1 (clone +
|
|
import) → force-push the origin (an orphan-root rewrite, V2) →
|
|
run 2 (``state == "success"`` — NOT the pre-phase ``failed``; the
|
|
error banner stays hidden and no error modal exists — a reset is
|
|
a logged event, not a user-facing failure) with the KB serving
|
|
the NEW head's content → run 3 (idempotent at the new head)."""
|
|
page.set_default_timeout(30_000)
|
|
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 (the V1 head) --------------------------
|
|
clicked_at = time.time()
|
|
btn.click()
|
|
expect(btn).to_be_disabled()
|
|
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
|
body = _wait_sync_state(page, app_url, clicked_at=clicked_at)
|
|
assert body["state"] == "success", body
|
|
# The fixture doc (one A9-format file) is the only change — the
|
|
# counts line rides the box (phase 150).
|
|
expect(page.locator("#sync-progress-text")).to_have_text("1 added")
|
|
# The REAL clone was imported: the fixture path is in the Sources
|
|
# table (the drill-down tree, the test_sync_button pattern).
|
|
_expect_fixture_row(page)
|
|
|
|
# --- the force push (server-side — the test owns the origin) ------
|
|
_force_push_new_head(fppush_repo, V2_DOC)
|
|
|
|
# --- run 2: the resync — SUCCESS, the reset is not a failure ------
|
|
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal
|
|
clicked_at = time.time()
|
|
btn.click()
|
|
expect(btn).to_be_disabled()
|
|
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
|
body = _wait_sync_state(page, app_url, clicked_at=clicked_at)
|
|
# THE regression pin: pre-phase, the --ff-only pull refused the
|
|
# divergent head and this terminal was "failed" (the run died in
|
|
# the failed state with a user-facing error).
|
|
assert body["state"] == "success", body
|
|
expect(btn).to_be_enabled()
|
|
# A reset is a LOGGED event, not a user-facing failure: the error
|
|
# banner stays hidden and no error modal was ever created (it is
|
|
# created on demand, so count 0 is the absence proof)…
|
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
|
expect(page.locator(".sync-modal-backdrop")).to_have_count(0)
|
|
# …and the digest delta re-indexed the file as updated (the counts
|
|
# are the secondary signal — the content assertion below is
|
|
# primary). The counts line rides the box (phase 150).
|
|
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 updated")
|
|
# The KB holds the NEW head: the document modal serves the V2
|
|
# sentinel — the reset's content flowed through the import.
|
|
_expect_fixture_row(page)
|
|
doc_row = page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)
|
|
doc_row.locator("td:nth-child(2) a.doc-link").click()
|
|
expect(page.locator(".doc-modal")).to_be_visible()
|
|
expect(page.locator("#doc-modal-content")).to_contain_text(SENTINEL_V2)
|
|
page.keyboard.press("Escape")
|
|
expect(page.locator(".doc-modal")).to_be_hidden()
|
|
|
|
# --- run 3: idempotent — the checkout is stably at the new head ---
|
|
expect(btn).to_be_enabled()
|
|
clicked_at = time.time()
|
|
btn.click()
|
|
expect(btn).to_be_disabled()
|
|
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
|
body = _wait_sync_state(page, app_url, clicked_at=clicked_at)
|
|
assert body["state"] == "success", body
|
|
# "1 unchanged" means the content hash did not move — drift back
|
|
# to V1 would have re-reported as "1 updated" (the no-drift pin).
|
|
# The counts line rides the box (phase 150).
|
|
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 1 unchanged")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. The negative pin at the API seam: after a force push, POST
|
|
# /api/sync + poll → state == "success" (no UI)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_failed_pull_is_not_the_outcome(
|
|
page: Page, app_url: str, db_ready: None, fppush_repo: Path
|
|
) -> None:
|
|
"""The API-level negative pin (no UI — the task's PREFERRED
|
|
direct-API leg, its own test rather than folded into test 1 as
|
|
step 4b so the UI test stays focused): a FRESH force push (V3 —
|
|
its own orphan root: test 1's checkout already sits at V2, so a
|
|
second rewrite is needed to make THIS sync the post-force-push
|
|
one) → ``POST /api/sync`` (202) + poll → ``state ==
|
|
"success"`` — the same contract as run 2 without the UI — and
|
|
the KB row's content carries the V3 sentinel.
|
|
|
|
Purely the status endpoint + the DB (no page navigation, no UI
|
|
assertion): the UI's 2 s poll loop starts only on a click or a
|
|
boot-while-running, so it does NOT adopt an API-triggered run —
|
|
the status endpoint is the authoritative surface (the house
|
|
rule, and the test_sync_button API-trigger pattern)."""
|
|
r = page.request.post(
|
|
f"{app_url}/api/login", data={"password": ADMIN_PASSWORD}
|
|
)
|
|
assert r.status == 204, r.text
|
|
|
|
_force_push_new_head(fppush_repo, V3_DOC)
|
|
|
|
clicked_at = time.time()
|
|
r = page.request.post(f"{app_url}/api/sync")
|
|
assert r.status == 202, r.text
|
|
body = _wait_sync_state(
|
|
page, app_url, clicked_at=clicked_at, settle_ui=False
|
|
)
|
|
assert body["state"] == "success", body
|
|
# Fresh after the per-test truncate: the (reset) checkout's file
|
|
# is re-added from the NEW head.
|
|
assert body["detail"]["added"] == 1, body
|
|
# The NEW head's content is what the KB serves (the sentinel in
|
|
# the stored document).
|
|
with SessionLocal() as db:
|
|
content = db.execute(
|
|
text(
|
|
"SELECT content FROM documents "
|
|
"WHERE source = :src AND path = :path"
|
|
),
|
|
{"src": FIXTURE_NAME, "path": FIXTURE_DOC},
|
|
).scalar_one()
|
|
assert SENTINEL_V3 in content
|