Files
brain-of-reese/tests/e2e/test_markdown_tables.py
T
ducoterra ad7585d474
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 11s
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).
2026-09-11 22:48:02 -04:00

357 lines
15 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 44 E2E (Playwright): GFM pipe tables in the shared renderer.
Story: ``.agents/user_stories/markdown-tables.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_markdown_tables.py -v --no-cov
Seeding reuses the real importer against ``tests/fixtures/docs/`` with
the deterministic mock embeddings (same pattern as ``test_chat_rag.py``).
The mock's ``TABLE_TRIGGER`` (``show me a table``, phase 44 task 02)
returns the byte-stable table answer: a 3-column service table, an
``<img onerror>`` XSS probe line, and a deliberately wide 5-column
table. The phase-44 fixture ``homelab/tables.md`` (a 3×3 pipe table
plus a pipe-heavy fenced block) is the viewer/fence subject — the
document viewer is database-only, so the imported row is enough.
Test → story mapping (Playwright Mapping Rule):
1. ``test_chat_table_renders`` — the brain bubble carries
``<div class="md-table-wrap"><table class="md-table">`` with a
``<thead>`` of three ``<th scope="col">`` (Service/Port/Host), the
expected body cells, no raw ``|---|`` separator text, and the turn is
NOT deflected (the honesty-gate interplay is part of the contract).
2. ``test_wide_table_scrolls`` — the wide table's wrapper has
``scrollWidth > clientWidth`` and horizontal scroll moves it; the
page itself has no horizontal overflow (the 46rem column holds).
3. ``test_table_xss_safe`` — the ``<img onerror>`` line renders as
visible, escaped text: zero injected ``<img>`` nodes, no dialog.
4. ``test_viewer_table_renders`` — the fixture's pipe table opens from
the Sources table (admin) in the modal and renders the same
``<table class="md-table">`` (shared renderer, story AC6).
5. ``test_fence_not_a_table`` — the fixture's pipe-heavy fenced block
renders ``<pre><code>``; the only ``<table>`` in the document is the
real pipe table (fences win, story AC3).
6. ``test_plain_pipe_stays_text`` — a grounded prose answer with a lone
``|`` (the mock echoes the question) renders as text, no
``<table>`` (story AC4).
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import 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
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: Carries the mock's ``TABLE_TRIGGER`` ("show me a table") and is
#: on-topic (the fixture set answers it — FTS-OR grounds it, so the
#: turn is HIGH and the suite can assert non-deflection).
QUESTION = "Show me a table of my homelab services?"
#: Grounded kubernetes question with a single ``|`` in the prose — the
#: mock's default branch echoes the question (first 80 chars), so the
#: lone pipe lands in the rendered answer.
PLAIN_QUESTION = "How is my Kubernetes cluster set up? A lone | in prose stays text."
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
TABLES_PATH = "homelab/tables.md"
WIDE_HEADER = "A very long column header to force overflow"
XSS_LINE = "<img src=x onerror=alert(1)>"
EXPECTED_HEADER = ["Service", "Port", "Host"]
EXPECTED_ROWS = [
["Caddy", "80", "homelab-gw"],
["GitLab", "8929", "homelab-git"],
["ntfy", "2087", "homelab-ntfy"],
]
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 (the established house helper).
"""
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 (+ the global prompt-state rows), then optionally
re-import the fixtures (13 docs since phase 47 added quadlet+j2)."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _ask_table_answer(page: Page, app_url: str) -> Any:
"""Drive the trigger question and return the brain bubble once the
whole byte-stable table answer has streamed in (the wide table's
last cell lands last)."""
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").first
bubble.wait_for(state="visible", timeout=30_000)
expect(bubble).to_contain_text("value-five", timeout=30_000)
# Non-deflection is part of the table contract (honesty gate interplay).
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
return bubble
def _open_tables_doc_modal(page: Page, app_url: str) -> None:
"""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()
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text("Service Port Table")
# ---------------------------------------------------------------------------
# 1. Chat: the pipe table renders as a semantic table
# ---------------------------------------------------------------------------
def test_chat_table_renders(
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 == 13 # phase 47: +quadlet/j2 fixtures
page.set_default_timeout(30_000)
bubble = _ask_table_answer(page, app_url)
# Both tables of the answer rendered: the 3-column service table and
# the wide one — each in its horizontal-overflow wrapper.
tables = bubble.locator("table.md-table")
expect(tables).to_have_count(2)
expect(bubble.locator(".md-table-wrap")).to_have_count(2)
# The 3×3 table: <thead> of three <th scope="col"> + the body cells
# (the whole answer has already streamed in — the DOM is settled).
first = tables.nth(0)
headers = first.locator("thead th[scope='col']")
expect(headers).to_have_count(3)
assert headers.all_inner_texts() == EXPECTED_HEADER
rows = first.locator("tbody tr")
expect(rows).to_have_count(3)
for i, cells in enumerate(EXPECTED_ROWS):
assert rows.nth(i).locator("td").all_inner_texts() == cells
# The raw markdown must not survive: no separator row, no raw header
# row as text anywhere in the bubble.
bubble_text = bubble.inner_text()
assert "|---|" not in bubble_text, "the |---| separator leaked into the bubble"
assert "| Service | Port | Host |" not in bubble_text, "the raw header row leaked"
# Grounded retrieval: the table fixture is the top source chip.
chip = page.locator(".msg.brain .source-chip", has_text="homelab/tables.md")
expect(chip).to_have_count(1)
# ---------------------------------------------------------------------------
# 2. Wide table: the wrapper scrolls, the page does not
# ---------------------------------------------------------------------------
def test_wide_table_scrolls(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
bubble = _ask_table_answer(page, app_url)
# The wide table (5 columns, one deliberately long header) sits in
# ITS wrapper — the 3-column table's wrapper is not the scroller.
wrap = bubble.locator(".md-table-wrap", has=page.locator("th", has_text=WIDE_HEADER))
expect(wrap).to_have_count(1)
scroll_width, client_width = wrap.evaluate(
"el => [el.scrollWidth, el.clientWidth]"
)
assert scroll_width > client_width, (
f"the wide table must overflow its wrapper "
f"(scrollWidth {scroll_width} <= clientWidth {client_width})"
)
# Horizontal scrolling (scrollLeft) moves the wrapper's content.
before = wrap.evaluate("el => el.scrollLeft")
wrap.evaluate("el => { el.scrollLeft = 120; }")
after = wrap.evaluate("el => el.scrollLeft")
assert after > before, "the wrapper must scroll horizontally"
# The 46rem chat column must not break the page: no horizontal
# document overflow (PLAN §7.1).
page_scroll, page_client = page.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
assert page_scroll <= page_client, (
f"the page overflowed horizontally ({page_scroll} > {page_client})"
)
# ---------------------------------------------------------------------------
# 3. XSS-safe: the <img onerror> probe renders inert text
# ---------------------------------------------------------------------------
def test_table_xss_safe(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
dialogs: list[str] = []
def _catch(d) -> None: # a fired dialog == the probe executed
dialogs.append(d.message)
d.dismiss()
page.on("dialog", _catch)
_ask_table_answer(page, app_url)
state = page.evaluate(
"""() => {
const el = document.querySelector('.msg.brain .bubble');
return {
imgs: el.querySelectorAll('img').length,
onerror: el.querySelectorAll('[onerror]').length,
text: el.innerText,
html: el.innerHTML,
};
}"""
)
assert state["imgs"] == 0, "the XSS probe became a live <img> element"
assert state["onerror"] == 0, "an onerror attribute survived into the DOM"
# The escaped tag renders as VISIBLE text (the escape-first contract).
assert XSS_LINE in state["text"], "the probe line must be visible text"
assert "&lt;img src=x onerror=alert(1)&gt;" in state["html"]
assert dialogs == [], f"dialog fired — the probe executed: {dialogs}"
# ---------------------------------------------------------------------------
# 4. Shared renderer: the viewer/modal renders the fixture's table
# ---------------------------------------------------------------------------
def test_viewer_table_renders(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
_open_tables_doc_modal(page, app_url)
# The same <table class="md-table"> shape the chat bubble gets — the
# shared renderer (story AC6) serves the viewer too.
table = page.locator("#doc-modal-content table.md-table")
expect(table).to_have_count(1)
headers = table.locator("thead th[scope='col']")
expect(headers).to_have_count(3)
assert headers.all_inner_texts() == EXPECTED_HEADER
rows = table.locator("tbody tr")
expect(rows).to_have_count(3)
for i, cells in enumerate(EXPECTED_ROWS):
assert rows.nth(i).locator("td").all_inner_texts() == cells
assert (
"|---|" not in page.locator("#doc-modal-content").inner_text()
), "the separator row leaked into the viewer"
# ---------------------------------------------------------------------------
# 5. Fences win: the pipe-heavy fenced block is code, never a table
# ---------------------------------------------------------------------------
def test_fence_not_a_table(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
_open_tables_doc_modal(page, app_url)
# The fixture's ``` block (pipe table inside) renders as code —
# fence protection runs before the table pass (story AC3).
pre = page.locator("#doc-modal-content pre code")
expect(pre).to_have_count(1)
expect(pre).to_contain_text("caddy", timeout=30_000)
code_text = pre.inner_text()
assert "| Service | Port |" in code_text, "the fenced header line must stay raw"
assert "|----------|------|" in code_text, "the fenced separator must stay raw"
assert "| caddy | 80 |" in code_text
assert "| gitlab | 8929 |" in code_text
# Exactly ONE table in the whole document — the real pipe table. The
# fenced rows (lowercase "caddy"/"gitlab") must not become cells.
table = page.locator("#doc-modal-content table.md-table")
expect(table).to_have_count(1)
cells = table.locator("th, td").all_inner_texts()
assert "caddy" not in cells and "gitlab" not in cells, (
"the fenced pipe block was parsed as a table"
)
# ---------------------------------------------------------------------------
# 6. Non-tables stay put: a lone pipe in grounded prose renders as text
# ---------------------------------------------------------------------------
def test_plain_pipe_stays_text(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", PLAIN_QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").first
bubble.wait_for(state="visible", timeout=30_000)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
# Grounded (the kubernetes FTS hit), not deflected — this is the
# default-answer path, so the echoed question is what we assert on.
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
# A single "|" in prose is not a table (no header + separator pair).
expect(bubble.locator("table")).to_have_count(0)
expect(bubble.locator(".md-table-wrap")).to_have_count(0)
assert "A lone | in prose stays text" in bubble.inner_text()