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`.
677 lines
28 KiB
Python
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/**")
|