Files
brain-of-reese/tests/e2e/test_hidden_folders_toggle.py
ducoterra d731169b8b
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: 105_hidden_folders_toggle
All completion criteria verified. Everything is green.

**Phase 105 final verification pass — all criteria verified**

- Verified the full implementation in the working tree: `git_sources.include_hidden` column + alembic `0019` (dev DB at head, column present), `iter_importable_files`/`import_sources` flag support with `str(root)`-keyed map used by both walk and progress pre-walk, `GitSourcePatchIn` rename with optional fields, sync/CLI pipeline wiring (OR-collision), and the per-row "Hidden" checkbox + tag + error line on the Sources page
- Unit + integration: `uv run pytest` → exit 0 (2148 tests collected, all pass; this sandbox occasionally swallows pytest's final status line — exit codes verified)
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (3879 stmts, 15 miss) — >90% gate ✓
- Dedicated E2E: `uv run pytest tests/e2e/test_hidden_folders_toggle.py -v --no-cov` → **6 passed in 23.20s** (DB up, in isolation)
- Regression E2E in isolation: `test_source_ignore_paths` 6 passed, `test_git_sources_admin` 6 passed, `test_local_directory_sources` 3 passed, `test_sync_button` 3 passed, `test_smoke` 3 passed
- Lint/types: `uv run ruff check .` + `uv run pyright` → clean (0 errors/warnings)

**Completion criteria:** (1) checkbox persists via PATCH 200 → "hidden on" tag + GET round-trips `include_hidden: true`; failure path reverts box + `role="alert"` canned message ✓; (2) flag OFF byte-identical (only `visible.md` indexed), ON indexes `.hidden/note.md` into the KB catalog, `EXCLUDED_DIRS` excluded both states ✓; (3) A2: flag OFF → `detail.pruned==1`, doc gone from catalog ✓; (4) PATCH bool-only/list-only/both/neither no-op, phase-89 fixed 422s unchanged, 404, anonymous 403 (incl. bool-only body) ✓; (5) env-fallback rows render no checkbox, WCAG-clean (aria-label, keyboard focus, visible label, text tag) ✓; (6) full gate green ✓; (7) commit left to the harness per instructions (no `git add`/`commit` run; phase files untouched).

**Deviations:** none — no defects found; no code changes were needed on this pass.

**Next pending phase:** `.agents/phases/todo/98_sync_summary_visibility`.
2026-09-12 21:26:30 -04:00

677 lines
28 KiB
Python

"""Phase 105 story E2E (Playwright): the per-source hidden-folders toggle —
the "Hidden" checkbox next to the "Ignore paths" button makes a source's
dot-prefixed paths indexable (``/git-sources.html``).
Story source: ``TODO.md`` L3 (owner roadmap confirmation 2026-09-14 —
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_hidden_folders_toggle.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) over a fixture tree with one
visible file, one hidden-folder file (the phase-105 subject), and one
``.venv`` file (``EXCLUDED_DIRS`` — never indexed in either state, A1).
The module app boots with one deterministic ``BOR_GIT_SOURCES`` env URL
(the ``test_source_ignore_paths.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 ``#git-sources-gate`` sign-in gate, the manager hidden,
NO ``/api/git-sources`` call on load, 403 on
``GET``/``POST /api/git-sources`` AND ``PATCH /api/git-sources/{id}``
with a bool-only body (the phase-89 anonymous pin extended to the
toggle payload);
* A4: with the default row, a sync indexes ``visible.md`` ONLY —
``detail.files`` counts one, ``.hidden/note.md`` has no ``documents``
row, the checkbox renders UNCHECKED and no "hidden on" tag;
* A1: flipping the checkbox on (the real click) → the PATCH 200 lands
(the "hidden on" tag appears, the announcer ``role=status`` fires the
confirmation AFTER the reload line) → sync → ``.hidden/note.md`` IS
indexed (``documents`` row present; the KB catalog lists it — the
tree/catalog is DB-driven, no extra surface) and ``.venv/junk.md`` is
STILL absent (EXCLUDED_DIRS in both states); the checkbox re-renders
CHECKED (server state);
* A2: flipping it OFF (real click) → sync → ``detail.pruned`` includes
the hidden doc, the catalog no longer lists it, the tag is gone;
* A3: the env-fallback view (table-empty state) renders the "from .env"
tag with NO Hidden checkbox and NO "Ignore paths" button;
* a11y + error surface: the checkbox has a full accessible name
containing the source location (``Index hidden folders for local
source: …``), is keyboard-focusable, the tag text is "hidden on"
(never color alone); ``#git-sources-hidden-error`` exists with
``role="alert"`` and stays ``hidden`` through the happy path — and a
network-aborted PATCH shows the canned "not changed" message and
reverts the box to the server state (§7.4 never-stale).
Test → contract mapping (one test per bullet, the phase-89 suite's
shape):
1. ``test_anonymous_gate_and_403s``
2. ``test_hidden_off_by_default``
3. ``test_toggle_on_indexes_hidden_folders``
4. ``test_toggle_off_prunes_hidden``
5. ``test_env_fallback_rows_have_no_toggle``
6. ``test_toggle_a11y_and_error_surface``
"""
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_HIDDEN", "8141"))
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 three fixture files (source-relative POSIX paths — exactly the
#: strings ``documents.path`` stores).
VISIBLE_MD = "visible.md"
HIDDEN_NOTE = ".hidden/note.md"
VENV_JUNK = ".venv/junk.md"
#: ``POST /api/sync`` → terminal ``GET /api/sync/status`` (the
#: test_sync_button.py polling idiom) — real import of ≤2 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("hidden_src")
(root / ".hidden").mkdir()
(root / ".venv").mkdir()
(root / VISIBLE_MD).write_text(
"# Visible\n"
"\n"
"The visible doc — indexed in EVERY state (flag on and off).\n",
encoding="utf-8",
)
# The phase-105 subject: a file INSIDE a hidden (dot-prefixed)
# folder — indexed only when the row's flag is on (A1).
(root / HIDDEN_NOTE).write_text(
"# Hidden note\n"
"\n"
"A file inside a hidden (dot) folder — the toggle's subject:\n"
"indexed, embedded, and summarized like any visible file when\n"
"the flag is on; pruned from the KB when it flips off (A2).\n",
encoding="utf-8",
)
# EXCLUDED_DIRS content: ``.venv`` is a cache, never content —
# skipped in BOTH flag states (A1).
(root / VENV_JUNK).write_text(
"venv junk — EXCLUDED_DIRS content: never indexed, both states.\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"`` — with NO ``ignore_paths`` and
NO ``include_hidden`` (A4: absent at create → stored ``False``) —
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
assert body["include_hidden"] is False # A4: default off
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).
The tree/catalog is DB-driven — a newly indexed hidden document
appears here automatically (no extra surface)."""
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 _db_document_paths() -> list[str]:
"""Every ``documents.path`` straight from the DB — the literal
"no ``documents`` row" assertion (the catalog is the same table,
read through the API)."""
with SessionLocal() as db:
rows = db.execute(text("SELECT path FROM documents")).fetchall()
return [row[0] for row in rows]
def _row(page: Page, value: str) -> Any:
"""The table row whose mono location cell shows ``value`` (re-resolved
on every call — the row re-renders from the server after each
successful toggle)."""
return page.locator("#git-sources-tbody tr", has_text=value)
def _hidden_box(page: Page, value: str) -> Any:
"""The row's Hidden checkbox (fresh locator — the element is
re-created by the post-toggle re-render)."""
return _row(page, value).locator(".git-source-hidden-box")
def _flip_hidden(page: Page, value: str, wanted_on: bool) -> None:
"""Click the row's Hidden checkbox and wait for the §7.4 success
lifecycle to settle: the PATCH 200 landed, the row re-rendered
from the server, and the CONFIRMATION is the last announcer message
(the reload's "N sources listed." cannot overwrite it — the
phase-89 order)."""
_hidden_box(page, value).click()
tag = _row(page, value).locator(".git-source-hidden-count")
if wanted_on:
expect(tag).to_have_text("hidden on", timeout=30_000)
else:
expect(tag).to_have_count(0, timeout=30_000)
expect(
page.locator("#git-sources-announcer")
).to_have_text(
f"Hidden folders {'enabled' if wanted_on else 'disabled'} for {value}."
)
# ---------------------------------------------------------------------------
# 1. Anonymous: gate, inert manager, no API calls, 403s (incl. the
# bool-only toggle 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_source_ignore_paths.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-105 toggle PATCH with a bool-only body (the row's list is
# untouched — the toggle's exact payload is gated too).
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={"include_hidden": True},
).status
== 403
)
# ---------------------------------------------------------------------------
# 2. A4: the default row is byte-identical to pre-phase-105 — the sync
# indexes visible.md ONLY; the checkbox renders UNCHECKED, no tag
# ---------------------------------------------------------------------------
def test_hidden_off_by_default(
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)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
# A4: the default row renders the NEW control in its default state —
# the checkbox UNCHECKED (checked comes only from server state) and
# NO "hidden on" tag (pre-phase-105 surface + the control).
box = _hidden_box(page, stored_path)
expect(box).to_have_count(1)
expect(box).not_to_be_checked()
expect(_row(page, stored_path).locator(".git-source-hidden-count")).to_have_count(0)
# The real sync: ``visible.md`` ONLY — the walk skipped the
# dot-prefixed ``.hidden/`` component (A4 byte-identical default)
# AND ``.venv/`` (EXCLUDED_DIRS in both states).
sync = run_sync(page, app_url)
assert sync["state"] == "success", sync
detail = sync["detail"]
assert detail["files"] == 1, detail
assert detail["added"] == 1, detail
assert detail["pruned"] == 0, detail
assert detail["errors"] == 0, detail
# The catalog holds exactly the visible file — no ``documents`` row
# for the hidden note (the direct DB read makes "no row" literal)…
paths = _catalog_paths(page, app_url)
assert VISIBLE_MD in paths, f"visible.md missing from the catalog: {paths}"
assert HIDDEN_NOTE not in paths, (
f"hidden note leaked into the catalog with the flag off: {paths}"
)
assert VENV_JUNK not in paths, f"EXCLUDED_DIRS leaked into the catalog: {paths}"
assert _db_document_paths() == [VISIBLE_MD]
# ---------------------------------------------------------------------------
# 3. A1: the real click flips the flag on — the PATCH 200 lands (tag +
# last-announce confirmation), the next sync indexes the hidden
# folder; EXCLUDED_DIRS stays excluded; the box re-renders CHECKED
# ---------------------------------------------------------------------------
def test_toggle_on_indexes_hidden_folders(
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)
# Baseline: the default sync indexes the visible file only.
first = run_sync(page, app_url)
assert first["state"] == "success", first
assert first["detail"]["files"] == 1, first["detail"]
# A1: flip the checkbox on (the real click) — the §7.4 lifecycle:
# PATCH 200 → the "hidden on" tag appears in the source cell and
# the confirmation is the LAST announcer message (the reload's
# "1 source listed." landed first).
_flip_hidden(page, stored_path, wanted_on=True)
# The checkbox re-renders CHECKED from the server state (never a
# local flip) and the API round-trips include_hidden: true.
expect(_hidden_box(page, stored_path)).to_be_checked()
r = page.request.get(f"{app_url}/api/git-sources")
assert r.status == 200, r.text
sources = r.json()["sources"]
assert [s["include_hidden"] for s in sources] == [True]
assert [s["ignore_paths"] for s in sources] == [[]] # the list untouched
# The NEXT sync indexes the hidden-folder file like any visible
# file (embedded + chunked by the mock LLM)…
second = run_sync(page, app_url)
assert second["state"] == "success", second
detail = second["detail"]
assert detail["files"] == 2, detail
assert detail["added"] == 1, detail # the hidden note only
assert detail["unchanged"] == 1, detail # visible.md
# …and ``.venv/junk.md`` is STILL absent — EXCLUDED_DIRS content is
# never indexed, in either state (A1).
paths = _catalog_paths(page, app_url)
assert HIDDEN_NOTE in paths, (
f"hidden note missing from the catalog after the on-sync: {paths}"
)
assert VISIBLE_MD in paths, paths
assert VENV_JUNK not in paths, (
f"EXCLUDED_DIRS leaked into the catalog with the flag on: {paths}"
)
assert set(_db_document_paths()) == {VISIBLE_MD, HIDDEN_NOTE}
# ---------------------------------------------------------------------------
# 4. A2: flipping the flag OFF prunes the previously indexed hidden doc
# from the KB on the next sync
# ---------------------------------------------------------------------------
def test_toggle_off_prunes_hidden(
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)
# On + sync: the hidden note is in the KB.
_flip_hidden(page, stored_path, wanted_on=True)
first = run_sync(page, app_url)
assert first["state"] == "success", first
assert first["detail"]["files"] == 2, first["detail"]
assert first["detail"]["added"] == 2, first["detail"]
assert HIDDEN_NOTE in _catalog_paths(page, app_url)
# A2: flip it OFF (the real click) — the previously indexed hidden
# file leaves the KB on the next sync (the seen-set prune — the
# phase-89 A2 / A9 precedent; the tag is gone).
_flip_hidden(page, stored_path, wanted_on=False)
r = page.request.get(f"{app_url}/api/git-sources")
assert [s["include_hidden"] for s in r.json()["sources"]] == [False]
second = run_sync(page, app_url)
assert second["state"] == "success", second
detail = second["detail"]
assert detail["pruned"] == 1, detail # the hidden doc
assert detail["files"] == 1, detail # the walk is back to visible-only
paths = _catalog_paths(page, app_url)
assert HIDDEN_NOTE not in paths, (
f"the hidden doc survived the flag-off prune: {paths}"
)
assert VISIBLE_MD in paths, f"visible.md must survive the prune: {paths}"
assert _db_document_paths() == [VISIBLE_MD]
# ---------------------------------------------------------------------------
# 5. A3: env-fallback rows have no toggle (no DB row to store a flag on)
# ---------------------------------------------------------------------------
def test_env_fallback_rows_have_no_toggle(
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_source_ignore_paths.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 A3: NO per-row controls at all — neither Remove, nor the
# phase-89 "Ignore paths" button, nor the phase-105 Hidden checkbox
# (and no "hidden on" tag).
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-hidden")).to_have_count(0)
expect(row.locator(".git-source-hidden-box")).to_have_count(0)
expect(row.locator(".git-source-hidden-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, an
# empty ignore list, and include_hidden False (no DB row to store a
# list or a flag on — the GitSourceRow contract, task 03).
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"] == [] and s["include_hidden"] is False
for s in body["sources"]
)
# ---------------------------------------------------------------------------
# 6. a11y + the error surface: the accessible name, keyboard focus, the
# text tag, and the role=alert line (hidden on the happy path, the
# canned message + revert on a network failure)
# ---------------------------------------------------------------------------
def test_toggle_a11y_and_error_surface(
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)
box = _hidden_box(page, stored_path)
expect(box).to_have_count(1)
# The full accessible name contains the source location (the
# aria-label — the ONLY place the value appears, setAttribute,
# never innerHTML)…
expect(box).to_have_attribute(
"aria-label", f"Index hidden folders for local source: {stored_path}"
)
expect(
page.get_by_role(
"checkbox", name=f"Index hidden folders for local source: {stored_path}"
)
).to_have_count(1)
# …it is keyboard-reachable — a plain Tab sequence from the page
# top reaches it (one full focus cycle covers every focusable
# element; the visible ring is the GLOBAL :focus-visible rule,
# styles.css — unit-pinned, no per-control rule)…
for _ in range(60):
page.keyboard.press("Tab")
if box.evaluate("el => el === document.activeElement"):
break
expect(box).to_be_focused()
# …and it receives programmatic focus too (the a11y trees agree).
box.focus()
expect(box).to_be_focused()
# …and the visible "Hidden" label wraps the box (text + control —
# never color or icon alone).
label = page.locator("label.git-source-hidden")
expect(label).to_have_count(1)
expect(label).to_contain_text("Hidden")
# The page-level error line exists, is a real role=alert, and stays
# hidden through the happy path.
error = page.locator("#git-sources-hidden-error")
expect(error).to_have_count(1)
assert error.get_attribute("role") == "alert"
expect(error).to_be_hidden()
# The tag is TEXT (never color alone — WCAG 1.4.1): flip on…
_flip_hidden(page, stored_path, wanted_on=True)
expect(
_row(page, stored_path).locator(".git-source-hidden-count")
).to_have_text("hidden on")
expect(error).to_be_hidden() # the happy path never touches the line
# §7.4 failure path: a network-level abort of the PATCH → the
# canned "not changed" message in the role=alert line and the box
# reverts to the SERVER state (still ON — the request never
# landed) and re-enables. The tag stays (the server is unchanged).
def _abort_patch(route: Any) -> None:
if route.request.method == "PATCH":
route.abort()
else:
route.continue_()
page.route("**/api/git-sources/**", _abort_patch)
try:
_hidden_box(page, stored_path).click()
expect(error).to_be_visible(timeout=30_000)
expect(error).to_have_text(
"Could not reach the server — the setting was not changed."
)
expect(
_row(page, stored_path).locator(".git-source-hidden-count")
).to_have_count(1) # the server state is unchanged: still on
reverted = _hidden_box(page, stored_path)
expect(reverted).to_be_checked() # reverted to the server state
expect(reverted).to_be_enabled() # re-enabled — never stale
finally:
page.unroute("**/api/git-sources/**")