**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/`.
758 lines
33 KiB
Python
758 lines
33 KiB
Python
"""Phase 135 story E2E (Playwright): a git source pins the branch it
|
|
mirrors.
|
|
|
|
Source: ``TODO.md:4`` — "Need to be able to specify the branch for a git
|
|
source."
|
|
|
|
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_git_source_branch.py -v --no-cov
|
|
|
|
The branch feature is pinned end to end against the REAL pipeline: a
|
|
real ``git clone`` of the ``file://`` fixture repo, a real import
|
|
against the mock LLM, a real KB — the owner's exact flow (add the
|
|
source WITH a branch through the UI → sync → the branch's content is
|
|
indexed, not the default branch's). The ``slow_llm`` proxy is NOT
|
|
needed: the branch assertions are about WHICH files land, not about a
|
|
running window.
|
|
|
|
Per-module app env (the E2E conftest pattern, module-scoped — the
|
|
``test_sync_button.py`` + ``test_git_sources_admin.py`` combination):
|
|
this module's app boots on its own port (a same-port second uvicorn
|
|
dies on bind and would drive the wrong server) with a dedicated
|
|
``BOR_SOURCES_DIR`` and NO ``BOR_GIT_SOURCES`` — the sources under
|
|
test are DB rows the UI adds, so the env fallback stays EMPTY (pinned
|
|
"" — the phase-61 leak guard: an operator's local gitignored ``.env``
|
|
list must not leak into the app under test; process env ranks above
|
|
the ``.env`` file). The session app (no git sources) is never started
|
|
in this isolated run, so no port clash.
|
|
|
|
The fixture (module-scoped, real ``git``): a two-branch repo —
|
|
``main`` (``notes/main-only.md`` with ``Marker: RESE-BRANCH-MAIN-7a31``
|
|
+ a shared ``notes/common.md`` saying ``VERSION: 1``) and ``release``
|
|
(``notes/release-only.md`` with ``Marker: RESE-BRANCH-REL-9c2d`` + the
|
|
same ``notes/common.md`` saying ``VERSION: 2``) — the branches
|
|
DIVERGE: release does not carry ``notes/main-only.md`` (a release
|
|
checkout must not hold the default branch's doc). A SECOND module
|
|
fixture is a main-only clone of it (identical history) — the
|
|
``branch=None`` path's subject.
|
|
|
|
Test → contract mapping (four tests):
|
|
|
|
1. ``test_add_with_branch_syncs_that_branch`` — THE FEATURE: the
|
|
owner adds the fixture WITH branch ``release`` through the UI (201
|
|
→ the row's ``branch: release`` tag) → sync succeeds → the catalog
|
|
holds ``release-only.md`` + ``common.md`` (the modal's stored
|
|
content says ``VERSION: 2``) while ``main-only.md`` is ABSENT (the
|
|
default branch's doc was never indexed); the idempotent re-sync
|
|
reports "0 added · 2 unchanged".
|
|
2. ``test_default_branch_source_unaffected`` — the ``branch=None``
|
|
path against the main-only clone: no branch given → sync →
|
|
``main-only.md`` present, ``release-only.md`` absent, ``common.md``
|
|
holds ``VERSION: 1`` (the pre-phase byte-identical behavior,
|
|
observable through the UI; the row carries NO branch tag).
|
|
3. ``test_row_editor_branch_change_fails_loud`` — the ASSUMPTION B5
|
|
guard end to end: the per-row editor (prefilled) changes the
|
|
branch to ``main`` → the PATCH lands (row re-renders with the
|
|
``branch: main`` tag) → the sync FAILS (the checkout sits on
|
|
``release``) with the fixed mismatch copy naming
|
|
``remove and re-add the source`` in the sanitized status error,
|
|
the banner, and the modal; restoring ``release`` makes the sync
|
|
succeed again (the guard is symmetric — the ff path works).
|
|
4. ``test_api_branch_contracts`` — the API layer through the logged-in
|
|
page context's request client (the admin cookie): invalid branch
|
|
names (``a..b``, ``HEAD``) → 422 with the detail EXACTLY
|
|
``not a valid branch name`` (no echo; the list unchanged); the
|
|
PATCH tri-state (set ``hotfix/x`` → 200 reports it; a PATCH that
|
|
does not touch the branch leaves it untouched; ``""`` → 200
|
|
reports ``null``); a branch on a LOCAL row → 422 ``branch is only
|
|
valid for git sources``.
|
|
|
|
Isolation: per-test TRUNCATE of the KB + registry tables (the
|
|
``test_sync_button.py``/``test_git_source_dates.py`` shape, extended
|
|
with ``sources_meta``) so each test's sync counts are its own; own
|
|
``BOR_SOURCES_DIR``; the checkout dirs survive across tests WITHIN the
|
|
module on purpose — test 3's fail-loud mismatch keys on test 1's
|
|
checkout (on ``release``) still being on disk. The whole module is
|
|
held to a 180 s wall budget (the ``_wall_time_guard`` fixture — the
|
|
three-plus full syncs of a 3-file repo against the mock LLM are a
|
|
regression canary for the sync pipeline's speed).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections.abc import Iterator
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from playwright.sync_api import Locator, 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
|
|
# (env-overridable, as in the sibling per-module suites).
|
|
APP_PORT = int(os.environ.get("E2E_APP_PORT_GITBR", "8134"))
|
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
|
|
|
GIT_SOURCES_URL = "/git-sources.html"
|
|
SOURCES_URL = "/sources.html"
|
|
|
|
#: The fixture repo's directory names — the source names the import
|
|
#: records for each (the repo_name basename rule, phase 28). The two
|
|
#: names DIFFER, so the two checkouts (and the two catalog sources)
|
|
#: never collide.
|
|
FIXTURE_NAME = "branch-fixture"
|
|
CLONE_NAME = "branch-fixture-main"
|
|
|
|
#: "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 user-visible fragment of the phase-135 mismatch error (the fixed
|
|
#: copy — safe to pin): the checkout guard's remove-and-re-add fix.
|
|
MISMATCH_FRAGMENT = "remove and re-add the source"
|
|
|
|
#: The module's wall budget (task 05 step 2): three-plus full syncs of
|
|
#: a 3-file repo against the mock LLM must stay well under this.
|
|
WALL_BUDGET_S = 180.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 _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 branch_fixture_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""A real TWO-branch git repo the sync must clone over ``file://``
|
|
(task 05 step 1): ``main`` (``notes/main-only.md`` +
|
|
``notes/common.md`` = ``VERSION: 1``), then the DIVERGED
|
|
``release`` branch (drops ``notes/main-only.md``, adds
|
|
``notes/release-only.md``, rewrites ``notes/common.md`` =
|
|
``VERSION: 2``).
|
|
|
|
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 — the
|
|
``test_sync_button.py`` idiom. ``init -b main`` pins the default
|
|
branch NAME (the task's fixture shape — the system's
|
|
``init.defaultBranch`` may differ).
|
|
"""
|
|
root = tmp_path_factory.mktemp("bor_git_br")
|
|
repo = root / FIXTURE_NAME
|
|
(repo / "notes").mkdir(parents=True)
|
|
(repo / "notes" / "main-only.md").write_text(
|
|
"# Main only note\n"
|
|
"\n"
|
|
"A note that exists only on the main branch.\n"
|
|
"\n"
|
|
"Marker: RESE-BRANCH-MAIN-7a31\n",
|
|
encoding="utf-8",
|
|
)
|
|
(repo / "notes" / "common.md").write_text(
|
|
"# Common note\n\nVERSION: 1\n", encoding="utf-8"
|
|
)
|
|
_git(repo, "init", "-q", "-b", "main")
|
|
_git(repo, "add", "-A")
|
|
_commit(repo, "main: base")
|
|
# The release branch DIVERGES from main (the feature's observable
|
|
# promise): it drops main-only.md (a release checkout must NOT hold
|
|
# the default branch's doc), adds a branch-only file, and rewrites
|
|
# the shared common.md (the two VERSIONs).
|
|
_git(repo, "checkout", "-q", "-b", "release")
|
|
(repo / "notes" / "main-only.md").unlink()
|
|
(repo / "notes" / "release-only.md").write_text(
|
|
"# Release only note\n"
|
|
"\n"
|
|
"A note that exists only on the release branch.\n"
|
|
"\n"
|
|
"Marker: RESE-BRANCH-REL-9c2d\n",
|
|
encoding="utf-8",
|
|
)
|
|
(repo / "notes" / "common.md").write_text(
|
|
"# Common note\n\nVERSION: 2\n", encoding="utf-8"
|
|
)
|
|
_git(repo, "add", "-A")
|
|
_commit(repo, "release: branch content")
|
|
assert (repo / ".git").is_dir()
|
|
return repo
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def branch_main_clone(
|
|
branch_fixture_repo: Path, tmp_path_factory: pytest.TempPathFactory
|
|
) -> Path:
|
|
"""A main-only clone of the fixture (identical history) — the
|
|
``branch=None`` path's subject (task 05 test 2): a SECOND
|
|
``file://`` repo whose default branch is ``main``. The clone's
|
|
``origin/release`` ref is remote-tracking only — the app's
|
|
no-branch clone/pull never touches it (its one sync is a FRESH
|
|
clone of the default branch, the pre-phase byte-identical path)."""
|
|
root = tmp_path_factory.mktemp("bor_git_br_main")
|
|
clone = root / CLONE_NAME
|
|
proc = subprocess.run(
|
|
["git", "clone", "-q", "-b", "main", f"file://{branch_fixture_repo}", str(clone)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise AssertionError(f"git clone of the fixture failed: {proc.stderr.strip()}")
|
|
assert (clone / ".git").is_dir()
|
|
return clone
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def app_server(mock_llm: int, branch_fixture_repo: Path) -> Iterator[str]:
|
|
"""The real app under test — per-module env: NO
|
|
``BOR_GIT_SOURCES`` (pinned EMPTY — the phase-61 leak guard; the
|
|
sources under test are DB rows the UI adds, so the env fallback
|
|
stays empty) and a dedicated ``BOR_SOURCES_DIR`` under the fixture
|
|
root (the checkouts never aim at anything outside the temp area).
|
|
The session app 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).
|
|
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
|
|
# NO env git sources (task 05 step 1): the rows under test are DB
|
|
# rows — the fallback list stays EMPTY (pinned, not absent: an
|
|
# operator's local .env list must not leak into the app under test).
|
|
env["BOR_GIT_SOURCES"] = ""
|
|
env["BOR_SOURCES_DIR"] = str(branch_fixture_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 — this suite
|
|
adds its rows through the UI, and a leftover row from another suite
|
|
would flip ``effective_sources`` to a list this test does not
|
|
control. ``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 budget (task 05 step 2): the whole module
|
|
— app boot + the full syncs of a 3-file repo against the mock LLM
|
|
— stays under 180 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 _git_source_row(page: Page, url: str) -> Locator:
|
|
"""The table row whose source cell shows ``url`` (the
|
|
``test_git_sources_admin.py`` _row idiom)."""
|
|
return page.locator("#git-sources-tbody tr", has_text=url)
|
|
|
|
|
|
def add_git_source(
|
|
page: Page, url: str, branch: str | None = None, token: str | None = None
|
|
) -> Locator:
|
|
"""Fill + submit the add form — the branch field when given (blank
|
|
→ the key is omitted from the POST, NULL = the default branch),
|
|
the masked token field when given — and wait for the new row
|
|
(the 201 → reload → row lifecycle, §7.4 never-stale)."""
|
|
page.fill("#git-source-url", url)
|
|
if branch is not None:
|
|
page.fill("#git-source-branch", branch)
|
|
if token is not None:
|
|
page.fill("#git-source-token", token)
|
|
page.locator("#git-source-add").click()
|
|
row = _git_source_row(page, url)
|
|
expect(row).to_have_count(1, timeout=30_000)
|
|
return row
|
|
|
|
|
|
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 phase-64 recorder's freshness convention)."""
|
|
started = body.get("started_at")
|
|
if not started:
|
|
return 0.0
|
|
try:
|
|
return datetime.fromisoformat(str(started)).timestamp()
|
|
except ValueError:
|
|
return 0.0
|
|
|
|
|
|
def _close_sync_failure_modal(page: Page) -> None:
|
|
"""Close the sync failure modal when it is open.
|
|
|
|
A Sources-view remount over a stale ``failed`` terminal re-renders
|
|
the failure modal (``initSyncButton`` — the never-stale contract:
|
|
the page boots to the last run's state), and the fixed-inset
|
|
backdrop would intercept the next ``#sync-btn`` click — closing it
|
|
is exactly what the owner does after reading the error."""
|
|
if page.locator(".sync-modal-backdrop.is-open").count():
|
|
page.click(".sync-modal-close")
|
|
expect(page.locator(".sync-modal-backdrop.is-open")).to_have_count(
|
|
0, timeout=5_000
|
|
)
|
|
|
|
|
|
def sync_and_wait(
|
|
page: Page, app_url: str, timeout_s: float = 90.0
|
|
) -> dict[str, Any]:
|
|
"""Click ``#sync-btn`` and poll the (cookie-authenticated) status
|
|
endpoint until THIS run reaches a terminal state — the
|
|
``_wait_sync_done`` pattern with the stale-terminal freshness gate
|
|
(the ``test_incremental_folder_summaries.py`` idiom — the module
|
|
app is shared, 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). A re-rendered failure modal (the remount
|
|
case above) is closed before the click. Waiting for the
|
|
synchronous "Syncing…" label first also pins every LATER UI
|
|
terminal assertion (the label / result line / banner) to this run
|
|
— a stale "Synced HH:MM" from a prior test cannot satisfy them."""
|
|
_close_sync_failure_modal(page)
|
|
r = page.request.get(f"{app_url}/api/sync/status")
|
|
assert r.status == 200, r.text
|
|
t0 = time.time()
|
|
page.click("#sync-btn")
|
|
# The click handler adopts the run on 202 (the label flips
|
|
# "Syncing…" before the first poll).
|
|
expect(page.locator("#sync-label")).to_have_text("Syncing…", timeout=15_000)
|
|
saw_running = False
|
|
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, r.text
|
|
body = r.json()
|
|
state = body["state"]
|
|
if state == "running":
|
|
saw_running = True
|
|
elif state in ("success", "failed", "cancelled") and (
|
|
saw_running or _started_at(body) >= t0 - 1.0
|
|
):
|
|
return body
|
|
time.sleep(0.25)
|
|
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
|
|
|
|
|
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 catalog_has_doc(page: Page, source_name: str, doc_name: str) -> Locator:
|
|
"""Drill the catalog tree (top → *source_name* → ``notes``) and
|
|
return the locator for *doc_name*'s file row (the
|
|
``_expect_fixture_row`` pattern, parameterized — the count
|
|
assertion is the caller's: 1 = present, 0 = absent)."""
|
|
_go_top(page)
|
|
source_link = page.locator(
|
|
f'#folders-tbody a.folder-link:text-is("{source_name}")'
|
|
)
|
|
expect(source_link).to_have_count(1)
|
|
source_link.click()
|
|
folder_link = page.locator('#folders-tbody a.folder-link:text-is("notes")')
|
|
expect(folder_link).to_have_count(1)
|
|
folder_link.click()
|
|
return page.locator("#docs-tbody tr", has_text=doc_name)
|
|
|
|
|
|
def _assert_admin_settled(page: Page) -> None:
|
|
"""The git-sources view settled as admin (the
|
|
``test_git_sources_admin.py`` _admin_git_sources_page tail)."""
|
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#git-sources-gate")).to_be_hidden()
|
|
expect(page.locator("#git-sources-content")).to_be_visible()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. THE FEATURE: add WITH a branch → the branch's content is indexed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_add_with_branch_syncs_that_branch(
|
|
page: Page, app_url: str, db_ready: None, branch_fixture_repo: Path
|
|
) -> None:
|
|
"""The owner's exact flow: the add form WITH the branch field →
|
|
201 → the row's ``branch: release`` tag → the real sync indexes
|
|
the BRANCH's tree (``release-only.md`` + ``common.md`` at
|
|
``VERSION: 2``) — never the default branch's (``main-only.md``
|
|
absent) — and the idempotent re-sync reports "0 added · 2
|
|
unchanged"."""
|
|
page.set_default_timeout(30_000)
|
|
url = f"file://{branch_fixture_repo}"
|
|
login(page, app_url, next=GIT_SOURCES_URL)
|
|
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
|
_assert_admin_settled(page)
|
|
|
|
# --- add through the UI form, WITH the branch (→ 201) --------------
|
|
post_statuses: list[int] = []
|
|
|
|
def track_post(r: Any) -> None:
|
|
if r.request.method == "POST" and r.url.endswith("/api/git-sources"):
|
|
post_statuses.append(r.status)
|
|
|
|
page.on("response", track_post)
|
|
try:
|
|
add_git_source(page, url, branch="release")
|
|
finally:
|
|
page.remove_listener("response", track_post)
|
|
assert post_statuses == [201], (
|
|
f"the add must be a single 201 POST, saw: {post_statuses}"
|
|
)
|
|
|
|
# The row renders with the ``branch: release`` tag in the source
|
|
# cell (the "hidden on" tag idiom — text + background)…
|
|
row = _git_source_row(page, url)
|
|
expect(row).to_have_count(1)
|
|
expect(row.locator("td.git-source-url-cell code")).to_have_text(url)
|
|
tag = row.locator(".git-source-branch-count")
|
|
expect(tag).to_have_count(1)
|
|
expect(tag).to_have_text("branch: release")
|
|
# …and the 201 cleared the credential-free inputs (never stale).
|
|
expect(page.locator("#git-source-url")).to_have_value("")
|
|
expect(page.locator("#git-source-branch")).to_have_value("")
|
|
|
|
# --- the real sync (switch to the Sources/RAG view) ----------------
|
|
page.goto(app_url + SOURCES_URL)
|
|
expect(page.locator("#sync-btn")).to_be_visible(timeout=30_000)
|
|
body = sync_and_wait(page, app_url)
|
|
assert body["state"] == "success", body
|
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
|
# Both branch docs are fresh after the per-test truncate — the
|
|
# counts line rides the box (phase 150).
|
|
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
|
|
|
|
# --- the branch's content is what got indexed ----------------------
|
|
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
|
|
expect(catalog_has_doc(page, FIXTURE_NAME, "common.md")).to_have_count(1)
|
|
# The default branch's doc was NEVER imported…
|
|
expect(page.locator("#docs-tbody tr", has_text="main-only.md")).to_have_count(0)
|
|
# …and the shared file holds the RELEASE-side content: drill into
|
|
# it and assert the stored content (the document modal — the
|
|
# dates suite's content-check idiom, at the UI level).
|
|
common_row = page.locator("#docs-tbody tr", has_text="common.md")
|
|
expect(common_row).to_have_count(1)
|
|
common_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("VERSION: 2")
|
|
page.keyboard.press("Escape")
|
|
expect(page.locator(".doc-modal")).to_be_hidden()
|
|
|
|
# --- the idempotent re-sync: the ff pull of the pinned ref --------
|
|
body = sync_and_wait(page, app_url)
|
|
assert body["state"] == "success", body
|
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
|
# Nothing re-embeds (sha256 delta) — the no-op run announces the
|
|
# unchanged count (release's two files) on the box (phase 150).
|
|
expect(page.locator("#sync-progress-text")).to_have_text("0 added · 2 unchanged")
|
|
# The branch's doc survived the prune re-import.
|
|
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. The branch=None path: the default branch, exactly as pre-phase
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_default_branch_source_unaffected(
|
|
page: Page, app_url: str, db_ready: None, branch_main_clone: Path
|
|
) -> None:
|
|
"""A SECOND source (a main-only clone of the fixture — a second
|
|
``file://`` repo, identical history, ``main`` only) added with NO
|
|
branch: the ``branch=None`` path mirrors the pre-phase behavior —
|
|
the DEFAULT branch's tree is indexed (``main-only.md`` present,
|
|
``release-only.md`` absent, ``common.md`` at ``VERSION: 1``), and
|
|
the row carries NO branch tag."""
|
|
page.set_default_timeout(30_000)
|
|
url = f"file://{branch_main_clone}"
|
|
login(page, app_url, next=GIT_SOURCES_URL)
|
|
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
|
_assert_admin_settled(page)
|
|
|
|
# Add with NO branch: the blank field omits the key (stored NULL).
|
|
add_git_source(page, url)
|
|
row = _git_source_row(page, url)
|
|
expect(row).to_have_count(1)
|
|
expect(row.locator(".git-source-branch-count")).to_have_count(0)
|
|
|
|
page.goto(app_url + SOURCES_URL)
|
|
expect(page.locator("#sync-btn")).to_be_visible(timeout=30_000)
|
|
body = sync_and_wait(page, app_url)
|
|
assert body["state"] == "success", body
|
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
|
# The default branch's two docs are fresh — the counts line rides
|
|
# the box (phase 150).
|
|
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
|
|
|
|
# The default branch (main) is what landed — the pre-phase
|
|
# byte-identical guarantee, observable through the UI…
|
|
expect(catalog_has_doc(page, CLONE_NAME, "main-only.md")).to_have_count(1)
|
|
expect(page.locator("#docs-tbody tr", has_text="release-only.md")).to_have_count(0)
|
|
# …and the shared file holds MAIN's content (VERSION: 1 — the
|
|
# branch the owner did NOT name).
|
|
common_row = page.locator("#docs-tbody tr", has_text="common.md")
|
|
expect(common_row).to_have_count(1)
|
|
common_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("VERSION: 1")
|
|
page.keyboard.press("Escape")
|
|
expect(page.locator(".doc-modal")).to_be_hidden()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. The fail-loud mismatch (ASSUMPTION B5, end to end)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_row_editor_branch_change_fails_loud(
|
|
page: Page, app_url: str, db_ready: None, branch_fixture_repo: Path
|
|
) -> None:
|
|
"""The per-row editor changes the branch to one the checkout is
|
|
NOT on: the PATCH lands (the row re-renders with the new tag),
|
|
the sync FAILS — the checkout guard names the remove-and-re-add
|
|
fix in the sanitized status error, the banner, and the modal —
|
|
and restoring the matching branch makes the sync succeed again
|
|
(the guard is symmetric: back to ``release``, the ff path works).
|
|
The fixture state is explicit: the autouse truncate re-creates the
|
|
row fresh, while test 1's checkout (on ``release``) is what the
|
|
guard runs against (the checkout dirs outlive the per-test
|
|
truncate — on purpose)."""
|
|
page.set_default_timeout(30_000)
|
|
url = f"file://{branch_fixture_repo}"
|
|
login(page, app_url, next=GIT_SOURCES_URL)
|
|
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
|
_assert_admin_settled(page)
|
|
|
|
# Re-add the release source fresh (the autouse truncate emptied the
|
|
# registry; the KB is empty too — nothing is indexed yet).
|
|
add_git_source(page, url, branch="release")
|
|
expect(
|
|
_git_source_row(page, url).locator(".git-source-branch-count")
|
|
).to_have_text("branch: release")
|
|
|
|
# --- the per-row editor: prefilled, change to main, save ----------
|
|
_git_source_row(page, url).locator(".git-source-ignore").click()
|
|
dialog = page.locator("#ignore-editor-dialog")
|
|
expect(dialog).to_be_visible()
|
|
# Git rows open the branch field VISIBLE, prefilled from the stored
|
|
# value (unlike the token — there IS a field to read it from).
|
|
expect(page.locator("#ignore-editor-branch")).to_have_value("release")
|
|
page.fill("#ignore-editor-branch", "main")
|
|
page.locator("#ignore-editor-save").click()
|
|
expect(dialog).to_be_hidden(timeout=30_000)
|
|
# The PATCH landed — the row re-renders from the server with the
|
|
# changed tag (the registry is updated; the sync is what guards).
|
|
expect(
|
|
_git_source_row(page, url).locator(".git-source-branch-count")
|
|
).to_have_text("branch: main", timeout=15_000)
|
|
|
|
# --- sync → FAILED: the checkout is on release, the row says main -
|
|
page.goto(app_url + SOURCES_URL)
|
|
expect(page.locator("#sync-btn")).to_be_visible(timeout=30_000)
|
|
body = sync_and_wait(page, app_url)
|
|
assert body["state"] == "failed", body
|
|
# The status error carries the fixed mismatch copy (sanitized —
|
|
# never the URL, never a credential)…
|
|
assert body["error"] is not None and MISMATCH_FRAGMENT in body["error"], body
|
|
# …and the UI surfaces it in the sanitized error banner AND the
|
|
# failure modal (the owner is never left with a silently-wrong
|
|
# checkout — the fail-loud promise, ASSUMPTION B5).
|
|
expect(page.locator("#sync-error-banner")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#sync-error-text")).to_contain_text(MISMATCH_FRAGMENT)
|
|
expect(page.locator(".sync-modal-backdrop")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#sync-modal-error")).to_contain_text(MISMATCH_FRAGMENT)
|
|
# The owner's next step: close the modal (its backdrop intercepts
|
|
# clicks until it does).
|
|
page.locator(".sync-modal-close").click()
|
|
expect(page.locator(".sync-modal-backdrop.is-open")).to_have_count(0)
|
|
|
|
# --- restore: back to the matching branch → the ff path works -----
|
|
page.goto(app_url + GIT_SOURCES_URL)
|
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
|
row = _git_source_row(page, url)
|
|
expect(row).to_have_count(1)
|
|
row.locator(".git-source-ignore").click()
|
|
expect(page.locator("#ignore-editor-dialog")).to_be_visible(timeout=30_000)
|
|
# The editor now prefills the (wrong-branch) stored value…
|
|
expect(page.locator("#ignore-editor-branch")).to_have_value("main")
|
|
page.fill("#ignore-editor-branch", "release")
|
|
page.locator("#ignore-editor-save").click()
|
|
expect(page.locator("#ignore-editor-dialog")).to_be_hidden(timeout=30_000)
|
|
expect(
|
|
_git_source_row(page, url).locator(".git-source-branch-count")
|
|
).to_have_text("branch: release", timeout=15_000)
|
|
|
|
page.goto(app_url + SOURCES_URL)
|
|
body = sync_and_wait(page, app_url)
|
|
assert body["state"] == "success", body
|
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
|
# The KB is still empty (the failed sync aborted before the
|
|
# import) — the restored branch's two docs are fresh additions;
|
|
# the counts line rides the box (phase 150).
|
|
expect(page.locator("#sync-progress-text")).to_have_text("2 added")
|
|
# And the branch's doc is what is indexed again.
|
|
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. The API contracts (the tri-state, the fixed 422s, the local-row
|
|
# rejection) through the logged-in page context's request client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_api_branch_contracts(
|
|
page: Page, app_url: str, db_ready: None, tmp_path: Path
|
|
) -> None:
|
|
"""The branch's API layer, admin-cookie only (the real API login —
|
|
no form, no page): invalid branch names 422 with the detail
|
|
EXACTLY ``not a valid branch name`` (fixed — no echo; the list
|
|
unchanged), the PATCH tri-state round-trips (set → untouched →
|
|
clear), and a branch on a LOCAL row is the fixed 422. The token
|
|
field is untouched by all of the above (the phase-121 tests stay
|
|
green — no regression pin needed here)."""
|
|
base = f"{app_url}/api/git-sources"
|
|
r = page.request.post(f"{app_url}/api/login", data={"password": ADMIN_PASSWORD})
|
|
assert r.status == 204, r.text
|
|
|
|
# --- POST with an invalid branch name → the EXACT fixed 422 ------
|
|
for bad in ("a..b", "HEAD"):
|
|
r = page.request.post(
|
|
base,
|
|
data={"url": f"https://example.com/reese/branch-{bad}.git", "branch": bad},
|
|
)
|
|
assert r.status == 422, r.text
|
|
# The detail is the FIXED string — never a substring of the
|
|
# input (the credential-safety discipline, applied to names).
|
|
assert r.json()["detail"] == "not a valid branch name"
|
|
# …and no row was created (the list is unchanged).
|
|
r = page.request.get(base)
|
|
assert r.status == 200, r.text
|
|
assert r.json()["sources"] == []
|
|
|
|
# --- the PATCH tri-state on a stored row ---------------------------
|
|
r = page.request.post(
|
|
base, data={"url": "https://example.com/reese/branch-patch.git"}
|
|
)
|
|
assert r.status == 201, r.text
|
|
body = r.json()
|
|
assert body["branch"] is None # absent at create → NULL (default branch)
|
|
row_id = body["id"]
|
|
|
|
# Set: non-empty → 200 reports it (re-validated).
|
|
r = page.request.patch(f"{base}/{row_id}", data={"branch": "hotfix/x"})
|
|
assert r.status == 200, r.text
|
|
assert r.json()["branch"] == "hotfix/x"
|
|
# A PATCH that does not touch the branch leaves it untouched.
|
|
r = page.request.patch(f"{base}/{row_id}", data={"include_hidden": True})
|
|
assert r.status == 200, r.text
|
|
assert r.json()["branch"] == "hotfix/x"
|
|
assert r.json()["include_hidden"] is True
|
|
# Clear: "" → 200 reports null (the tri-state's clear leg).
|
|
r = page.request.patch(f"{base}/{row_id}", data={"branch": ""})
|
|
assert r.status == 200, r.text
|
|
assert r.json()["branch"] is None
|
|
|
|
# --- a branch on a LOCAL row → the fixed 422 ----------------------
|
|
local_dir = tmp_path / "local-src"
|
|
local_dir.mkdir()
|
|
r = page.request.post(
|
|
base, data={"kind": "local", "path": str(local_dir), "branch": "x"}
|
|
)
|
|
assert r.status == 422, r.text
|
|
assert r.json()["detail"] == "branch is only valid for git sources"
|