Files
brain-of-reese/tests/e2e/test_markdown_tables.py
ducoterra a5b63f83ad
Build and Push Containers / build-and-push-app (push) Successful in 2m1s
Build and Push Containers / build-and-push-db (push) Successful in 18s
phase: 119_name_signal_read_chips
All verification complete. Final report:

**Phase 119 final verification pass — all criteria verified, one stale pin fixed.**
- Verified implementation of all 6 tasks: D1 component name-hit rule (`name_hit` flag, titles never matched, retired length tie-break), D2 `BOR_NAME_HIT_BONUS` (0.005 default, 0 = byte-identical kill switch, negative fails startup, selection-layer only, `eval_retrieval` `suggested:` line), D3 suggested-folder lines (after `SUGGEST_INTRO`, before first block), D4 cite-discipline `SUGGEST_INTRO` sentence (PERSONA/LOW/`TOOLS_SECTION` byte-pins intact), D5 `done.sources` = read docs only (frontend no-op on empty confirmed), D6 mock `repeat your folder map` echo + new suite + telemetry.
- Battery (replica restored per skill, fingerprint docs=1000/chunks=8866 verified, `eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` re-run): **GATE PASS** — gitea README #4 in suggested top-5, forgejo 5/5 (README #1), gateway README in top-5 (#4), qwen3.8-27b quadlets top-5, Mongolia HIGH/fts=5 unchanged.
- New E2E in isolation: `4 passed` ×2 (deterministic). All 27 modified E2E suites in isolation: 26 green; **1 stale pin fixed** — `test_source_chip_quality.py` durable-record order pin pre-dated the D1 re-rank (`aliases` stem sub-component name-hits `ssh_aliases.txt`, deterministically lifting `backups.md` over `kubernetes.md`; probe-verified 0.016277 vs 0.016036, 4/4 stable) — re-pinned with the phase-119 rationale; suite green ×2.
- Gates: `uv run pytest --cov=app --cov-report=term-missing` → **2547 passed, app coverage 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Completion criteria: 1 ✅ (battery, recorded), 2 ✅ (folder lines; block/LOW byte-identical pins green), 3 ✅ (read-only chips, zero-read chips nothing, related row + durable record untouched — unit+E2E agree), 4 ✅ (all green), 5 → commit/phase-move left to the harness per pass rules (nothing committed).
- Deviations: battery output + real-model telemetry recorded in `.agents/reports/119_name_signal_read_chips/task06_battery_and_e2e.md` and `TOOL_CALLING_TESTING.md` §11 (task files in `complete/` are immutable to this pass); gateway canonical doc at #4 vs overview's #3 was already documented at task 06 (containment gate met).
- Next pending phase: **none** — `todo/` holds only phase 119.
2026-09-16 15:50:48 -04:00

360 lines
15 KiB
Python
Raw Permalink 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 72rem container column
holds — phase 100).
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 (the table answer is the mock's non-deflected table
# branch) — and ZERO citation chips: the turn read nothing, so
# (phase 119, LOCKED A1) the chip row is empty (the retired
# phase-118 A4 suggested-chip is gone).
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
# ---------------------------------------------------------------------------
# 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 72rem container column must not break the page: no horizontal
# document overflow (PLAN §7.1, as revised by phase 100).
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()