Files
brain-of-reese/tests/e2e/test_import_documents.py
T
ducoterra 99c48cbe06 feat(rag): index markdown KB — chunker, embed client, delta importer, Sources page
Phase 02 (story: import documents):

- fence-aware markdown chunker (heading sections, 200-char overlap,
  heading anchor on every chunk, 1200-char hard cap, fence blocks
  kept atomic and split under the cap)
- LLMClient over aipi (LiteLLM) reusing the openai client's httpx
  transport to send a clean {model, input} payload — the openai SDK
  injects encoding_format, which aipi's openai_like group rejects;
  token-budget batching + halving retry for the endpoint's
  ~1024-token per-request input cap
- two-phase per-file upsert importer: sha256 delta (unchanged skip),
  atomic commit, A9 exclusion walk, per-source prune, per-file error
  tolerance (rollback + log + continue, non-zero CLI exit), adaptive
  re-chunk at half target for URL-dense files the endpoint rejects
- scripts/import_docs CLI (repeatable --source, --prune, --limit,
  defaults ~/Homelab + ~/Deployments)
- GET /api/docs with per-doc chunk counts; Sources page wired to the
  real endpoint (stat cards, full-width a11y table, designed empty
  state, DOM-built rows — no innerHTML)
- tests: 63 passed (chunker/llm/importer units, docs API + importer
  integration), story E2E 3/3 (real endpoints, in-thread import);
  app/ coverage 98%
- real KB imported: 672 docs / 8969 chunks in ~3m, idempotent
  re-run (672 unchanged, 0 batches)
- harness: .agent/validate.sh now gates through uv (pytest +
  coverage >90% + ruff + pyright) instead of system python3
2026-08-21 16:24:45 -04:00

140 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 02 E2E (Playwright): the Sources page reflects the imported KB.
Story: ``.agent/user_stories/import-documents.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_import_documents.py -v --no-cov
Seeding runs the real import function in-process against
``tests/fixtures/docs/`` with the deterministic mock embeddings — it is a
fixture, not the subject of the tests.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Browser, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
EXPECTED_ROWS = (
"homelab/kubernetes.md",
"homelab/backups.md",
"deployments/new-service.md",
)
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def test_sources_page_lists_indexed_docs(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 3
page.goto(f"{app_url}/sources.html")
expect(page.locator("#stat-docs")).to_have_text("3")
expect(page.locator("#stat-chunks")).to_have_text(str(summary.chunks))
expect(page.locator("#stat-last")).not_to_have_text("–")
expect(page.locator("#sources-empty")).to_be_hidden()
for row_path in EXPECTED_ROWS:
expect(page.locator("#docs-tbody tr", has_text=row_path)).to_have_count(1)
# The path column carries the full path for hover (ellipsis is visual only).
expect(page.locator("#docs-tbody tr", has_text="homelab/kubernetes.md")
.get_by_role("cell").nth(1)).to_have_attribute("title", "homelab/kubernetes.md")
def test_sources_table_layout(
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.goto(f"{app_url}/sources.html")
page.locator("#docs-tbody tr").first.wait_for(state="visible")
wrap = page.locator(".table-wrap")
expect(wrap).to_be_visible()
expect(wrap).to_have_attribute("role", "region")
expect(wrap).to_have_attribute("tabindex", "0")
expect(page.locator("#docs-table caption")).to_have_count(1)
# Full-width table: the wrapper uses (well) ≥80% of the 72rem container.
wrap_box = 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"]
# Mobile (375px): the table keeps its 640px min-width → the wrapper
# scrolls horizontally instead of squeezing into a hairline.
mobile = browser.new_page(viewport={"width": 375, "height": 812})
try:
mobile.goto(f"{app_url}/sources.html")
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
scroll_width, client_width = mobile.evaluate(
"() => { const el = document.querySelector('.table-wrap');"
" return [el.scrollWidth, el.clientWidth]; }"
)
assert scroll_width > client_width
finally:
mobile.close()
def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
_reset_db(mock_llm, seed=False)
page.goto(f"{app_url}/sources.html")
expect(page.locator("#sources-empty")).to_be_visible()
expect(page.locator("#sources-empty")).to_contain_text("Nothing indexed yet")
expect(page.locator("#sources-empty code")).to_have_text(
"uv run python -m scripts.import_docs"
)
expect(page.locator(".table-wrap")).to_be_hidden()
expect(page.locator("#stat-docs")).to_have_text("0")
expect(page.locator("#stat-chunks")).to_have_text("0")