"""Phase 44 E2E (Playwright): GFM pipe tables in the shared renderer. Story: ``.agent/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 ```` 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 ``
`` with a ```` of three ``
`` (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 ```` line renders as visible, escaped text: zero injected ```` 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 ```` (shared renderer, story AC6). 5. ``test_fence_not_a_table`` — the fixture's pipe-heavy fenced block renders ``
``; the only ``
`` 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 ``
`` (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 = "" 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 (9 docs since phase 44 added tables.md).""" 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).""" page.goto(app_url) 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.""" login(page, app_url) # phase 16: the Sources catalog is admin-only 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 == 9 # phase 44: +tables.md 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: of three
+ 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 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 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 "<img src=x onerror=alert(1)>" 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 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) page.goto(app_url) 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()