phase: 89_source_ignore_paths
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 13s

All verification complete — TODO.md was already cleared in the roadmap commit; the two extra unit-test diffs are necessary fake-signature adaptations for the new keywords. Everything is green, no fixes were needed.

## Phase 89 — final verification pass: ALL GREEN

**Verified (all 6 task files present in `complete/`):**
- `git_sources.ignore_paths` JSONB column + migration 0013; `alembic downgrade -1 && upgrade head` round-trips (head `0013`)
- Importer: `normalize_ignore_path`/`is_ignored`/`_ignore_for_root`, `ignore` in walk + progress pre-walk, `ignore_by_root` in `import_sources`
- API: GET/POST carry list; admin-only `PATCH` (replace, 404/422 fixed details, anonymous 403)
- Pipelines wired: `_run_sync`, `_run_upload` re-upload, `scripts/import_docs.py`
- Sources-page box: dialog, §7.4 save lifecycle, `N ignored` tag, a11y; env rows get no box

**Test/lint results:**
- `uv run pytest` → 1808 passed
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%)
- `uv run pytest tests/e2e/test_source_ignore_paths.py -v --no-cov` → 6 passed (isolated, DB up)
- Regressions in isolation: `test_git_sources_admin` 6, `test_archive_upload_sources` 5, `test_sync_button` 3, `test_smoke` 3 — all passed
- `uv run ruff check . && uv run pyright` → clean (0 errors)

**Completion criteria:** box→PATCH 200→count+GET round-trip ✅ · sync excludes `ignore/` (no docs/chunks/embeddings/summaries) + prunes newly-ignored (pruned==2) ✅ · no-mid-path rule E2E ✅ · PATCH 404/422/replace/clear/403 ✅ · full gate green ✅ · commit + phase move left to harness per rules.

