phase: 97_kb_tree_catalog
All completion criteria verified — everything is green, no defects found. Final report: ## Phase 97 final verification pass — ALL GREEN **Verified (no code changes needed):** - `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css` - `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate - Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved **Gates (exact commands):** - `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓) - `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors** - `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation - 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview` **Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run) **Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
This commit is contained in:
@@ -0,0 +1,941 @@
|
||||
"""Phase 97 task 06 E2E (Playwright, mock-only): the RAG view's drill-down
|
||||
catalog tree + the editable folder descriptions.
|
||||
|
||||
The dedicated story suite for ``97_kb_tree_catalog`` (owner request,
|
||||
2026-09-11): the Knowledge base view lists the KB the way the agent's
|
||||
``ls`` sees it (the phase-94 concept, one end to end) — sources at the
|
||||
top, then per level the subfolders with their stored descriptions and
|
||||
the level's files — and the owner edits (or clears) any directory's
|
||||
description with the phase-57 inline affordance. Everything is pinned
|
||||
against the real app + the deterministic mock.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_kb_tree.py -v --no-cov
|
||||
|
||||
KB fixture — a host temp-dir tree (``tmp_path_factory``; the app runs on
|
||||
the same host) with TWO local sources registered through the
|
||||
authenticated API, then the real in-process ``POST /api/sync`` pipeline
|
||||
(the ``test_local_directory_sources.py`` / phase-94 registration +
|
||||
real-Sync pattern; no git anywhere):
|
||||
|
||||
* ``alpha/`` — ``root-note.md`` at the source root, ``one/`` (2 docs),
|
||||
``two/`` (2 docs);
|
||||
* ``beta/`` — ``gamma/`` (2 docs).
|
||||
|
||||
Total: 7 documents; alpha counts 5 (root + 2 + 2), beta counts 2. Every
|
||||
stored description is deterministic: the mock's EXISTING
|
||||
``FOLDER_SUMMARY_MODE`` branch (phase 94 — no mock changes needed)
|
||||
stores, per ≥ 2-doc folder, the canned one-liner naming the folder,
|
||||
``Fixture folder summary for <source>[/<folder>].`` — the ``synced_kb``
|
||||
module fixture pins those exact rows (all with
|
||||
``manually_edited = false``) after the sync, and the tests assert on
|
||||
that exact text.
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the real
|
||||
``lite`` does whatever it does with the folder prompts, while this
|
||||
story's assertions key on the CANNED summaries (the phase-94
|
||||
convention): the stored rows are a pure function of the request, so the
|
||||
drill, the edit/clear, and the keep-through-sync observables are
|
||||
byte-exact only against the mock.
|
||||
|
||||
The view is admin-only (phase 16): the admin flows drive the REAL form
|
||||
login (``e2e.auth_helpers.login`` → ``/sources.html``); the anonymous
|
||||
test uses a fresh context and asserts the sign-in gate + zero catalog
|
||||
fetches.
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule):
|
||||
1. ``test_top_level_lists_sources_with_descriptions`` — the source rows:
|
||||
``alpha`` (5) + ``beta`` (2) in registry order with the canned
|
||||
source-root descriptions (the top-level rows ARE the sources — the
|
||||
``ls()`` equivalence); the top-level file table is HIDDEN (files are
|
||||
per-source); the stat cards read 7 documents + the computed chunks
|
||||
total (the tree walk = the former flat walk).
|
||||
2. ``test_drill_into_source`` — click the ``alpha`` row → breadcrumb
|
||||
``alpha`` (the ``aria-current`` segment); the level block shows
|
||||
alpha's root description (the canned text, title = the source name);
|
||||
the folder rows ``one`` / ``two`` each with count 2 + their canned
|
||||
summaries; the file row ``root-note.md`` (Source column, title,
|
||||
chunks column) in the level's file table.
|
||||
3. ``test_drill_into_folder`` — drill to ``alpha`` → ``two`` →
|
||||
breadcrumb ``alpha`` → ``two``; the level block shows ``two``'s
|
||||
description (title = the full source-relative path); the file rows
|
||||
``two-a`` / ``two-b`` (titles, the Source column ``alpha``);
|
||||
``#folders-wrap`` hidden (no subfolders); the breadcrumb link on
|
||||
``alpha`` goes back up to the source level.
|
||||
4. ``test_edit_folder_description`` — on the ``alpha`` level, Edit on
|
||||
the ``one/`` ROW (pinned: the row surface — the level-block surface
|
||||
is test 6's) → the textarea prefilled with the canned text → set the
|
||||
new text → Save → the new text renders in the row + status
|
||||
"Description updated."; a SQL assert (the ``SessionLocal`` house
|
||||
pattern) on ``folder_summaries``: the row's ``summary`` is the new
|
||||
text AND ``manually_edited`` is true.
|
||||
5. ``test_clear_folder_description`` — an empty save → the text is gone
|
||||
(the row cell emptied — the always-present Edit button stays) +
|
||||
status "Description cleared."; the SQL assert: no row for the folder
|
||||
(the next KB-changing sync regenerates an AI description — the
|
||||
reset path).
|
||||
6. ``test_manual_description_survives_a_changed_sync`` — the
|
||||
LEVEL-BLOCK edit on the ``beta`` source root (the static
|
||||
``#kb-level-edit`` button, ``folder_path ""``); a new file is added
|
||||
to the fixture dir; the real in-process sync (the mock regenerates
|
||||
the OTHER folders' summaries) → the catalog re-fetch renders the
|
||||
edited description UNCHANGED (the SQL row keeps the manual text +
|
||||
the flag — the ``kept_manual`` path, E2E-pinned) while the untouched
|
||||
folders show the canned regenerated text (flag back to false) and
|
||||
the new document lands in the tree (the ``one`` folder counts 3).
|
||||
7. ``test_reload_falls_back_to_top_level`` — drill to ``alpha/two``;
|
||||
delete ``two/``'s documents + chunks directly (the house DB
|
||||
pattern); re-show the RAG view (the nav link re-click — the
|
||||
``bor:view-refresh`` trigger) → the breadcrumb is hidden and the top
|
||||
level renders (the never-stale contract, PLAN §7.4).
|
||||
8. ``test_anonymous_sees_the_gate`` — the RAG view for an anonymous
|
||||
visitor: the sign-in gate visible, no stat cards, no folders/file
|
||||
table, no Edit affordance, and NO ``/api/docs*`` request (the
|
||||
phase-16 soft rule — asserted via the request log, the
|
||||
``test_admin_auth.py`` pattern).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.config import Settings as _Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import FolderSummary
|
||||
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_KBTREE", "8139"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixture constants (deterministic, token-controlled)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
ALPHA = "alpha"
|
||||
BETA = "beta"
|
||||
|
||||
ROOT_NOTE = "root-note.md"
|
||||
ONE_A = "one/one-a.md"
|
||||
ONE_B = "one/one-b.md"
|
||||
ONE_C = "one/one-c.md" # test 6's KB-changing addition
|
||||
TWO_A = "two/two-a.md"
|
||||
TWO_B = "two/two-b.md"
|
||||
GAMMA_A = "gamma/gamma-a.md"
|
||||
GAMMA_B = "gamma/gamma-b.md"
|
||||
|
||||
ALPHA_COUNT = 5 # 1 root note + 2 one/ + 2 two/
|
||||
BETA_COUNT = 2
|
||||
TOTAL_DOCS = ALPHA_COUNT + BETA_COUNT
|
||||
|
||||
#: The sync-time folder descriptions the mock's canned
|
||||
#: ``FOLDER_SUMMARY_MODE`` branch stores (the phase-94 byte-stable
|
||||
#: template — the one-liner names the folder), in ``(source,
|
||||
#: folder_path)`` order: one row per ≥ 2-doc folder (the
|
||||
#: recursive-subtree rule) — the ``""`` rows are the source roots.
|
||||
SUMMARY_FOR = "Fixture folder summary for {}."
|
||||
ALPHA_ROOT_SUM = SUMMARY_FOR.format(ALPHA)
|
||||
ONE_SUM = SUMMARY_FOR.format(f"{ALPHA}/one")
|
||||
TWO_SUM = SUMMARY_FOR.format(f"{ALPHA}/two")
|
||||
BETA_ROOT_SUM = SUMMARY_FOR.format(BETA)
|
||||
GAMMA_SUM = SUMMARY_FOR.format(f"{BETA}/gamma")
|
||||
|
||||
EXPECTED_SUMMARIES: list[tuple[str, str, str]] = [
|
||||
(ALPHA, "", ALPHA_ROOT_SUM),
|
||||
(ALPHA, "one", ONE_SUM),
|
||||
(ALPHA, "two", TWO_SUM),
|
||||
(BETA, "", BETA_ROOT_SUM),
|
||||
(BETA, "gamma", GAMMA_SUM),
|
||||
]
|
||||
assert [
|
||||
(source, folder) for source, folder, _s in EXPECTED_SUMMARIES
|
||||
] == sorted((source, folder) for source, folder, _s in EXPECTED_SUMMARIES)
|
||||
|
||||
#: The hand-edited descriptions (tests 4 and 6) — distinctive sentences
|
||||
#: no part of the fixture or the canned template contains, so the
|
||||
#: round-trip assertions can never pass against the old text.
|
||||
NEW_ALPHA_ONE = (
|
||||
"Owner override: alpha/one holds the one-a and one-b fixture notes — "
|
||||
"the drill catalog's first hand-written description. (RESE-KBTREE-01)"
|
||||
)
|
||||
NEW_BETA_ROOT = (
|
||||
"Owner override: beta is the second fixture source — gamma only. "
|
||||
"(RESE-KBTREE-02)"
|
||||
)
|
||||
|
||||
|
||||
def _md(title: str, body: str) -> str:
|
||||
return f"# {title}\n\n{body}\n"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def kb_tree_dirs(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]:
|
||||
"""The two-source temp tree (see the module docstring): the app
|
||||
server runs on the same host, so the paths are visible to it. The
|
||||
directory NAMES are the source names (``kind=local`` → the
|
||||
directory's basename, phase 38)."""
|
||||
root = tmp_path_factory.mktemp("bor_kb_tree")
|
||||
alpha = root / ALPHA
|
||||
beta = root / BETA
|
||||
(alpha / "one").mkdir(parents=True)
|
||||
(alpha / "two").mkdir(parents=True)
|
||||
(beta / "gamma").mkdir(parents=True)
|
||||
|
||||
(alpha / ROOT_NOTE).write_text(
|
||||
_md(
|
||||
"Alpha Root Note",
|
||||
"This file sits directly under the alpha source, not in any "
|
||||
"folder.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(alpha / ONE_A).write_text(
|
||||
_md(
|
||||
"Alpha One A",
|
||||
"Alpha one fixture note A: covers topic A of the alpha "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(alpha / ONE_B).write_text(
|
||||
_md(
|
||||
"Alpha One B",
|
||||
"Alpha one fixture note B: covers topic B of the alpha "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(alpha / TWO_A).write_text(
|
||||
_md(
|
||||
"Alpha Two A",
|
||||
"Alpha two fixture note A: covers topic A of the alpha "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(alpha / TWO_B).write_text(
|
||||
_md(
|
||||
"Alpha Two B",
|
||||
"Alpha two fixture note B: covers topic B of the alpha "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(beta / GAMMA_A).write_text(
|
||||
_md(
|
||||
"Beta Gamma A",
|
||||
"Beta gamma fixture note A: covers topic A of the beta "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(beta / GAMMA_B).write_text(
|
||||
_md(
|
||||
"Beta Gamma B",
|
||||
"Beta gamma fixture note B: covers topic B of the beta "
|
||||
"source tree.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert (alpha / TWO_A).is_file() and (beta / GAMMA_B).is_file()
|
||||
return alpha, beta
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, kb_tree_dirs: tuple[Path, Path]) -> Iterator[str]:
|
||||
"""The real app under test — per-module app (the conftest pattern,
|
||||
cf. ``test_local_directory_sources.py`` / ``test_ls_tree_drilldown.
|
||||
py``): NO ``BOR_GIT_SOURCES`` (the env fallback is git-only — the
|
||||
sources here are DB-registered local directories), the mock LLM, the
|
||||
mock-calibrated threshold, and the leak-guarded code defaults. The
|
||||
session app is never started in this isolated run, so no port clash.
|
||||
``kb_tree_dirs`` is a dependency only for the fixture ordering (the
|
||||
temp tree exists before the app boots — the sync reads it)."""
|
||||
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): this suite never
|
||||
# asks the chat model anything — the gate is never on a path.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
# Phase 67: instant retry waits + the code-default budget (the
|
||||
# conftest leak-guard pattern).
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
|
||||
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 repo's .env file carries the owner's BOR_GIT_SOURCES (the app
|
||||
# reads it from cwd) — override it with an EMPTY value (the env var
|
||||
# beats the .env file): the registry must hold EXACTLY the two
|
||||
# local directories this suite registers (a leftover env git list
|
||||
# would pollute the top-level rows the whole story asserts on).
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
# Leak guards (conftest pattern): an operator's local (gitignored)
|
||||
# .env cannot leak corpus-specific settings into the app under test.
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
_Settings.model_fields["suggestions"].default
|
||||
)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||
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 (the E2E isolation pattern): the E2E suites
|
||||
share one Postgres, so a leftover git_sources row would pollute the
|
||||
top-level rows and a leftover document would show up in the level
|
||||
file tables and the stat cards the suite asserts on exactly."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, git_sources, folder_summaries"
|
||||
)
|
||||
)
|
||||
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
|
||||
|
||||
|
||||
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
|
||||
"""Poll the (cookie-authenticated) status endpoint until the run
|
||||
reaches a terminal state (the test_local_directory_sources pattern,
|
||||
over plain httpx)."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
if body["state"] in ("success", "failed"):
|
||||
return body
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def synced_kb(app_server: str, kb_tree_dirs: tuple[Path, Path]) -> None:
|
||||
"""The story's precondition: the folder-structured KB synced under
|
||||
the deterministic mock.
|
||||
|
||||
Registers the two temp directories through the authenticated API
|
||||
(the ``test_local_directory_sources.py`` pattern — ``alpha`` FIRST,
|
||||
committed separately, so the registry order — ``(added_at, id)`` —
|
||||
lists alpha before beta, the top-level row order the suite
|
||||
asserts), runs the REAL in-process sync (``POST /api/sync`` —
|
||||
walk → chunk → embed → overview → folder summaries → version
|
||||
bump), and pins the stored folder descriptions: the mock's canned
|
||||
``FOLDER_SUMMARY_MODE`` branch (phase 94) makes the sync store one
|
||||
deterministic row per ≥ 2-doc folder — the tests assert on that
|
||||
exact text (and on the phase-97 ``manually_edited`` flag: every
|
||||
stored row starts out AI-written).
|
||||
"""
|
||||
alpha, beta = kb_tree_dirs
|
||||
_truncate_all()
|
||||
with httpx.Client(base_url=app_server, timeout=30.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
r = client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": str(alpha)}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
time.sleep(0.05) # distinct added_at: alpha before beta (registry order)
|
||||
r = client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": str(beta)}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
r = client.post("/api/sync")
|
||||
assert r.status_code == 202, r.text
|
||||
body = _wait_sync_done_http(client)
|
||||
assert body["state"] == "success", body
|
||||
detail = body["detail"]
|
||||
assert detail["added"] == TOTAL_DOCS, detail
|
||||
assert detail["pruned"] == 0, detail
|
||||
assert detail["overview"] is True, detail
|
||||
# The change-gated folder descriptions (phase 94) landed: one row
|
||||
# per ≥ 2-doc folder, the mock's byte-stable text — and every row
|
||||
# AI-written (the ``manually_edited`` flag starts false).
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(
|
||||
select(
|
||||
FolderSummary.source,
|
||||
FolderSummary.folder_path,
|
||||
FolderSummary.summary,
|
||||
FolderSummary.manually_edited,
|
||||
).order_by(FolderSummary.source, FolderSummary.folder_path)
|
||||
).all()
|
||||
assert [
|
||||
(source, folder, summary, False)
|
||||
for source, folder, summary in EXPECTED_SUMMARIES
|
||||
] == [(s, f, t, m) for s, f, t, m in rows], rows
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Page + DB helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _kb_totals() -> tuple[int, int]:
|
||||
"""The KB-wide ``(documents, chunks)`` totals for the two sources,
|
||||
straight from the DB — the values the stat cards' tree walk must
|
||||
render (the former flat /api/docs walk, one level deeper)."""
|
||||
with SessionLocal() as db:
|
||||
docs = db.scalar(
|
||||
text("SELECT count(*) FROM documents WHERE source IN (:a, :b)"),
|
||||
{"a": ALPHA, "b": BETA},
|
||||
)
|
||||
chunks = db.scalar(
|
||||
text(
|
||||
"SELECT count(*) FROM chunks c JOIN documents d "
|
||||
"ON c.document_id = d.id WHERE d.source IN (:a, :b)"
|
||||
),
|
||||
{"a": ALPHA, "b": BETA},
|
||||
)
|
||||
return int(docs), int(chunks)
|
||||
|
||||
|
||||
def _doc_chunks(source: str, path: str) -> int:
|
||||
"""One document's chunk count (the Chunks column value the file
|
||||
row must render — the count ``GET /api/docs`` returns for it)."""
|
||||
with SessionLocal() as db:
|
||||
n = db.scalar(
|
||||
text(
|
||||
"SELECT count(*) FROM chunks c JOIN documents d "
|
||||
"ON c.document_id = d.id WHERE d.source = :s AND d.path = :p"
|
||||
),
|
||||
{"s": source, "p": path},
|
||||
)
|
||||
return int(n)
|
||||
|
||||
|
||||
def _folder_row(source: str, folder: str) -> tuple[str, bool] | None:
|
||||
"""The stored ``(source, folder)`` description as
|
||||
``(summary, manually_edited)`` (``None`` when no row exists) —
|
||||
read back through a fresh session (the ``SessionLocal`` house
|
||||
pattern)."""
|
||||
with SessionLocal() as db:
|
||||
row = db.scalar(
|
||||
select(FolderSummary).where(
|
||||
FolderSummary.source == source,
|
||||
FolderSummary.folder_path == folder,
|
||||
)
|
||||
)
|
||||
return (row.summary, row.manually_edited) if row is not None else None
|
||||
|
||||
|
||||
def _run_sync(app_url: str) -> dict[str, Any]:
|
||||
"""One admin sync through the API — the cookie-authenticated
|
||||
``POST /api/sync`` + the status poll (the ``synced_kb`` pattern,
|
||||
for test 6's mid-suite KB-changing sync)."""
|
||||
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
r = client.post("/api/sync")
|
||||
assert r.status_code == 202, r.text
|
||||
return _wait_sync_done_http(client)
|
||||
|
||||
|
||||
def _wait_top_level(page: Page) -> None:
|
||||
"""The admin boot has rendered the top level: the two source rows
|
||||
are in the folders table (the tree's single fetch settled) and the
|
||||
top-level file table is empty (files are per-source — always hidden
|
||||
at the top)."""
|
||||
expect(page.locator("#folders-tbody tr")).to_have_count(2, timeout=30_000)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
|
||||
|
||||
def _drill(page: Page, *names: str) -> None:
|
||||
"""Drill one level at a time (client-side — no fetch, no URL
|
||||
change): each name is the EXACT text of the source/folder link at
|
||||
the current level (the row builders' link text: the source name,
|
||||
or the folder's last path segment)."""
|
||||
for name in names:
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
|
||||
|
||||
def _expect_level(page: Page, title: str, summary: str) -> None:
|
||||
"""The level block shows the current directory's stored
|
||||
description: the full source-relative path as the title, the
|
||||
description as the text."""
|
||||
expect(page.locator("#kb-level")).to_be_visible()
|
||||
expect(page.locator("#kb-level-title")).to_have_text(title)
|
||||
expect(page.locator("#kb-level-summary")).to_have_text(summary)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. The top level: the source rows (the ls() equivalence) + stat cards
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_top_level_lists_sources_with_descriptions(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
docs, chunks = _kb_totals()
|
||||
assert docs == TOTAL_DOCS # the fixture's precondition (7 documents)
|
||||
|
||||
login(page, app_url) # → /sources.html (the RAG view)
|
||||
_wait_top_level(page)
|
||||
|
||||
# The source rows: registry order (alpha registered first), the
|
||||
# recursive count, the stored (source, "") description — the
|
||||
# top-level rows ARE the sources (the ls() equivalence: name,
|
||||
# count, description).
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text(ALPHA)
|
||||
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text(str(ALPHA_COUNT))
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(ALPHA_ROOT_SUM)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text(BETA)
|
||||
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text(str(BETA_COUNT))
|
||||
expect(rows.nth(1).locator("td:nth-child(3) span")).to_have_text(BETA_ROOT_SUM)
|
||||
# The edit affordance is ALWAYS present (a description can be
|
||||
# CREATED where none is stored) — the row surface, one per row.
|
||||
expect(page.locator("#folders-tbody .kb-summary-edit")).to_have_count(2)
|
||||
|
||||
# The top level carries no breadcrumb and no level block; the
|
||||
# empty state is off (a source IS listed).
|
||||
expect(page.locator("#kb-crumb")).to_be_hidden()
|
||||
expect(page.locator("#kb-level")).to_be_hidden()
|
||||
expect(page.locator("#sources-empty")).to_be_hidden()
|
||||
|
||||
# The file table is HIDDEN at the top level (files are seen per
|
||||
# source, as with ls(source) — the ls() equivalence).
|
||||
expect(page.locator("#docs-table")).to_be_hidden()
|
||||
|
||||
# The stat cards: the KB-wide walk of the WHOLE tree — identical
|
||||
# values to the former flat /api/docs walk.
|
||||
expect(page.locator("#stat-docs")).to_have_text(str(TOTAL_DOCS))
|
||||
expect(page.locator("#stat-chunks")).to_have_text(str(chunks))
|
||||
expect(page.locator("#stat-last")).not_to_have_text("–")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Drill into a source: breadcrumb, level block, folder rows, files
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_drill_into_source(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
root_chunks = _doc_chunks(ALPHA, ROOT_NOTE)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA)
|
||||
|
||||
# Breadcrumb: the top-level link + the current source segment.
|
||||
expect(page.locator("#kb-crumb")).to_be_visible()
|
||||
links = page.locator("#kb-crumb a.kb-crumb-link")
|
||||
expect(links).to_have_count(1)
|
||||
expect(links.nth(0)).to_have_text("Knowledge base")
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_text(ALPHA)
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_attribute(
|
||||
"aria-current", "page"
|
||||
)
|
||||
|
||||
# The level block: the source's stored root description (title =
|
||||
# the source name — the source root is folder "") + the level's
|
||||
# own Edit button (task 05).
|
||||
_expect_level(page, ALPHA, ALPHA_ROOT_SUM)
|
||||
expect(page.locator("#kb-level-edit")).to_be_visible()
|
||||
|
||||
# The subfolder rows: path order, the recursive count, the stored
|
||||
# (AI) description.
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("one")
|
||||
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text("2")
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(ONE_SUM)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text("two")
|
||||
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text("2")
|
||||
expect(rows.nth(1).locator("td:nth-child(3) span")).to_have_text(TWO_SUM)
|
||||
|
||||
# The level's direct files: root-note.md — the UNCHANGED 5-column
|
||||
# contract (makeRow): Source | Path | Title | Chunks | Indexed.
|
||||
expect(page.locator("#docs-table")).to_be_visible()
|
||||
frows = page.locator("#docs-tbody tr")
|
||||
expect(frows).to_have_count(1)
|
||||
expect(frows.nth(0).locator("td:nth-child(1)")).to_have_text(ALPHA)
|
||||
expect(frows.nth(0).locator("a.doc-link")).to_have_text(ROOT_NOTE)
|
||||
expect(frows.nth(0).locator("td:nth-child(3)")).to_have_text("Alpha Root Note")
|
||||
expect(frows.nth(0).locator("td:nth-child(4)")).to_have_text(str(root_chunks))
|
||||
expect(frows.nth(0).locator("td:nth-child(5)")).not_to_have_text("")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Drill into a folder: the file level + the breadcrumb going back up
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_drill_into_folder(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA, "two")
|
||||
|
||||
# Breadcrumb: the top-level link + the source link + the current
|
||||
# folder segment.
|
||||
links = page.locator("#kb-crumb a.kb-crumb-link")
|
||||
expect(links).to_have_count(2)
|
||||
expect(links.nth(0)).to_have_text("Knowledge base")
|
||||
expect(links.nth(1)).to_have_text(ALPHA)
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_text("two")
|
||||
|
||||
# The level block: two's stored description, the FULL
|
||||
# source-relative path as the title.
|
||||
_expect_level(page, f"{ALPHA}/two", TWO_SUM)
|
||||
|
||||
# The file rows: two-a / two-b (path order), the Source column
|
||||
# carries the source (the file nodes are source-scoped in the tree
|
||||
# — the row object restores the flat shape makeRow reads).
|
||||
frows = page.locator("#docs-tbody tr")
|
||||
expect(frows).to_have_count(2)
|
||||
expect(frows.nth(0).locator("td:nth-child(1)")).to_have_text(ALPHA)
|
||||
expect(frows.nth(0).locator("a.doc-link")).to_have_text(TWO_A)
|
||||
expect(frows.nth(0).locator("td:nth-child(3)")).to_have_text("Alpha Two A")
|
||||
expect(frows.nth(1).locator("td:nth-child(1)")).to_have_text(ALPHA)
|
||||
expect(frows.nth(1).locator("a.doc-link")).to_have_text(TWO_B)
|
||||
expect(frows.nth(1).locator("td:nth-child(3)")).to_have_text("Alpha Two B")
|
||||
|
||||
# No subfolders under two: the folders table is hidden.
|
||||
expect(page.locator("#folders-wrap")).to_be_hidden()
|
||||
|
||||
# The breadcrumb link on the source goes back UP to the source
|
||||
# level (client-side — no fetch, no URL change).
|
||||
page.click(f'#kb-crumb a.kb-crumb-link:text-is("{ALPHA}")')
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_text(ALPHA)
|
||||
expect(page.locator("#kb-crumb a.kb-crumb-link")).to_have_count(1)
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("one")
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text("two")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Edit a folder description (the row surface) → manually_edited
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_edit_folder_description(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA)
|
||||
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
row = rows.nth(0)
|
||||
expect(row.locator("a.folder-link")).to_have_text("one")
|
||||
|
||||
# Edit → the inline editor: textarea PREFILLED with the stored
|
||||
# (canned) text, Save / Cancel, and the role=status live region.
|
||||
row.locator(".kb-summary-edit").click()
|
||||
editor = page.locator(".kb-summary-editor")
|
||||
expect(editor).to_have_count(1)
|
||||
expect(editor).to_be_visible()
|
||||
expect(editor).to_have_value(ONE_SUM)
|
||||
expect(page.locator(".kb-summary-save")).to_be_visible()
|
||||
expect(page.locator(".kb-summary-cancel")).to_be_visible()
|
||||
status = row.locator(".kb-summary-status")
|
||||
expect(status).to_have_attribute("role", "status")
|
||||
expect(status).to_have_attribute("aria-live", "polite")
|
||||
|
||||
# Replace the text with the distinctive hand-edit and Save.
|
||||
page.fill(".kb-summary-editor", NEW_ALPHA_ONE)
|
||||
page.click(".kb-summary-save")
|
||||
|
||||
# The live-region confirmation; the new text renders in the row
|
||||
# (the textContent re-render — the canned text is gone); the
|
||||
# always-present Edit button is back.
|
||||
expect(status).to_have_text("Description updated.")
|
||||
expect(row.locator("td:nth-child(3) span")).to_have_text(NEW_ALPHA_ONE)
|
||||
expect(row.locator(".kb-summary-edit")).to_be_visible()
|
||||
|
||||
# The server row: the new text AND the manually_edited flag (the
|
||||
# task-01 keep/keep-out rules apply from this save on — the
|
||||
# sync-time generator must never rewrite or prune the row).
|
||||
stored = _folder_row(ALPHA, "one")
|
||||
assert stored == (NEW_ALPHA_ONE, True), stored
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. An empty save CLEARS — the row is deleted (the reset path)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clear_folder_description(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA)
|
||||
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
row = rows.nth(0)
|
||||
expect(row.locator("a.folder-link")).to_have_text("one")
|
||||
# The row holds a description (the module's canned row — or test
|
||||
# 4's manual edit when the tests run in order); the editor opens
|
||||
# either way (the button is always present).
|
||||
row.locator(".kb-summary-edit").click()
|
||||
expect(page.locator(".kb-summary-editor")).to_be_visible()
|
||||
|
||||
# Select-all + delete — clear the prefilled editor — then Save.
|
||||
page.fill(".kb-summary-editor", "")
|
||||
page.click(".kb-summary-save")
|
||||
|
||||
status = row.locator(".kb-summary-status")
|
||||
expect(status).to_have_text("Description cleared.")
|
||||
# The text is gone: the cell empties (the always-present Edit
|
||||
# button stays — a description can be re-created from the empty
|
||||
# cell).
|
||||
expect(row.locator("td:nth-child(3) span")).to_have_text("")
|
||||
expect(row.locator(".kb-summary-edit")).to_be_visible()
|
||||
|
||||
# The SQL assert: no row for the folder (the next KB-changing
|
||||
# sync regenerates an AI description — the reset path).
|
||||
assert _folder_row(ALPHA, "one") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. The manual description survives a KB-changing sync (kept_manual)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manual_description_survives_a_changed_sync(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
synced_kb: None,
|
||||
kb_tree_dirs: tuple[Path, Path],
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
alpha, _beta = kb_tree_dirs
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, BETA)
|
||||
|
||||
# The beta level: the stored root description + the gamma row
|
||||
# (both canned — AI-written at the module sync).
|
||||
_expect_level(page, BETA, BETA_ROOT_SUM)
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(1)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("gamma")
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(GAMMA_SUM)
|
||||
|
||||
# The LEVEL-BLOCK Edit (the level surface — test 4 pins the row
|
||||
# surface): the static #kb-level-edit button, the source root
|
||||
# (folder_path ""). The textarea is prefilled with the stored text.
|
||||
page.click("#kb-level-edit")
|
||||
editor = page.locator(".kb-summary-editor")
|
||||
expect(editor).to_have_count(1)
|
||||
expect(editor).to_have_value(BETA_ROOT_SUM)
|
||||
page.fill(".kb-summary-editor", NEW_BETA_ROOT)
|
||||
page.click(".kb-summary-save")
|
||||
expect(page.locator("#kb-level .kb-summary-status")).to_have_text(
|
||||
"Description updated."
|
||||
)
|
||||
expect(page.locator("#kb-level-summary")).to_have_text(NEW_BETA_ROOT)
|
||||
|
||||
# The KB-changing sync: a new file lands in alpha/one (the fixture
|
||||
# dir — the app runs on the same host), then the real in-process
|
||||
# sync (the mock's FOLDER_SUMMARY_MODE branch regenerates the
|
||||
# non-manual folders' summaries).
|
||||
(alpha / ONE_C).write_text(
|
||||
_md(
|
||||
"Alpha One C",
|
||||
"Alpha one fixture note C: added for the changed-sync test.",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
body = _run_sync(app_url)
|
||||
assert body["state"] == "success", body
|
||||
assert body["detail"]["added"] == 1, body["detail"]
|
||||
|
||||
# The catalog re-fetch (the nav re-click — the bor:view-refresh
|
||||
# trigger, no document load): the current position (the beta root)
|
||||
# still exists, so the view stays put and re-renders from the NEW
|
||||
# tree — the EDITED description is UNCHANGED (kept_manual: the
|
||||
# generator skipped the owner's row, no lite burn on it)…
|
||||
page.click("#nav-sources")
|
||||
expect(page.locator("#kb-level-summary")).to_have_text(NEW_BETA_ROOT)
|
||||
# …while the untouched folder shows the canned REGENERATED text.
|
||||
expect(
|
||||
page.locator("#folders-tbody tr")
|
||||
.nth(0)
|
||||
.locator("td:nth-child(3) span")
|
||||
).to_have_text(GAMMA_SUM)
|
||||
|
||||
# The new document landed in the tree: back to the top (the
|
||||
# breadcrumb's top-level link), then into alpha — the one folder
|
||||
# now counts 3 with its regenerated canned description.
|
||||
page.locator("#kb-crumb a.kb-crumb-link").nth(0).click()
|
||||
_drill(page, ALPHA)
|
||||
one_row = page.locator("#folders-tbody tr").nth(0)
|
||||
expect(one_row.locator("a.folder-link")).to_have_text("one")
|
||||
expect(one_row.locator("td:nth-child(2)")).to_have_text("3")
|
||||
expect(one_row.locator("td:nth-child(3) span")).to_have_text(ONE_SUM)
|
||||
|
||||
# The SQL asserts: the manual row survived (text + flag), the
|
||||
# untouched rows were regenerated (the canned text, the flag back
|
||||
# to false — the rows are AI-written again).
|
||||
assert _folder_row(BETA, "") == (NEW_BETA_ROOT, True)
|
||||
assert _folder_row(BETA, "gamma") == (GAMMA_SUM, False)
|
||||
assert _folder_row(ALPHA, "one") == (ONE_SUM, False)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 7. A vanished location resets the view to the top level (never stale)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reload_falls_back_to_top_level(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url)
|
||||
_wait_top_level(page)
|
||||
_drill(page, ALPHA, "two")
|
||||
expect(page.locator("#kb-crumb .kb-crumb-current")).to_have_text("two")
|
||||
|
||||
# The folder vanishes: its documents + chunks deleted directly (the
|
||||
# house DB pattern — no sync, no API).
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"DELETE FROM chunks WHERE document_id IN "
|
||||
"(SELECT id FROM documents WHERE source = :s AND path LIKE 'two/%')"
|
||||
),
|
||||
{"s": ALPHA},
|
||||
)
|
||||
db.execute(
|
||||
text("DELETE FROM documents WHERE source = :s AND path LIKE 'two/%'"),
|
||||
{"s": ALPHA},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Re-show the RAG view: the nav-link RE-CLICK is the
|
||||
# bor:view-refresh trigger (the router's re-fetch on the mounted
|
||||
# view — no document load).
|
||||
page.click("#nav-sources")
|
||||
|
||||
# The vanished location resets the view to the top level BEFORE
|
||||
# rendering (PLAN §7.4 — no stale breadcrumb, no stale block): the
|
||||
# breadcrumb and the level block are hidden, the source rows
|
||||
# re-render.
|
||||
expect(page.locator("#kb-crumb")).to_be_hidden()
|
||||
expect(page.locator("#kb-level")).to_be_hidden()
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text(ALPHA)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text(BETA)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 8. Anonymous: the sign-in gate — and zero catalog fetches
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_sees_the_gate(
|
||||
page: Page, app_url: str, synced_kb: None, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
docs_calls: list[str] = []
|
||||
page.on(
|
||||
"request",
|
||||
lambda r: docs_calls.append(r.url) if "/api/docs" in r.url else None,
|
||||
)
|
||||
|
||||
# Fresh context (the function-scoped page fixture — no login).
|
||||
page.goto(app_url + "/sources.html")
|
||||
gate = page.locator("#sources-gate")
|
||||
expect(gate).to_be_visible(timeout=30_000)
|
||||
expect(gate).to_contain_text("Sign in to view the full catalog")
|
||||
|
||||
# The catalog surfaces stay hidden (the tree ships hidden and never
|
||||
# fills for anonymous)…
|
||||
expect(page.locator("#stat-cards")).to_be_hidden()
|
||||
expect(page.locator("#folders-wrap")).to_be_hidden()
|
||||
expect(page.locator("#docs-table")).to_be_hidden()
|
||||
expect(page.locator("#kb-crumb")).to_be_hidden()
|
||||
expect(page.locator("#kb-level")).to_be_hidden()
|
||||
expect(page.locator("#sources-empty")).to_be_hidden()
|
||||
expect(page.locator("#folders-tbody tr")).to_have_count(0)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
# …and there is no Edit affordance anywhere (the row buttons are
|
||||
# never built; the static level button sits in the hidden block).
|
||||
expect(page.locator(".kb-summary-edit:visible")).to_have_count(0)
|
||||
expect(page.locator("#kb-level-edit")).to_be_hidden()
|
||||
|
||||
# The soft rule's wire-level proof (the phase-16 pattern, the
|
||||
# tree edition): not one catalog request — the anonymous gate
|
||||
# branch never fetches /api/docs/tree.
|
||||
assert docs_calls == [], f"anonymous RAG view fetched the catalog: {docs_calls}"
|
||||
Reference in New Issue
Block a user