phase: 97_kb_tree_catalog
All completion criteria verified — everything is green, no defects found. Final report: ## Phase 97 final verification pass — ALL GREEN **Verified (no code changes needed):** - `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css` - `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate - Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved **Gates (exact commands):** - `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓) - `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors** - `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation - 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview` **Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run) **Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user