Files
brain-of-reese/tests/e2e/test_source_ignore_paths.py
T
ducoterra 8c706259e9
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 89_source_ignore_paths
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.
2026-09-09 01:45:42 -04:00

641 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]
)