"""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. expect(page.locator("#sync-result")).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). expect(page.locator("#sync-result")).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) expect(page.locator("#sync-result")).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. expect(page.locator("#sync-result")).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"