"""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")