**Deviations:** none blocking — E2E pins `files == 4` (overview's "5" was an off-by-one vs its own 6-file tree, documented in-test); `tests/unit/test_importer.py` + `test_sync_button.py` test-double fakes extended for the new keywords (needed for the suite to stay green).

**Next pending phase:** none — `todo/` holds only this phase.
This commit is contained in:
2026-09-09 01:45:42 -04:00
parent 0495e4e7e4
commit 8c706259e9
49 changed files with 3717 additions and 63 deletions
+640
View File
@@ -0,0 +1,640 @@
"""Phase 89 story E2E (Playwright): per-source ignore paths — the box on
the Sources page excludes files/folders from the import (``/git-sources.html``).
Story source: ``TODO.md`` L3 (owner roadmap confirmation 2026-09-08 —
TODO-derived, no separate user-story file).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_source_ignore_paths.py -v --no-cov
The story gate proves the whole item end to end through the REAL page +
REAL API + REAL sync pipeline (the in-app Sync's background task, mock
LLM). **No git, no network** — the suite's single source is a local
directory row (``kind="local"``, phase 38), so the sync walks the
fixture dir directly: no clone, no pull, no remote of any kind. The
module app boots with one deterministic ``BOR_GIT_SOURCES`` env URL
(the ``test_git_sources_admin.py`` module-env pattern) that is NEVER
synced or cloned: every sync in this suite runs with the local DB row
in place (DB rows win over the env fallback), and the one
env-fallback test never triggers a sync.
Contract under test:
* anonymous: the sign-in gate (the ``#git-sources-gate`` pattern), the
manager hidden, NO ``/api/git-sources`` call on load, 403 on
``GET``/``POST /api/git-sources`` AND ``PATCH /api/git-sources/{id}``
(the ``test_git_sources_admin.py`` anonymous pin, extended to the
phase-89 route);
* admin: the per-row "Ignore paths" button opens the page-local
alertdialog (the phase-69 ``#remove-confirm-dialog`` idiom); one path
per line; Save → ``PATCH`` 200 → the row shows the "N ignored" count
tag; the list round-trips through ``GET /api/git-sources``;
* the sync honors the list: with ``ignore/`` set, nothing under
``ignore/`` is indexed — no ``documents`` rows (and therefore no
chunks/embeddings, no summary calls) — and the sync's ``files``
count excludes the ignored files from the walk;
* the spec's no-mid-path rule: ``myfile.md`` ignores the root-level
``myfile.md`` only, never ``sub/myfile.md``; saving the box
REPLACES the list (A5 — the previous test's list is gone);
* A2: a previously indexed file that newly matches an ignore pattern
is PRUNED from the KB on the next sync (``detail.pruned``);
* a11y + the error path: the dialog is a real ``role="alertdialog"``,
the textarea has a visible label, focus lands on Cancel, Escape
closes and returns focus to the trigger; a 501-char entry 422s with
the fixed detail, the textarea content is KEPT and the Save button
re-enables with its "Save" label; the happy path heals the error
state;
* A3: env-fallback rows (table empty → ``BOR_GIT_SOURCES``) render the
"from .env" tag with NO "Ignore paths" button and the env note.
Test → contract mapping:
1. ``test_anonymous_gate_and_403s``
2. ``test_ignore_box_excludes_from_import``
3. ``test_prefix_rule_no_mid_path``
4. ``test_newly_ignored_file_is_pruned``
5. ``test_editor_a11y_and_error_path``
6. ``test_env_fallback_rows_have_no_box``
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
import uuid
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_IGNORE", "8140"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
#: The module app's ``BOR_GIT_SOURCES`` — one deterministic URL that is
#: NEVER cloned (the env-fallback test reads it; the sync tests all run
#: with the local DB row in place, so the env list never reaches a
#: clone).
ENV_SOURCE = "https://github.com/reese/env-alpha.git"
#: The sentinel inside the fixture's keeper doc — proof the (non-
#: ignored) markdown path was really imported.
SENTINEL = "RESE-IGNORE-KEEP-7f3a"
#: The six fixture files (source-relative POSIX paths — exactly the
#: strings ``documents.path`` stores).
KEEP_MD = "keep.md"
NOTES_YAML = "notes.yaml"
ROOT_MYFILE = "myfile.md"
SUB_MYFILE = "sub/myfile.md"
IGNORE_SECRET = "ignore/secret.md"
IGNORE_DEEP = "ignore/deep/x.txt"
ALL_SIX = (KEEP_MD, NOTES_YAML, ROOT_MYFILE, SUB_MYFILE, IGNORE_SECRET, IGNORE_DEEP)
#: ``POST /api/sync`` → terminal ``GET /api/sync/status`` (the
#: test_sync_button.py polling idiom) — real import of ≤6 small files
#: against the mock LLM; generous budget.
SYNC_TIMEOUT_S = 60.0
SYNC_TICK_S = 2.0
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def source_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The local source's on-disk tree (task 06 step 1).
Module-scoped (the same reasoning as ``test_sync_button.py``'s
module-scoped fixture note): ``tmp_path`` is function-scoped while
the module-scoped app + the seeded row reference the dir for the
module's lifetime, so it is built under ``tmp_path_factory`` (the
same pytest-managed temp area, module-safe). Plain files — no git:
a ``kind="local"`` row is walked directly by the sync (phase 38).
"""
root = tmp_path_factory.mktemp("ignore_src")
(root / "sub").mkdir()
(root / "ignore" / "deep").mkdir(parents=True)
(root / KEEP_MD).write_text(
"# Keep\n"
"\n"
"The keeper doc — the one markdown file the ignore list must\n"
"never touch.\n"
f"\n"
f"Marker: {SENTINEL}\n",
encoding="utf-8",
)
# Non-markdown → the phase-30 lite-summary path (proves "not
# summarized" applies to the non-md branch too when ignored).
(root / NOTES_YAML).write_text(
"title: Notes\nitems:\n - one\n - two\n", encoding="utf-8"
)
(root / ROOT_MYFILE).write_text(
"# Root myfile\n\nRoot-level file — matched by the bare "
"``myfile.md`` prefix (the spec's own example).\n",
encoding="utf-8",
)
(root / SUB_MYFILE).write_text(
"# Sub myfile\n\nA namesake in a subdirectory — NEVER matched "
"by ``myfile.md`` (no mid-path matching).\n",
encoding="utf-8",
)
(root / IGNORE_SECRET).write_text(
"# Secret\n\nIgnored by the ``ignore/`` prefix.\n", encoding="utf-8"
)
(root / IGNORE_DEEP).write_text("deep ignored text\n", encoding="utf-8")
return root
@pytest.fixture(scope="module")
def app_server(mock_llm: int, source_dir: Path) -> Iterator[str]:
"""The real app under test — per-module env (the conftest pattern,
module-scoped): one deterministic ``BOR_GIT_SOURCES`` URL (the env
fallback's subject — never cloned), module-scratch checkouts/
upload dirs, the mock LLM. No git anywhere in this suite's path."""
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) — no chat turn is
# ever sent in this suite, but the app boots with the same env
# shape.
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
# The empty-table env fallback (only read while ``git_sources`` is
# EMPTY — the env-fallback test; never cloned in this suite).
env["BOR_GIT_SOURCES"] = ENV_SOURCE
# Module-scratch dirs (never reached by this suite's local-row
# syncs — kept explicit so a shared checkouts dir cannot leak rows
# into the walk).
scratch = source_dir.parent
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
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_all() -> None:
"""Fresh registry + KB per test (the E2E isolation pattern, the
test_sync_button.py module-env DSN): the sync's counts and the
catalog must be each test's own doing."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, kb_overview, "
"git_sources"
)
)
db.commit()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
"""This suite owns ``git_sources`` AND the KB tables (the E2E
isolation pattern): suites run in isolation but share one
Postgres, and a leftover row would flip the app from the
``BOR_GIT_SOURCES`` env fallback to the DB list (and a leftover
document would skew the prune counts). Empty BOTH before and
after every test."""
_truncate_all()
yield
_truncate_all()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _admin_git_sources_page(page: Page, app_url: str) -> None:
"""Real form login landing on the git sources page (admin settled:
Sign out visible, the manager revealed by the page module)."""
login(page, app_url, next=GIT_SOURCES_URL)
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
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()
def _seed_local_source(page: Page, app_url: str, source_dir: Path) -> str:
"""(Re)seed the stored row for the module fixture dir via the REAL
admin API (201) — ``kind="local"`` (the POST contract is unchanged
by phase 89; no ``ignore_paths`` → the stored list is ``[]``) —
then reload the view so the page's table re-fetches the row (the
API seed is server-side: the mounted view does not know about it
without a re-show). Returns the stored (expanded) path — the
row's ``url`` location value, the same string the page renders
and aria-labels."""
r = page.request.post(
f"{app_url}/api/git-sources",
data={"kind": "local", "path": str(source_dir)},
)
assert r.status == 201, r.text
body = r.json()
assert body["ignore_paths"] == [] # the default list, round-tripped
page.reload()
_admin_git_sources_page(page, app_url)
return body["url"]
def run_sync(page: Page, app_url: str) -> dict[str, Any]:
"""``POST /api/sync`` → poll ``GET /api/sync/status`` to a terminal
state (the test_sync_button.py polling idiom — ~2 s ticks, 60 s
budget). Returns the terminal status body."""
r = page.request.post(f"{app_url}/api/sync")
assert r.status == 202, r.text
deadline = time.monotonic() + SYNC_TIMEOUT_S
body: dict[str, Any] = {}
while True:
s = page.request.get(f"{app_url}/api/sync/status")
assert s.status == 200, s.text
body = s.json()
if body["state"] in ("success", "failed"):
return body
assert time.monotonic() < deadline, (
f"sync did not reach a terminal state: {body}"
)
time.sleep(SYNC_TICK_S)
def _catalog_paths(page: Page, app_url: str) -> list[str]:
"""The RAG catalog's data source (``GET /api/docs`` — the Sources
page's table): every ``documents.path`` (source-relative POSIX)."""
r = page.request.get(f"{app_url}/api/docs")
assert r.status == 200, r.text
return [d["path"] for d in r.json()["documents"]]
def _row(page: Page, value: str) -> Any:
"""The table row whose mono location cell shows ``value``."""
return page.locator("#git-sources-tbody tr", has_text=value)
def _open_ignore_editor(page: Page, value: str) -> Any:
"""Click the row's "Ignore paths" button; the alertdialog opens.
Returns the trigger button (focus returns to it on close)."""
btn = _row(page, value).locator(".git-source-ignore")
expect(btn).to_have_count(1)
btn.click()
dialog = page.locator("#ignore-editor-dialog")
expect(dialog).to_be_visible(timeout=30_000)
return btn
def _save_ignore_list(page: Page, value: str, entry: str) -> None:
"""Open the row's box, replace its content with one line (``entry``
— ``""`` clears) and Save; wait for the A5 round-trip: the dialog
closes and the row shows the "1 ignored" / cleared count state."""
_open_ignore_editor(page, value)
page.fill("#ignore-editor-textarea", entry)
page.click("#ignore-editor-save")
if entry.strip():
expect(_row(page, value).locator(".git-source-ignore-count")).to_have_text(
"1 ignored", timeout=30_000
)
else:
expect(_row(page, value).locator(".git-source-ignore-count")).to_have_count(0)
expect(page.locator("#ignore-editor-dialog")).to_be_hidden()
# ---------------------------------------------------------------------------
# 1. Anonymous: gate, inert manager, no API calls, 403s (incl. PATCH)
# ---------------------------------------------------------------------------
def test_anonymous_gate_and_403s(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
# Track every /api/git-sources request the page itself makes — the
# gate must be reached WITHOUT touching the admin API (the
# test_git_sources_admin.py pattern).
api_calls: list[str] = []
page.on(
"request",
lambda r: api_calls.append(r.url) if "/api/git-sources" in r.url else None,
)
page.goto(app_url + GIT_SOURCES_URL)
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
# The sign-in gate (the #sources-gate pattern, phase 16)…
gate = page.locator("#git-sources-gate")
expect(gate).to_be_visible()
expect(gate).to_contain_text("Sign in to manage the git sources")
expect(
gate.locator("a[href='/login.html?next=/git-sources.html']")
).to_have_count(1)
# …and the manager is absent/inert: table + form + env note, all
# inside the hidden #git-sources-content.
expect(page.locator("#git-sources-content")).to_be_hidden()
expect(page.locator("#git-sources-table")).to_be_hidden()
expect(page.locator("#git-source-form")).to_be_hidden()
expect(page.locator("#git-sources-env-note")).to_be_hidden()
# The gate never called the admin API…
assert api_calls == [], f"anonymous page called the git sources API: {api_calls}"
# …and the API 403s anonymous callers on GET/POST AND the
# phase-89 PATCH (the page context has no cookie — the
# test_git_sources_admin.py anonymous pin, extended).
assert page.request.get(f"{app_url}/api/git-sources").status == 403
assert (
page.request.post(
f"{app_url}/api/git-sources", data={"url": ENV_SOURCE}
).status
== 403
)
assert (
page.request.patch(
f"{app_url}/api/git-sources/{uuid.uuid4()}",
data={"ignore_paths": ["ignore/"]},
).status
== 403
)
# ---------------------------------------------------------------------------
# 2. The box sets the list; the sync honors it (nothing under ignore/
# embedded/summarized; the walk's files count excludes it)
# ---------------------------------------------------------------------------
def test_ignore_box_excludes_from_import(
page: Page, app_url: str, db_ready: None, source_dir: Path
) -> None:
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
# Seed the row (no list) through the real API…
stored_path = _seed_local_source(page, app_url, source_dir)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
# …open the box, type "ignore/" (one line), Save → 200 → the row's
# "1 ignored" count tag lands (the A5 round-trip through GET).
_save_ignore_list(page, stored_path, "ignore/")
r = page.request.get(f"{app_url}/api/git-sources")
assert r.status == 200, r.text
body = r.json()
# A1: the raw box line is stored NORMALIZED ("ignore/" → "ignore")
# — the round-trip is canonical.
assert [s["ignore_paths"] for s in body["sources"]] == [["ignore"]]
assert body["from_env"] is False
# The real sync (mock LLM): success, and the walk counted the
# remaining FOUR files — the two ignore/ files never entered it
# (no documents → no chunks/embeddings, no summary calls for
# them). (6 fixture files − 2 ignored = 4; the task's "5" is a
# slip against its own six-file tree — the exclusion is what is
# under test, and it holds either way.)
sync = run_sync(page, app_url)
assert sync["state"] == "success", sync
detail = sync["detail"]
assert detail["files"] == 4, detail
assert detail["added"] == 4, detail
assert detail["errors"] == 0, detail
# The catalog holds exactly the four non-ignored paths.
paths = _catalog_paths(page, app_url)
for p in (KEEP_MD, NOTES_YAML, ROOT_MYFILE, SUB_MYFILE):
assert p in paths, f"{p} missing from the catalog: {paths}"
assert not any(p.startswith("ignore/") for p in paths), (
f"ignored paths leaked into the catalog: {paths}"
)
# ---------------------------------------------------------------------------
# 3. The spec's no-mid-path rule: myfile.md ignores the root-level file
# only; the box REPLACES the previous test's list (A5)
# ---------------------------------------------------------------------------
def test_prefix_rule_no_mid_path(
page: Page, app_url: str, db_ready: None, source_dir: Path
) -> None:
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
stored_path = _seed_local_source(page, app_url, source_dir)
# Bare filename prefix (no leading/trailing slash): the spec's own
# example — matches the root-level file, never the subdirectory
# namesake.
_save_ignore_list(page, stored_path, "myfile.md")
sync = run_sync(page, app_url)
assert sync["state"] == "success", sync
assert sync["detail"]["files"] == 5, sync["detail"]
paths = _catalog_paths(page, app_url)
# No mid-path matching: the namesake in sub/ is indexed…
assert SUB_MYFILE in paths, f"sub/myfile.md was wrongly ignored: {paths}"
# …the root-level file is NOT…
assert ROOT_MYFILE not in paths, f"root myfile.md must be ignored: {paths}"
# …and the ignore/ files ARE indexed this time — the box replaced
# (A5), not appended to, the previous test's "ignore/" list
# (regression guard).
assert IGNORE_SECRET in paths, f"ignore/secret.md must be indexed: {paths}"
assert IGNORE_DEEP in paths, f"ignore/deep/x.txt must be indexed: {paths}"
# ---------------------------------------------------------------------------
# 4. A2: a previously indexed file that newly matches is pruned
# ---------------------------------------------------------------------------
def test_newly_ignored_file_is_pruned(
page: Page, app_url: str, db_ready: None, source_dir: Path
) -> None:
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
stored_path = _seed_local_source(page, app_url, source_dir)
# Sync 1 — no list: all six files are indexed.
first = run_sync(page, app_url)
assert first["state"] == "success", first
assert first["detail"]["files"] == 6, first["detail"]
assert first["detail"]["added"] == 6, first["detail"]
paths = _catalog_paths(page, app_url)
for p in ALL_SIX:
assert p in paths, f"{p} missing after the first sync: {paths}"
# New list: "ignore/" — the two previously indexed files now match.
_save_ignore_list(page, stored_path, "ignore/")
# Sync 2 — the prune=True run deletes the newly ignored documents
# (the seen-set mechanism, the A9 out-of-scope-junk precedent).
second = run_sync(page, app_url)
assert second["state"] == "success", second
detail = second["detail"]
assert detail["pruned"] == 2, detail
assert detail["files"] == 4, detail
paths = _catalog_paths(page, app_url)
for p in (KEEP_MD, NOTES_YAML, ROOT_MYFILE, SUB_MYFILE):
assert p in paths, f"{p} missing after the prune: {paths}"
assert not any(p.startswith("ignore/") for p in paths), (
f"newly ignored paths survived the prune: {paths}"
)
# ---------------------------------------------------------------------------
# 5. The editor's a11y + the 422 error path (and the happy-path heal)
# ---------------------------------------------------------------------------
def test_editor_a11y_and_error_path(
page: Page, app_url: str, db_ready: None, source_dir: Path
) -> None:
page.set_default_timeout(30_000)
_admin_git_sources_page(page, app_url)
stored_path = _seed_local_source(page, app_url, source_dir)
# The trigger is the labeled per-row button (left of Remove)…
row = _row(page, stored_path)
btn = row.locator(".git-source-ignore")
expect(btn).to_have_count(1)
expect(btn).to_have_attribute(
"aria-label", f"Edit ignored paths for local source: {stored_path}"
)
expect(row.locator(".git-source-ignore-count")).to_have_count(0) # no list yet
# …and it opens a real alertdialog (the phase-69 idiom) with the
# visible label and the Cancel-safe focus default.
btn.click()
dialog = page.locator("#ignore-editor-dialog")
expect(dialog).to_be_visible(timeout=30_000)
assert dialog.get_attribute("role") == "alertdialog"
assert dialog.get_attribute("aria-modal") == "true"
# exact: the row button's aria-label ("Edit ignored paths for …")
# contains the substring — the visible <label> is the only exact
# "Ignored paths" name.
expect(page.get_by_label("Ignored paths", exact=True)).to_have_count(1)
# The dialog names the source (textContent — the local path).
expect(page.locator("#ignore-editor-source")).to_have_text(stored_path)
expect(page.locator("#ignore-editor-cancel")).to_be_focused()
# Escape closes as CANCEL — no request — and focus returns to the
# row's trigger button.
page.keyboard.press("Escape")
expect(dialog).to_be_hidden()
expect(btn).to_be_focused()
# The 422 path: one entry of 501 chars (> the A4 500-char limit) →
# the fixed detail, the textarea content KEPT, Save re-enabled with
# its idle label (never stale).
btn.click()
expect(dialog).to_be_visible(timeout=30_000)
bad = "a" * 501
page.fill("#ignore-editor-textarea", bad)
page.click("#ignore-editor-save")
error = page.locator("#ignore-editor-error")
expect(error).to_be_visible(timeout=30_000)
assert error.get_attribute("role") == "alert"
expect(error).to_have_text("an ignore path exceeds 500 characters")
expect(page.locator("#ignore-editor-textarea")).to_have_value(bad)
expect(dialog).to_be_visible() # the fix is one edit + retry
save_btn = page.locator("#ignore-editor-save")
expect(save_btn).to_be_enabled()
expect(save_btn).to_have_text("Save")
# Nothing was stored: the list is still empty (no count tag).
expect(row.locator(".git-source-ignore-count")).to_have_count(0)
# The happy path heals the error state: clear, one entry, Save →
# the dialog closes and the "1 ignored" tag lands.
page.fill("#ignore-editor-textarea", "keep.md")
save_btn.click()
expect(dialog).to_be_hidden(timeout=30_000)
expect(
page.locator("#git-sources-tbody tr .git-source-ignore-count")
).to_have_text("1 ignored", timeout=30_000)
r = page.request.get(f"{app_url}/api/git-sources")
assert r.status == 200, r.text
assert [s["ignore_paths"] for s in r.json()["sources"]] == [["keep.md"]]
# ---------------------------------------------------------------------------
# 6. A3: env-fallback rows have no box (no DB row to store a list on)
# ---------------------------------------------------------------------------
def test_env_fallback_rows_have_no_box(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
# The autouse fixture truncated git_sources — the table is EMPTY,
# so the module app's BOR_GIT_SOURCES URL is the effective list
# (the test_git_sources_admin.py module-env pattern).
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
row = page.locator("#git-sources-tbody tr")
expect(row).to_contain_text(ENV_SOURCE)
# The env-fallback surface: the "from .env" tag (never color
# alone)…
expect(row.locator(".git-source-env-tag")).to_have_count(1)
expect(row.locator(".git-source-env-tag")).to_have_text("from .env")
# …and NO per-row actions at all — neither Remove (nothing stored
# to remove) nor the phase-89 "Ignore paths" box (A3).
expect(row.locator(".git-source-remove")).to_have_count(0)
expect(row.locator(".git-source-ignore")).to_have_count(0)
expect(row.locator(".git-source-ignore-count")).to_have_count(0)
# The env-fallback note explains the active list's origin…
expect(page.locator("#git-sources-env-note")).to_be_visible()
# …and the API agrees: from_env true, null ids, the env URL, and an
# empty ignore list (no DB row to store a list on).
r = page.request.get(f"{app_url}/api/git-sources")
assert r.status == 200, r.text
body = r.json()
assert body["from_env"] is True
assert [s["url"] for s in body["sources"]] == [ENV_SOURCE]
assert all(
s["id"] is None and s["ignore_paths"] == [] for s in body["sources"]
)