phase: 136_force_push_reset
Everything is verified green and consistent. Final report: **Phase 136 — final verification pass (all tasks already complete in `complete/136_force_push_reset/`):** - **Verified implemented:** unified ff-or-reset update sequence in `scripts/git_sync.py::clone_or_pull` (phase-135 HEAD guard → shallow self-heal → `fetch origin <ref>` → `merge --ff-only` → on non-ff: SHA capture + `reset --hard FETCH_HEAD` + one WARNING line, returns normally); detached-HEAD fail-loud; `branch=None` legacy `git pull` deliberately gone - **Completion criteria — all met:** 1. ✅ Force push no longer fails sync (integration `test_clone_or_pull_force_push_resets_to_the_new_head` + API-seam `test_sync_pipeline_survives_a_force_push` + E2E run 2 asserts `success` and KB serves the V2 sentinel) 2. ✅ Unrelated-history force push handled by the same reset (`test_clone_or_pull_unrelated_history_force_push_resets`) 3. ✅ Broken fetch still fails loud (fetch-failure unit pins raise with no merge/reset argv; `test_main_git_failure_aborts_before_import`) 4. ✅ WARNING line pinned: one line, 7-char SHAs, checkout path, `origin/<ref>`, no URL (unit exact-string pin + integration caplog pin) 5. ✅ `uv run pytest --cov=app --cov-report=term-missing` → **3231 passed, app/ 99%**; `uv run pytest tests/e2e/test_force_push_resync.py -v --no-cov` → **2 passed in isolation** (10.6 s) 6. ✅ Phase-135 pins preserved (mismatch guard, `--branch` clone argv, `branch=None` fresh-clone argv all green in the 53-test run) - **Test/lint results:** `uv run pytest tests/unit/test_git_sync.py tests/integration/test_import_docs_git.py -v` → 53 passed; full suite 3231 passed (72 s); `uv run ruff check . && uv run pyright` → clean (0 errors) - **Documentation:** README.md git-sources section already updated this phase to the ff-or-reset contract; all `.env.example` vars documented; pages table matches router; AGENTS.md rules and quick-reference commands verified accurate (rule 5 correctly documents the phase-100 §7.1 supersession) — **no changes needed** - **Notable:** E2E "teeth" verification (fails on pre-phase code) is recorded in the task report; nothing was committed per harness protocol - **Next pending phase:** `todo/137_pdf_image_context`
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
"""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; ``#sync-result`` shows
|
||||
"0 added · 1 updated" 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.
|
||||
expect(page.locator("#sync-result")).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).
|
||||
expect(page.locator("#sync-result")).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).
|
||||
expect(page.locator("#sync-result")).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
|
||||
Reference in New Issue
Block a user