phase: 97_kb_tree_catalog
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 11s

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:
2026-09-11 22:48:02 -04:00
parent a49be80b8e
commit ad7585d474
81 changed files with 6299 additions and 211 deletions
+54 -1
View File
@@ -23,6 +23,13 @@ Phase 79 (API tokens): the anonymous pins moved to the gated contract —
phase-16 "the viewer stays open" soft rule is SUPERSEDED, shared chats
are the only open surface). The password sign-in / sign-out /
wrong-password assertions are UNCHANGED.
Phase 97 adaptation: the admin catalog pin (test 4) is re-pointed at
the DRILL-DOWN TREE — the top level lists the fixture's single
indexed-only source (``docs``), the flat 13-row tbody no longer
renders, so the total is re-asserted PER LEVEL (drill → count; the
stat cards carry the KB total). The asserted document behavior is
unchanged; navigation only.
"""
from __future__ import annotations
@@ -81,6 +88,28 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
return _run_in_thread(_import_fixtures(mock_port))
# ---------------------------------------------------------------------------
# Phase 97: the catalog is the drill-down tree the agent's `ls` walks —
# top level = the sources (the fixture's single indexed-only source,
# `docs`), then one folder link per path segment. Small per-suite
# drill helpers (the test_kb_tree house pattern).
# ---------------------------------------------------------------------------
def _drill(page: Page, *names: str) -> None:
"""Drill one level at a time: each name is the EXACT text of the
source/folder link at the current level (client-side — no fetch,
no URL change)."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _go_top(page: Page) -> None:
"""Back to the top level: the breadcrumb's top-level link (call
between drills only — the breadcrumb is hidden at the top)."""
page.locator("#kb-crumb a.kb-crumb-link").first.click()
def _ask(page: Page, question: str) -> None:
"""Send one turn and wait until the grounded answer has fully landed."""
page.fill("#message-input", question)
@@ -247,8 +276,32 @@ def test_admin_login_unlocks_sources_and_tuning(
expect(page.locator("#sources-gate")).to_be_hidden()
expect(page.locator("#stat-docs")).to_have_text("13") # phase 47: +quadlet/j2
expect(page.locator("#stat-chunks")).not_to_have_text("–")
# Phase 97: the catalog is the drill-down tree — the top level
# lists the source (the catalog-rendered signal), the file table
# is hidden there; the flat 13-row total is re-asserted PER LEVEL
# (drill → count; the sum is 3 + 1 + 2 + 1 + 3 + 1 + 1 + 1 = 13).
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
expect(page.locator("#docs-table")).to_be_hidden()
_drill(page, "docs")
expect(page.locator("#folders-tbody tr")).to_have_count(2)
expect(page.locator("#docs-tbody tr")).to_have_count(0) # no root-level files
_drill(page, "homelab")
expect(page.locator("#docs-table")).to_be_visible()
expect(page.locator("#docs-tbody tr")).to_have_count(13)
expect(page.locator("#docs-tbody tr")).to_have_count(3)
_go_top(page)
_drill(page, "docs", "deployments")
expect(page.locator("#docs-tbody tr")).to_have_count(1)
for folder, count in (
("container_gitlab", 2),
("networking", 1),
("quadlet", 3),
("scripts", 1),
("ssh", 1),
("templates", 1),
):
_go_top(page)
_drill(page, "docs", "homelab", folder)
expect(page.locator("#docs-tbody tr")).to_have_count(count)
# Chat: the tuning UI is back — Sign out instead of Sign in, Tune
# under the answer. The header toggle is NOT back: removed from the
+6
View File
@@ -524,7 +524,13 @@ def test_upload_registers_without_indexing(
# Phase 90 A1: the upload indexes NOTHING — the KB is empty…
assert _docs(page, app_url) == []
# …and so is the RAG catalog (the scan is the Sync button's job).
# Phase 97: the registered source renders its 0-document row
# (the load-settled signal; #sources-empty is the zero-SOURCES
# state only) and the file table stays empty.
page.goto(app_url + SOURCES_URL)
row = page.locator("#folders-tbody tr", has_text=SOURCE_NAME)
expect(row).to_have_count(1, timeout=30_000)
expect(row.locator("td:nth-child(2)")).to_have_text("0")
expect(page.locator("#docs-tbody tr")).to_have_count(0)
+4 -1
View File
@@ -280,7 +280,10 @@ def test_persists_across_page_navigation(
# bar at owner request, 2026-08-28 — pinned in
# tests/e2e/test_shared_header.py).
page.goto(f"{app_url}/sources.html")
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# Phase 97: the top level lists the sources (the file table is
# per-level, hidden at the top) — the source row is the
# catalog-rendered signal.
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#new-chat-btn")).to_be_hidden()
# Back to the chat: the conversation is exactly as left — both turns,
+8 -2
View File
@@ -150,7 +150,10 @@ def test_back_from_sources_returns_to_sources(
) -> None:
_reset_db(mock_llm, seed=True)
login(page, app_url) # phase 16: the Sources table is admin-only
# Phase 97: the catalog is the drill-down tree — the kubernetes.md
# row lives at the homelab level (the drill is the only change).
for name in ("docs", "homelab"):
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
row = page.locator("#docs-tbody tr", has_text="kubernetes.md")
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
@@ -174,7 +177,10 @@ def test_back_from_sources_returns_to_sources(
back.click()
expect(page).to_have_url(f"{app_url}/sources.html")
expect(page.locator("#docs-table")).to_be_visible()
# Phase 97: the re-mount lands on the tree's top level (the sources
# list — the file table is per-level, hidden at the top); the
# source row is the catalog-rendered signal.
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
# ---------------------------------------------------------------------------
+16 -1
View File
@@ -107,6 +107,16 @@ def _assert_closed(page: Page) -> None:
expect(page.locator(".doc-modal")).not_to_be_visible()
def _drill(page: Page, *names: str) -> None:
"""Phase 97: the catalog is the drill-down tree the agent's `ls`
sees — click through the source/folder rows (exact name match,
one per name) to the level that holds the asserted file. The drill
is the only change from the flat-table era; the row itself is
unchanged."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
# ---------------------------------------------------------------------------
# 1. Chat source chip → SAME-PAGE modal (no new tab)
# ---------------------------------------------------------------------------
@@ -172,7 +182,9 @@ def test_sources_row_opens_modal(
) -> None:
_reset_db(mock_llm, seed=True)
login(page, app_url) # phase 16: the Sources catalog is admin-only
# Phase 97: the row lives at its folder level (docs → homelab →
# container_gitlab) — the drill is the only change.
_drill(page, "docs", "homelab", "container_gitlab")
row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml")
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
@@ -320,6 +332,9 @@ def test_modal_xss_safe(
# The Sources table lists every indexed document — the admin entry
# point into the modal for a doc the chat never cited.
login(page, app_url)
# Phase 97: the seeded doc's row lives at the notes/ level — the
# drill is the only change.
_drill(page, "docs", "notes")
row = page.locator("#docs-tbody tr", has_text="xss-fixture.md")
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
+5
View File
@@ -387,6 +387,11 @@ def test_admin_modal_surface_edit(page: Page, app_url: str) -> None:
assert before["summary"] == expected
login(page, app_url) # lands on /sources.html (the catalog is admin-only)
# Phase 97: the catalog is the drill-down tree — the row lives at
# the quadlet level (the drill is the only change; the phase-57
# summary-edit flow on the viewer itself is untouched).
for name in (SOURCE, "quadlet"):
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
row = page.locator("#docs-tbody tr", has_text=DOC_PATH)
expect(row).to_have_count(1)
before_tabs = len(page.context.pages)
+7 -1
View File
@@ -379,7 +379,13 @@ def test_tuning_page_a11y_and_no_cdn(
# Landmarks (PLAN §7.2).
assert page.locator("header.app-header").count() == 1, "header missing"
assert page.locator("nav[aria-label]").count() == 1, "labeled nav missing"
# Phase 97: the shell carries a SECOND nav in the RAG view
# (#kb-crumb, the catalog breadcrumb — hidden at the top level,
# pinned in test_kb_tree), so the primary-nav landmark is pinned by
# its label.
assert page.locator("nav[aria-label='Primary']").count() == 1, (
"labeled primary nav missing"
)
assert page.locator("main#main").count() == 1, "main#main missing"
assert page.locator("footer.app-footer").count() == 1, "footer missing"
+41 -8
View File
@@ -177,13 +177,25 @@ def _open_menu(page: Page) -> None:
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
def _drill_to_files(page: Page) -> None:
"""Phase 97: the catalog is the drill-down tree — the top level
lists the sources (the file table is per-level, hidden at the
top). The 640px ``min-width`` pin targets the FILE table, so both
RAG-view tests drill to a level that has files (the fixture
source + its ``homelab`` folder) before pinning."""
for name in ("docs", "homelab"):
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _wrap_handle(page: Page, view: str) -> JSHandle:
"""The table card (``.table-wrap``) of ``view``: the id'd cards for
history/tokens, the RAG card found through its ``.docs-table``
(that card is the only one without an id)."""
history/tokens, the RAG card found through its FILE table (phase 97:
the RAG view carries TWO cards — ``#folders-wrap`` at the top level
and the per-level file table's card; the 640px pin is the file
table's, and the tests drill there first)."""
if view == "sources":
return page.evaluate_handle(
"() => document.querySelector('.docs-table').closest('.table-wrap')"
"() => document.querySelector('#docs-table').closest('.table-wrap')"
)
return page.evaluate_handle(f"() => document.querySelector('#{view}-table-wrap')")
@@ -381,14 +393,25 @@ def test_rag_view_regression(
regress it — AND its table is still full-width inside the card:
the 640px ``min-width`` still engages, so the card keeps its
in-card scroll (the shared rule added a containing block, not a
width)."""
width).
Phase 97: the catalog is the drill-down tree — the top level lists
the sources, so the FILE table (the 640px pin's subject) is reached
by drilling ``docs`` → ``homelab`` first; the drill is the only
change to this pin."""
summary = _seed_kb(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page = _mobile_page(browser)
try:
login(page, app_url, next="/sources.html")
_wait_settled_admin(page)
expect(page.locator(".docs-table")).to_be_visible(timeout=15_000)
# Phase 97: the top level lists the sources (the source row is
# the catalog-rendered signal); the 640px in-card-scroll pin
# targets the FILE table, so drill to a level that has files
# first (the drill is the only change).
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=15_000)
_drill_to_files(page)
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/sources.html (direct)")
_assert_in_card_scroll(page, _wrap_handle(page, "sources"), "/sources.html")
@@ -411,7 +434,10 @@ def test_desktop_unchanged(
than the mobile 346px card. The zero-offset
``position: relative`` changed no layout, so desktop is
byte-identical in behavior. The KB is seeded the house way for
the RAG view's table (it hides itself on an empty KB).
the RAG view's table (it hides itself on an empty KB). Phase 97:
the RAG view's table is the FILE table — the top level lists the
sources, so the sources pass drills ``docs`` → ``homelab`` first
(the drill is the only change).
Data state: the desktop pin is enforced on the EMPTY-table state
the module docstring declares ("the History/Tokens contract holds
@@ -443,8 +469,15 @@ def test_desktop_unchanged(
if i: # the first view is the login landing
page.goto(app_url + path)
_wait_settled_admin(page)
marker = ".docs-table" if view == "sources" else f"#{view}-table-wrap"
expect(page.locator(marker)).to_be_visible(timeout=15_000)
if view == "sources":
# Phase 97: the top level lists the sources — the
# container-width pin is the FILE table's (the 640px
# min-width's desktop counterpart), so drill to a level
# that has files first.
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=15_000)
_drill_to_files(page)
marker = ("#docs-tbody tr" if view == "sources" else f"#{view}-table-wrap")
expect(page.locator(marker).first).to_be_visible(timeout=15_000)
_assert_viewport_width(page, f"{path} (desktop)")
report = _wrap_handle(page, view).evaluate(
+173 -8
View File
@@ -11,14 +11,33 @@ fixture, not the subject of the tests.
Phase 16 adaptation: the Sources catalog is admin-only — every test
performs the real form login (``e2e.auth_helpers.login``) first.
Phase 97 adaptation: the catalog is the DRILL-DOWN TREE the agent's
``ls`` walks — the top level lists the sources (this fixture's single
indexed-only source, ``docs``), then one folder link per path segment;
the flat all-documents table no longer renders. The asserted rows,
links, and stat cards are UNCHANGED in intent — the drill is the only
change (every row here is nested under a folder). The empty-state test
now pins the zero-SOURCES state (a registered 0-document source renders
its row instead) — for that state to be reachable at all, this suite
runs its OWN module app (the conftest leak-guard pattern) with
``BOR_GIT_SOURCES`` forced empty: the session app would inherit an
operator's local ``.env`` fallback source, which would leak a
0-document top-level row into the tree.
"""
from __future__ import annotations
import asyncio
import json
import os
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Browser, Page, expect
from sqlalchemy import text
@@ -27,10 +46,86 @@ from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
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]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
# 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_IMPORTDOCS", "8140"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env (the conftest pattern):
the leak guards force the code defaults, and ``BOR_GIT_SOURCES`` is
forced EMPTY (phase 97 — the registered sources now render as
top-level rows: the operator's ``.env`` fallback source would leak
a 0-document source into the tree and break the empty-state test's
zero-SOURCES state). The session app is never started in this
isolated run, so no port clash."""
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).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
# Phase 67: instant retry waits + the code-default budget.
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
# Phase 97: the registry is this suite's own concern (see the
# fixture docstring) — the env fallback is git-only, empty here.
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
EXPECTED_ROWS = (
"homelab/kubernetes.md",
"homelab/backups.md",
@@ -78,15 +173,57 @@ def _run_in_thread(coro: Any) -> Any:
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
"""Truncate the KB (and query log), then optionally re-import fixtures.
Phase 97: the registry (``git_sources``) and the stored folder
descriptions (``folder_summaries``) are truncated too — they now
RENDER in the RAG view (top-level source rows + descriptions), so a
leftover row from another suite would show up as a 0-document
source and break the empty-state test's zero-SOURCES state."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.execute(
text("TRUNCATE chunks, documents, query_log, git_sources, folder_summaries")
)
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
# ---------------------------------------------------------------------------
# Phase 97: the catalog is the drill-down tree the agent's `ls` walks —
# the top level lists the SOURCES (this fixture's single indexed-only
# source: `docs`, the fixtures dir's basename — import_sources seeded it
# with no registry row), then one folder link per path segment. The
# flat all-documents table is gone; the drill is the only change (the
# asserted rows/links are the same).
# ---------------------------------------------------------------------------
SOURCE_NAME = "docs" # the fixtures dir's basename (the indexed-only source)
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 test_kb_tree house pattern)."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _go_top(page: Page) -> None:
"""Back to the top level (the sources list): the breadcrumb's
top-level link (hidden AT the top — call between drills only)."""
page.locator("#kb-crumb a.kb-crumb-link").first.click()
def _wait_top_level(page: Page) -> None:
"""The tree's single fetch settled: the source row is rendered
(the catalog-rendered signal — the top-level file table is always
hidden, so it is no longer a usable one)."""
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
def test_sources_page_lists_indexed_docs(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
@@ -111,11 +248,25 @@ def test_sources_page_lists_indexed_docs(
expect(page.locator("#stat-last")).not_to_have_text("–")
expect(page.locator("#sources-empty")).to_be_hidden()
for row_path in EXPECTED_ROWS:
_wait_top_level(page)
# Phase 97: every row is nested under a folder — drill source →
# folder(s) per path before asserting (the flat 13-row tbody no
# longer exists; the stat cards above hold the KB total).
for i, row_path in enumerate(EXPECTED_ROWS):
if i > 0:
_go_top(page)
_drill(page, SOURCE_NAME, *row_path.rsplit("/", 1)[0].split("/"))
expect(page.locator("#docs-tbody tr", has_text=row_path)).to_have_count(1)
# The hidden junk was never indexed (A9 scope).
# The hidden junk was never indexed (A9 scope) — scoped to the
# level where it WOULD appear: no `.hidden` file row at the source
# level AND no `.hidden` folder row there either (the stat cards
# pin the 13 total, so the junk is counted nowhere).
_go_top(page)
_drill(page, SOURCE_NAME)
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
expect(page.locator("#folders-tbody .folder-link", has_text=".hidden")).to_have_count(0)
# The path column carries the full path for hover (ellipsis is visual only).
_drill(page, "homelab")
expect(page.locator("#docs-tbody tr", has_text="homelab/kubernetes.md")
.get_by_role("cell").nth(1)).to_have_attribute("title", "homelab/kubernetes.md")
@@ -125,11 +276,18 @@ def test_sources_table_layout(
) -> None:
_reset_db(mock_llm, seed=True)
login(page, app_url) # phase 16: the catalog is admin-only
# Phase 97: the top level lists the sources (the file table is
# hidden there) — wait for the source row, then drill to a file
# level (docs → homelab) for the file table's layout asserts.
_wait_top_level(page)
_drill(page, SOURCE_NAME, "homelab")
page.locator("#docs-tbody tr").first.wait_for(state="visible")
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap —
# scope to the RAG view.
wrap = page.locator("#view-rag .table-wrap")
# scope to the RAG view. Phase 97: the RAG view carries TWO
# .table-wraps (#folders-wrap + the file table's) — :has() targets
# the file table's (the one these layout pins were written for).
wrap = page.locator("#view-rag .table-wrap:has(#docs-table)")
expect(wrap).to_be_visible()
expect(wrap).to_have_attribute("role", "region")
expect(wrap).to_have_attribute("tabindex", "0")
@@ -146,9 +304,13 @@ def test_sources_table_layout(
mobile = browser.new_page(viewport={"width": 375, "height": 812})
try:
login(mobile, app_url) # phase 16: the catalog is admin-only
_wait_top_level(mobile)
_drill(mobile, SOURCE_NAME, "homelab")
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
# Phase 97: target the FILE table's wrap explicitly (a bare
# .table-wrap query would hit #folders-wrap first now).
scroll_width, client_width = mobile.evaluate(
"() => { const el = document.querySelector('#view-rag .table-wrap');"
"() => { const el = document.querySelector('#docs-table').parentElement;"
" return [el.scrollWidth, el.clientWidth]; }"
)
assert scroll_width > client_width
@@ -165,6 +327,9 @@ def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_re
expect(page.locator("#sources-empty code")).to_have_text(
"uv run python -m scripts.import_docs"
)
expect(page.locator("#view-rag .table-wrap")).to_be_hidden()
# Phase 97: BOTH catalog tables ship in the RAG view — the empty
# state hides both (the original single-wrap pin, extended).
expect(page.locator("#view-rag .table-wrap:has(#docs-table)")).to_be_hidden()
expect(page.locator("#folders-wrap")).to_be_hidden()
expect(page.locator("#stat-docs")).to_have_text("0")
expect(page.locator("#stat-chunks")).to_have_text("0")
+34 -2
View File
@@ -16,6 +16,11 @@ DB isolation: the fixture's source name (``extension_kb``) is
distinctive — the suite never asserts on absolute row counts and
deletes the rows it creates in a ``finally`` (other suites' documents
stay untouched in the shared E2E database).
Phase 97 adaptation: the catalog is the DRILL-DOWN TREE — the rows
live at their folder levels (``extension_kb`` → ``homelab`` →
``scripts`` / ``notes``); the drill is the only change, the asserted
rows/links/modal are unchanged.
"""
from __future__ import annotations
@@ -86,6 +91,20 @@ def _delete_source_rows() -> None:
db.commit()
def _drill(page: Page, *names: str) -> None:
"""Drill one level at a time (phase 97 — client-side, no fetch,
no URL change): each name is the EXACT text of the source/folder
link at the current level."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _go_top(page: Page) -> None:
"""Back to the top level: the breadcrumb's top-level link (call
between drills only — the breadcrumb is hidden at the top)."""
page.locator("#kb-crumb a.kb-crumb-link").first.click()
@pytest.fixture(autouse=True)
def extension_kb(mock_llm: int, db_ready: None) -> Iterator[ImportSummary]:
"""Seed the fixture with the NOVEL scope (``md,sh``) for one test
@@ -110,14 +129,27 @@ def test_admin_sources_lists_the_novel_extension(
login(page, app_url) # phase 16: the catalog is admin-only
# The novel .sh document is listed; the path cell carries the full
# path (the column is ellipsized — the title attribute is the pin).
# Phase 97: the rows live at their folder levels — drill source →
# homelab → scripts first.
_drill(page, SOURCE, "homelab", "scripts")
row = page.locator("#docs-tbody tr", has_text=SH_REL)
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
expect(link).to_have_count(1)
expect(link).to_have_attribute("title", SH_REL)
# The markdown control doc is listed too (never asserted by count —
# other suites' documents may share the shared E2E database).
# The markdown control doc is listed too (its OWN level — never
# asserted by count: other suites' documents may share the shared
# E2E database).
_go_top(page)
_drill(page, SOURCE, "homelab", "notes")
expect(page.locator("#docs-tbody tr", has_text=MD_REL)).to_have_count(1)
# Back to the .sh row's level — the modal asserts below click its
# path link.
_go_top(page)
_drill(page, SOURCE, "homelab", "scripts")
link = page.locator("#docs-tbody tr", has_text=SH_REL).locator(
"td:nth-child(2) a.doc-link"
)
# Format badge: the row's path link opens the same-page modal and
# its meta row shows the .sh format (house assertion style —
+941
View File
@@ -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}"
+1
View File
@@ -393,6 +393,7 @@ def _truncate_all() -> None:
"kb_overview, git_sources, folder_summaries"
)
)
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
db.commit()
+7 -1
View File
@@ -133,8 +133,14 @@ def _ask_table_answer(page: Page, app_url: str) -> Any:
def _open_tables_doc_modal(page: Page, app_url: str) -> None:
"""Admin → Sources → the tables.md row → same-page document modal."""
"""Admin → Sources → the tables.md row → same-page document modal.
Phase 97: the catalog is the drill-down tree — the row lives at
its folder level (``docs`` → ``homelab``); the drill is the only
change."""
login(page, app_url) # phase 16: the Sources catalog is admin-only
for name in ("docs", "homelab"): # the fixture's indexed-only source + folder
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
row = page.locator("#docs-tbody tr", has_text=TABLES_PATH)
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
+5 -4
View File
@@ -608,15 +608,16 @@ def test_viewer_nav_click_full_loads_the_shell_rag_view(
# The arrival is the SHELL at the RAG view's URL: the document loaded
# for real (the sentinel is gone), the RAG view is rendered (first
# table row visible), the Chat view is hidden AND inert in the same
# document, and the RAG link carries the router's single-writer
# active stamp.
# catalog row visible — phase 97: the top level lists the sources;
# the file table is per-level and hidden at the top), the Chat view
# is hidden AND inert in the same document, and the RAG link carries
# the router's single-writer active stamp.
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
assert page.evaluate("() => window.__phase76_viewer") is None, (
"a surviving document's nav click must be a real departure "
"(a fresh document load wipes window globals)"
)
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
assert page.evaluate("() => document.getElementById('view-chat').inert") is True, (
"the chat view ships hidden AND inert in the shell"
+8 -5
View File
@@ -106,7 +106,9 @@ ANSWER = ".msg.brain .bubble:not(.typing)"
#: The other three navbar views (test 2): the nav link, the view's
#: URL (pushState target), and an admin-visible content marker inside
#: the view (proof the view actually showed — the RAG view gets the
#: same treatment with ``#docs-tbody tr`` in test 1).
#: same treatment with ``#folders-tbody tr`` in test 1: phase 97's
#: top level lists the sources, the file table is per-level and
#: hidden at the top).
OTHER_VIEWS: tuple[tuple[str, str, str], ...] = (
("#nav-git-sources", "/git-sources.html", "#git-sources-content"),
("#nav-tuning", "/tuning.html", "#tune-save"),
@@ -329,10 +331,11 @@ def test_rag_switch_mid_stream_completes(
"a real navigation would have wiped the window sentinel — "
"the switch must be same-document"
)
# The RAG view actually showed (the fixture docs' rows are listed)
# and the chat view is hidden (the stream fills it in the
# background — that persistence IS the fix).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# The RAG view actually showed (the source rows are listed — phase
# 97: the top level lists the sources; the file table is per-level
# and hidden at the top) and the chat view is hidden (the stream
# fills it in the background — that persistence IS the fix).
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
# Stay on the RAG view while the stream keeps running (the switch
+9 -5
View File
@@ -109,7 +109,10 @@ TITLE_E = "Phase77 popstate E"
# pollers hit OTHER paths and only run while a job is in flight).
DATA_VIEWS: tuple[tuple[str, str, str], ...] = (
("#nav-history", "/history.html", "/api/chats"),
("#nav-sources", "/sources.html", "/api/docs"),
# Phase 97: the RAG view's list endpoint is the drill-down tree
# (GET /api/docs/tree — the flat /api/docs is the unchanged API
# surface, no longer the view's fetch).
("#nav-sources", "/sources.html", "/api/docs/tree"),
("#nav-git-sources", "/git-sources.html", "/api/git-sources"),
("#nav-tuning", "/tuning.html", "/api/steering"),
)
@@ -568,10 +571,11 @@ def test_stream_survival_control(page: Page, app_url: str, mock_llm: int, db_rea
assert page.evaluate("() => window.__shell_boot") == "phase77", (
"a real navigation would have wiped the window sentinel"
)
# The RAG view actually showed (the fixture docs' rows are listed)
# and the chat view is hidden (the stream fills it in the
# background — that persistence IS the phase-76 fix).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# The RAG view actually showed (the source rows are listed — phase
# 97: the top level lists the sources; the file table is per-level
# and hidden at the top) and the chat view is hidden (the stream
# fills it in the background — that persistence IS the phase-76 fix).
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
# Stay on RAG while the stream keeps running, then return to Chat.
+27 -1
View File
@@ -26,6 +26,11 @@ Test → story mapping (Playwright Mapping Rule):
below threshold **and** zero FTS hits) → honest-positive: the answer
bubble is not ``.is-deflected`` and a source chip names
``templates/deploy.j2``.
Phase 97 adaptation: the Sources table is the DRILL-DOWN TREE — the
rows live at their folder levels (``docs`` → ``homelab`` → ``quadlet``
/ ``templates``); the drill is the only change, the asserted
rows/links are unchanged.
"""
from __future__ import annotations
@@ -99,6 +104,20 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
return _run_in_thread(_import_fixtures(mock_port))
def _drill(page: Page, *names: str) -> None:
"""Drill one level at a time (phase 97 — client-side, no fetch,
no URL change): each name is the EXACT text of the source/folder
link at the current level."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _go_top(page: Page) -> None:
"""Back to the top level: the breadcrumb's top-level link (call
between drills only — the breadcrumb is hidden at the top)."""
page.locator("#kb-crumb a.kb-crumb-link").first.click()
# ---------------------------------------------------------------------------
# 1. Default-extensions import indexes the new formats (API view)
# ---------------------------------------------------------------------------
@@ -133,7 +152,12 @@ def test_quadlet_and_jinja_indexed(page: Page, app_url: str, mock_llm: int, db_r
def test_sources_table_shows_them(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
_reset_db(mock_llm, seed=True)
login(page, app_url) # phase 16: the Sources catalog is admin-only
for path in NEW_FORMAT_DOCS:
for i, path in enumerate(NEW_FORMAT_DOCS):
# Phase 97: the rows live at their folder levels — drill
# source → folder(s) per path (the drill is the only change).
if i > 0:
_go_top(page)
_drill(page, "docs", *path.rsplit("/", 1)[0].split("/"))
row = page.locator("#docs-tbody tr", has_text=path)
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
@@ -154,6 +178,8 @@ def test_container_content_viewable(
_reset_db(mock_llm, seed=True)
login(page, app_url) # phase 16: the Sources catalog is admin-only
# Phase 97: the row lives at its folder level — drill first.
_drill(page, "docs", "homelab", "quadlet")
row = page.locator("#docs-tbody tr", has_text="homelab/quadlet/compose.container")
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
+28 -9
View File
@@ -213,7 +213,10 @@ def test_no_horizontal_overflow_at_viewports(
_assert_no_doc_overflow(page, f"chat @ {width}px")
page.goto(f"{app_url}/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
# Phase 97: the top level lists the sources (the file table
# is per-level, always hidden at the top) — the source row
# is the catalog-rendered signal.
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=10_000)
_assert_no_doc_overflow(page, f"sources @ {width}px")
finally:
page.close()
@@ -276,10 +279,14 @@ def test_sources_table_full_width(
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
login(page, app_url, next="/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap
# (the git-sources one ships hidden) — scope to the RAG view.
wrap_box = page.locator("#view-rag .table-wrap").bounding_box()
# Phase 97: the top level lists the sources (the file table is
# per-level, hidden at the top) — the source row is the
# catalog-rendered signal, and the visible catalog card is
# #folders-wrap (the ONE .table-wrap without an id in the RAG
# view is no longer unique — the shell carries the git-sources
# one too, so scope by the id).
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=10_000)
wrap_box = page.locator("#folders-wrap").bounding_box()
shell_box = page.locator(".sources-shell").bounding_box()
assert wrap_box is not None and shell_box is not None
assert wrap_box["width"] >= 0.80 * shell_box["width"], (
@@ -292,9 +299,11 @@ def test_sources_table_full_width(
mobile = browser.new_page(viewport={"width": 375, "height": 812})
try:
login(mobile, app_url, next="/sources.html") # phase 16: admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
# Phase 97: the top-level catalog card is #folders-wrap (the
# .kb-folders-table keeps its min-width and scrolls in-card).
mobile.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=10_000)
scroll, client = mobile.evaluate(
"() => { const el = document.querySelector('#view-rag .table-wrap');"
"() => { const el = document.querySelector('#folders-wrap');"
" return [el.scrollWidth, el.clientWidth]; }"
)
assert scroll > client, (
@@ -366,9 +375,14 @@ def test_a11y_landmarks_and_labels(
page.wait_for_load_state("networkidle")
expect(page.locator("#auth-gate")).to_be_hidden()
# Landmarks (PLAN §7.2).
# Landmarks (PLAN §7.2). Phase 97: the shell carries a SECOND
# nav in the RAG view (#kb-crumb, the catalog breadcrumb —
# hidden at the top level, pinned in test_kb_tree), so the
# primary-nav landmark is pinned by its label.
assert page.locator("header.app-header").count() == 1, f"header missing on {path}"
assert page.locator("nav[aria-label]").count() == 1, f"labeled nav missing on {path}"
assert page.locator("nav[aria-label='Primary']").count() == 1, (
f"labeled primary nav missing on {path}"
)
assert page.locator("main#main").count() == 1, f"main#main missing on {path}"
assert page.locator("footer.app-footer").count() == 1, f"footer missing on {path}"
@@ -577,6 +591,11 @@ def test_long_content_wraps_without_overflow(
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
login(phone, app_url, next="/sources.html") # phase 16: admin-only
# Phase 97: the catalog is the drill-down tree — the row lives
# at the deep level of the longkb source (the drill is the only
# change).
for name in ("longkb", "deep"):
phone.click(f'#folders-tbody a.folder-link:text-is("{name}")')
row = phone.locator("#docs-tbody tr", has_text="backup_rotation").first
row.wait_for(state="visible", timeout=10_000)
cell = row.get_by_role("cell").nth(1)
+7
View File
@@ -118,8 +118,15 @@ def test_multi_format_import_hidden_doc_excluded(
}
# The Sources page (we're already on it, signed in) reflects the set.
# Phase 97: the file table is hidden at the top level (the source
# row is the tree's top) — drill into the source, where a
# `.hidden` junk row WOULD appear, and scope the absence there
# (the stat card pins the 13 total: the junk is counted nowhere).
expect(page.locator("#stat-docs")).to_have_text("13")
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
page.click('#folders-tbody a.folder-link:text-is("docs")')
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
expect(page.locator("#folders-tbody .folder-link", has_text=".hidden")).to_have_count(0)
def test_gitlab_question_is_grounded_with_gitlab_chip(
+4 -1
View File
@@ -266,7 +266,10 @@ def test_admin_bar_on_all_pages(
page.goto(app_url + SOURCES_URL)
expect(page.locator("#sources-gate")).to_be_hidden()
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# Phase 97: the top level lists the sources (the file table is
# per-level, hidden at the top) — the source row is the
# catalog-rendered signal.
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
assert_shared_bar(page, admin=True, page_kind="sources")
page.goto(app_url + VIEWER_URL)
+7 -3
View File
@@ -305,8 +305,10 @@ def test_full_answer_completes_after_rag_nav_midstream(
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76"
# The RAG view actually mounted (the fixture docs' rows are listed).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# The RAG view actually mounted (the source rows are listed — phase
# 97: the top level lists the sources; the file table is per-level
# and hidden at the top).
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
# Stay on the RAG view while the stream keeps running in the
# background (the switch is ~t+2s; the full answer needs ~9s).
@@ -381,7 +383,9 @@ def test_nav_switch_before_first_token_completes(
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76"
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# Phase 97: the source rows are the top-level catalog-rendered
# signal (the file table is per-level, hidden at the top).
expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000)
# Let the 4s pre-token pause elapse WHILE the RAG view is up — the
# first content frames land while the chat view is still hidden —
+8 -1
View File
@@ -214,7 +214,14 @@ def test_app_header_stuck_at_top_on_sources(
) -> None:
page.set_default_timeout(30_000)
login(page, app_url) # → /sources.html (the table is admin-only)
expect(page.locator("#docs-tbody tr")).to_have_count(TOTAL_DOCS)
# Phase 97: the catalog is the drill-down tree — the top level
# lists the two sources (the KB total rides the stat cards; the
# flat 55-row tbody no longer exists). The LONG page the story
# needs is a file level: drill into `gen` (41 direct docs).
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
expect(page.locator("#stat-docs")).to_have_text(str(TOTAL_DOCS))
page.click('#folders-tbody a.folder-link:text-is("gen")')
expect(page.locator("#docs-tbody tr")).to_have_count(TOTAL_DOCS - 13)
# The precondition the story needs: the page must actually scroll —
# fail loudly if the table ever stops being long enough.
+9
View File
@@ -222,6 +222,10 @@ def test_modal_shows_panel_and_full_page_agrees(
login(page, app_url) # phase 16: the Sources catalog is admin-only
digest_line, pointer_line = _summary_lines(SOURCE, YAML_PATH)
# Phase 97: the catalog is the drill-down tree — the row lives at
# the quadlet level (the drill is the only change).
for name in (SOURCE, "quadlet"):
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
row = page.locator("#docs-tbody tr", has_text=YAML_PATH)
expect(row).to_have_count(1)
before = len(page.context.pages)
@@ -301,6 +305,11 @@ def test_markdown_doc_has_no_summary_panel(
# Modal: same story — no panel, .doc-md is the sole content child.
# (the session is already signed in — the form login above)
page.goto(f"{app_url}/sources.html")
# Phase 97: the re-mount lands on the tree's top level — drill to
# the notes level where the row lives (the drill is the only
# change).
for name in (SOURCE, "notes"):
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
row = page.locator("#docs-tbody tr", has_text=MD_PATH)
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
+29 -4
View File
@@ -185,6 +185,29 @@ def _overview_row() -> KbOverview | None:
return db.get(KbOverview, 1)
# The git fixture repo's directory name — the source name the import
# records for it (the repo_name basename rule, phase 28).
GIT_SOURCE_NAME = "homelab-notes"
def _go_top(page: Page) -> None:
"""Back to the top level (no-op when already there — the
breadcrumb is hidden at the top)."""
if not page.locator("#kb-crumb").is_hidden():
page.locator("#kb-crumb a.kb-crumb-link").first.click()
def _expect_fixture_row(page: Page) -> None:
"""The REAL clone was imported — the fixture path is listed. Phase
97: the catalog is the drill-down tree, so drill source → folder
``notes`` first (the flat all-docs row is the same row, one level
deeper)."""
_go_top(page)
page.click(f'#folders-tbody a.folder-link:text-is("{GIT_SOURCE_NAME}")')
page.click('#folders-tbody a.folder-link:text-is("notes")')
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
def _wait_sync_done(page: Page, app_url: str, timeout_s: float = 60.0) -> dict[str, Any]:
"""Poll the (cookie-authenticated) status endpoint until the run
reaches a terminal state — exactly what the UI's 2 s poll loop
@@ -244,8 +267,9 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
expect(page.locator("#sync-result")).to_have_text("1 added")
# The REAL clone was imported: the fixture path is in the Sources
# table (the sentinel lives inside it).
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
# table (the sentinel lives inside it) — via the drill-down tree
# (phase 97).
_expect_fixture_row(page)
# Phase-31 regeneration ran (the import changed the KB): the single
# kb_overview row is fresh and non-empty (DB check — truncated
@@ -264,8 +288,9 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
# Nothing re-embedded (sha256 delta) — the no-op run announces the
# unchanged count instead of an empty live region.
expect(page.locator("#sync-result")).to_have_text("0 added · 1 unchanged")
# The doc survived the prune re-import (its file is still in the repo).
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
# The doc survived the prune re-import (its file is still in the
# repo) — via the drill-down tree (phase 97).
_expect_fixture_row(page)
# --- 3. Concurrency: one sync at a time ------------------------------------
+29 -3
View File
@@ -267,6 +267,29 @@ def _overview_row() -> KbOverview | None:
return db.get(KbOverview, 1)
# The git fixture repo's directory name — the source name the import
# records for it (the repo_name basename rule, phase 28).
GIT_SOURCE_NAME = "homelab-notes"
def _go_top(page: Page) -> None:
"""Back to the top level (no-op when already there — the
breadcrumb is hidden at the top)."""
if not page.locator("#kb-crumb").is_hidden():
page.locator("#kb-crumb a.kb-crumb-link").first.click()
def _expect_fixture_row(page: Page) -> None:
"""The REAL clone was imported — the fixture path is listed. Phase
97: the catalog is the drill-down tree, so drill source → folder
``notes`` first (the flat all-docs row is the same row, one level
deeper)."""
_go_top(page)
page.click(f'#folders-tbody a.folder-link:text-is("{GIT_SOURCE_NAME}")')
page.click('#folders-tbody a.folder-link:text-is("notes")')
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
def _dismiss_reattached_modal(page: Page) -> None:
"""Close the modal the page load RE-ATTACHED to (phase 32/41
behavior): the dead app's status is still ``failed`` from an
@@ -433,8 +456,9 @@ def test_healthy_sync_still_succeeds(
expect(page.locator("#sync-result")).to_have_text("1 added")
# The REAL clone was imported: the fixture path is in the Sources
# table (the sentinel lives inside it).
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
# table (the sentinel lives inside it) — via the drill-down tree
# (phase 97).
_expect_fixture_row(page)
# Phase-31 regeneration ran (the import changed the KB): the single
# kb_overview row is fresh and non-empty (DB check — truncated
@@ -451,7 +475,9 @@ def test_healthy_sync_still_succeeds(
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
expect(btn).to_be_enabled()
expect(page.locator("#sync-result")).to_have_text("0 added · 1 unchanged")
expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1)
# The doc survived the prune re-import — via the drill-down tree
# (phase 97).
_expect_fixture_row(page)
# And no modal ever opened (no failure on the healthy app).
expect(page.locator(".sync-modal")).to_have_count(0)
+27 -2
View File
@@ -568,7 +568,12 @@ def test_upload_toast_then_navigate_away(
# page).
page.goto(app_url + SOURCES_URL)
# Phase 90 A1: the upload indexed NOTHING — the catalog is empty…
# Phase 90 A1: the upload indexed NOTHING — the catalog shows the
# registered source with 0 documents (phase 97: the source row IS
# the load-settled signal; the file table stays hidden/empty)…
row = page.locator("#folders-tbody tr", has_text=UPLOAD_NAME)
expect(row).to_have_count(1, timeout=30_000)
expect(row.locator("td:nth-child(2)")).to_have_text("0")
expect(page.locator("#docs-tbody tr")).to_have_count(0)
# …and the sync button settles idle with no error UI (the
# sub-second upload run is over by the time this page's 2 s poll
@@ -623,6 +628,11 @@ def test_upload_toast_then_navigate_away(
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
# Phase 97: the catalog is the drill-down tree — drill source →
# folder `docs` before the per-level row asserts (the flat total
# no longer exists in one tbody).
page.click(f'#folders-tbody a.folder-link:text-is("{UPLOAD_NAME}")')
page.click('#folders-tbody a.folder-link:text-is("docs")')
expect(page.locator("#docs-tbody tr")).to_have_count(N_FILES, timeout=30_000)
expect(page.locator("#docs-tbody tr", has_text="docs/00.md")).to_have_count(1)
expect(page.locator("#docs-tbody tr", has_text="docs/24.md")).to_have_count(1)
@@ -738,7 +748,17 @@ def test_sync_live_file_label(
btn = page.locator("#sync-btn")
expect(btn).to_be_visible(timeout=30_000)
expect(page.locator("#sync-label")).to_have_text("Sync sources")
# The button is retry-ready at boot: idle ("Sync sources") — or the
# RE-ATTACHED terminal from a prior test's sync (the module app is
# shared, its in-memory sync state survives the test boundary, and
# the phase-32 boot re-attach renders the last result — "Synced
# HH:MM" after test 1's successful run). Either way the button is
# enabled and a click starts THIS test's run (the assertions below
# are the run's own).
expect(page.locator("#sync-label")).to_have_text(
re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
)
expect(btn).to_be_enabled()
recorder = _TickRecorder(app_url, "/api/sync/status")
recorder.start()
@@ -778,6 +798,11 @@ def test_sync_live_file_label(
expect(btn).to_be_enabled()
expect(btn).not_to_have_attribute("aria-busy")
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
# Phase 97: the catalog is the drill-down tree — drill source →
# folder `notes` before the per-level row asserts (the flat total
# no longer exists in one tbody).
page.click(f'#folders-tbody a.folder-link:text-is("{SYNC_SOURCE_DIR}")')
page.click('#folders-tbody a.folder-link:text-is("notes")')
expect(page.locator("#docs-tbody tr")).to_have_count(N_FILES, timeout=30_000)
expect(page.locator("#docs-tbody tr", has_text="notes/00.md")).to_have_count(1)
expect(page.locator("#docs-tbody tr", has_text=SYNC_SOURCE_DIR)).to_have_count(N_FILES)
+38 -11
View File
@@ -122,7 +122,7 @@ from sqlalchemy.orm import Session
from app.config import Settings
from app.core.theming import COLOR_FIELDS
from app.db import SessionLocal
from app.models import Chunk, Document, SavedChat, UiSettings
from app.models import Chunk, Document, GitSource, SavedChat, UiSettings
from app.rag.sources_meta import bump_sources_version
from e2e.auth_helpers import login
from e2e.conftest import (
@@ -823,7 +823,22 @@ def _seed_tool_docs(db: Session) -> None:
"""The two-document pair (see the constants above): the retrievable
grounding document (one chunk carrying the mock's own bag-of-words
embedding) + the catalog-only read target (no chunks — retrieval
never puts it in context, so the agent's read tool accepts it)."""
never puts it in context, so the agent's read tool accepts it).
Phase 94: the drill-down ``ls`` top level reads the registry — the
seed registers BOTH sources (TRUNCATEd in
``_reset_kb_with_tool_docs``), ``Checklist`` FIRST: registry order
is ``(added_at, id)``, so the mock's drill (first source of the
listing) — and therefore the read — lands on ``read-me.md``
deterministically, independent of the shared dev DB's leftover
registry rows."""
# COMMIT between the inserts (not flush): ``added_at`` is
# ``server_default now()`` — the transaction timestamp — and the
# tie-break is the random uuid ``id``, so one-transaction rows order
# nondeterministically.
db.add(GitSource(url=READ_SOURCE, kind="local"))
db.commit()
db.add(GitSource(url=TOOL_DOC_SOURCE, kind="local"))
db.add(
Document(
source=TOOL_DOC_SOURCE,
@@ -866,11 +881,18 @@ def _seed_tool_docs(db: Session) -> None:
def _reset_kb_with_tool_docs() -> None:
"""Truncate the KB tables (the house reset) and seed the pair — a
direct DB seed, so the turn is genuinely grounded and the mock's
single-read flow runs to completion: ls → read on the first catalog
line (the catalog-only document) → the quoted answer."""
single-read flow runs to completion: ls → drill ls (phase 94 — the
top level lists sources only) → read on the first file line (the
catalog-only document) → the quoted answer. Phase 94: ``git_sources``
joins the TRUNCATE — the top-level listing reads the registry, so
the leftover rows of other suites would change what the drill
targets."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources"
)
)
db.commit()
_seed_tool_docs(db)
@@ -904,20 +926,25 @@ def test_tool_call_lines_gray(page: Page, app_url: str, db_ready: None) -> None:
# Screenshot 1: the yellow "Listing documents" / "Reading" lines —
# now gray accent-ink text with the gray accent-line left border,
# both lines' text intact.
# the lines' text intact. Phase 94: the drill-down ls adds a THIRD
# line between them — the drill ls scoped to the first source of
# the top level (registry order: Checklist — the read target's
# source).
lines = page.locator(".msg.brain .tool-call")
expect(lines).to_have_count(2)
expect(lines).to_have_count(3)
expect(lines.nth(0)).to_contain_text("Listing documents")
expect(lines.nth(1)).to_contain_text("Reading")
expect(lines.nth(1)).to_contain_text(READ_SP)
expect(lines.nth(1)).to_contain_text("Listing documents in")
expect(lines.nth(1)).to_contain_text(READ_SOURCE)
expect(lines.nth(2)).to_contain_text("Reading")
expect(lines.nth(2)).to_contain_text(READ_SP)
_assert_gray(lines.nth(0), "color", GRAY["accent_ink"], label="ls line text")
_assert_gray(
lines.nth(0), "borderLeftColor", GRAY["accent_line"], label="ls line border"
)
_assert_gray(lines.nth(1), "color", GRAY["accent_ink"], label="read line text")
_assert_gray(lines.nth(2), "color", GRAY["accent_ink"], label="read line text")
# The path chip on the Reading line: gray brand-soft background +
# gray ink (the screenshot's code chip — still gray under the ramp).
code = lines.nth(1).locator("code")
code = lines.nth(2).locator("code")
_assert_gray(code, "backgroundColor", GRAY["brand_soft"], label="read chip bg")
_assert_gray(code, "color", GRAY["ink"], label="read chip text")
+24 -9
View File
@@ -60,7 +60,9 @@ Contract under test:
control) and its folder exists on the host under ``BOR_UPLOAD_DIR``
with all three files — but **zero documents are indexed**:
``GET /api/docs`` is empty and the RAG catalog (``/sources.html``)
settles on its empty state;
settles on the source's 0-document row (phase 97: ``#sources-empty``
is the zero-SOURCES state only — a registered 0-document source
renders its ``0`` count instead);
* **test 2 — ignore list, then Sync scans**: fresh state → upload →
success line → the row's phase-89 "Ignore paths" editor: type
``notes``, Save → the row shows the "1 ignored" count tag (the A5
@@ -465,12 +467,17 @@ def test_upload_does_not_scan(
assert _folder_files(folder) == {"alpha.md", "beta.md", "notes/skipme.md"}, (
f"unexpected unpacked files: {_folder_files(folder)}"
)
# …and so is the RAG catalog: it settles on its empty state (the
# visible #sources-empty is the deterministic "the load finished
# with zero documents" signal — a count-0 check alone would race
# the boot loadDocs fetch).
# …and so is the RAG catalog: the registered source renders its
# 0-document row (phase 97: the "zero documents" signal moved from
# #sources-empty — the zero-SOURCES state only — to the source
# row's own `0` count, which is ALSO the deterministic load-settled
# signal; a count-0 check alone would race the boot loadTree
# fetch).
page.goto(app_url + SOURCES_URL)
expect(page.locator("#sources-empty")).to_be_visible(timeout=30_000)
row = page.locator("#folders-tbody tr", has_text=SOURCE_NAME)
expect(row).to_have_count(1, timeout=30_000)
expect(row.locator("td:nth-child(2)")).to_have_text("0")
expect(page.locator("#sources-empty")).to_be_hidden()
expect(page.locator("#docs-tbody tr")).to_have_count(0)
expect(page.locator("#stat-docs")).to_have_text("0")
@@ -533,10 +540,13 @@ def test_ignore_list_then_sync_scans(
("local", str(upload_dir / SOURCE_NAME), [IGNORE_ENTRY])
]
# The RAG page — the catalog is still empty before the scan…
# The RAG page — the catalog is still empty before the scan (the
# source row reads 0 — phase 97's load-settled zero signal)…
page.goto(app_url + SOURCES_URL)
expect(page.locator("#sync-btn")).to_be_visible(timeout=30_000)
expect(page.locator("#sources-empty")).to_be_visible(timeout=30_000)
row = page.locator("#folders-tbody tr", has_text=SOURCE_NAME)
expect(row).to_have_count(1, timeout=30_000)
expect(row.locator("td:nth-child(2)")).to_have_text("0")
expect(page.locator("#sync-label")).to_have_text("Sync sources")
expect(page.locator("#sync-error-banner")).to_be_hidden()
@@ -566,7 +576,12 @@ def test_ignore_list_then_sync_scans(
assert status["files_done"] == 2 and status["files_total"] == 2
# The catalog: exactly the two non-ignored docs, for the source —
# and the ignored one is NOT there.
# and the ignored one is NOT there. Phase 97: files are seen per
# source — drill into the source row first (the two docs sit at
# its root; the ignored notes/ folder is not even listed — the
# existence rule runs over INDEXED paths).
page.click(f'#folders-tbody a.folder-link:text-is("{SOURCE_NAME}")')
expect(page.locator("#folders-wrap")).to_be_hidden() # no subfolders
expect(page.locator("#docs-tbody tr")).to_have_count(2, timeout=30_000)
expect(page.locator("#docs-tbody tr", has_text=SOURCE_NAME)).to_have_count(2)
expect(page.locator("#docs-tbody tr", has_text="alpha.md")).to_have_count(1)