Files
brain-of-reese/tests/e2e/test_git_source_branch.py
T
ducoterra edf6048a0d
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 150_drop_sync_result
**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/`.
2026-10-02 02:35:03 -04:00

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"