feat(chat): render markdown tables in answers, viewer, and thinking

GFM pipe tables in the shared renderer (TODO.md L6): a table-protection
pass in frontend/assets/markdown.js (fences -> tables -> escape order)
pulls each header+separator+body block out as a placeholder, renders
cells escape-first with the same inline transforms, and reinserts a
semantic <table class="md-table"> inside a horizontal-overflow
.md-table-wrap — so a pipe table in a chat answer, the document
viewer/modal, and the thinking block all render the same semantic
table. Fences win over tables; lone pipes stay text.

- styles.css: .md-table palette rules (PLAN §7.2 tokens, no motion);
  min-width: max-content so a WIDE table keeps its natural width and
  the wrapper is the real scroller (width:100% alone wrapped the wide
  table's cells — proven by the new E2E).
- mock_llm.py: TABLE_TRIGGER ("show me a table") -> byte-stable
  TABLE_ANSWER (3-column table, <img onerror> XSS probe line, wide
  5-column table), checked before DEFLECT_MODE like SUMMARY_MODE.
- tests/fixtures/docs/homelab/tables.md: 3x3 pipe table + pipe-heavy
  fenced block (viewer/fence subject); the shared fixture set grows
  8 -> 9 docs, so every suite pinning the count (added/formats/
  stat-docs/EXPECTED_ROWS) is updated accordingly.
- tests/e2e/test_markdown_tables.py (new, story suite): chat table
  shape + non-deflection, wide-table wrapper scroll (no page
  overflow), XSS probe inert, viewer modal table, fence-not-a-table,
  lone pipe stays text.
- tests/e2e/test_agent_document_tools.py: fix a pre-existing flake —
  the "Calling tool…" label window is ~0.4 s at the mock's 0.1 s
  tool-frame pacing, and a polling expect could stride over it
  (failed 3 of 5 runs on the committed baseline). The pre-submit
  MutationObserver record is the deterministic source of truth; the
  racy to_have_text gate is gone.

uv run pytest: 738 passed, app/ coverage 99% (TOTAL unchanged);
ruff + pyright clean; story E2E 6/6 in isolation; regression E2E
suites (chat_rag, document_viewer, document_summaries, smoke) green.
This commit is contained in:
2026-08-28 03:35:50 -04:00
parent 27b7cb96d5
commit bc70ce36e0
30 changed files with 967 additions and 73 deletions
+350
View File
@@ -0,0 +1,350 @@
"""Unit: the shared renderer's GFM pipe-table pass (phase 44, task 01).
Browser behavior is E2E-covered by ``tests/e2e/test_markdown_tables.py``
(task 03); here we pin the source-level contract — the pass ordering
(fences → tables → escape), the escape-first cell pipeline, the exact
output markers, the placeholder restore, and the ``.md-table`` /
``.md-table-wrap`` CSS rules — so a silent regression is caught without
a browser (the established frontend pattern). A small node-driven
behavioral check (skipped when node is unavailable, like the phase-10
renderer check in ``test_document_viewer.py``) proves the four story
acceptance shapes: table, XSS cell, fence-wins, non-tables stay text.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
MARKDOWN_JS = ASSETS / "markdown.js"
STYLES_CSS = ASSETS / "styles.css"
HAVE_NODE = shutil.which("node") is not None
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
# ---------- markdown.js: the table-protection pass ----------
def test_table_pass_runs_after_fences_and_before_escape() -> None:
"""The renderer's pass order is fences → tables → escape (story AC3:
a |-heavy fenced block is protected before the table pass ever sees
it, so fences win). Pinned by the placeholder-emission order in the
source: the \\u0000CODEn\\u0000 push (step 1) precedes the
\\u0000TABLEn\\u0000 push (step 1b), which precedes step 2's
escape of the whole text."""
js = _text(MARKDOWN_JS)
fence_emit = r"\u0000CODE${codeBlocks.length - 1}\u0000"
table_emit = r"\u0000TABLE${tables.length - 1}\u0000"
assert fence_emit in js, "the fence pass keeps its CODE placeholder"
assert table_emit in js, "the table pass emits a TABLE placeholder"
assert js.find(fence_emit) < js.find(table_emit), (
"the fence pass must run BEFORE the table pass (fences win)"
)
assert "escapeHtml(text)" in js, "step 2 still escapes the whole text"
assert js.find(table_emit) < js.find("escapeHtml(text)"), (
"the table pass must run BEFORE the escape pass (cells are escaped per-cell)"
)
def test_table_detection_requires_pipe_header_then_separator() -> None:
"""Detection (per the phase spec): a table block starts at a line
containing '|' whose NEXT line is a separator — >=1 pipe-separated
cells, each ^\\s*:?-+:?\\s*$ (leading/trailing pipes allowed) — and
extends over every following line that still contains '|'; the
maximal run is one table. Everything else is left untouched."""
js = _text(MARKDOWN_JS)
# the separator cell rule (alignment colons accepted, then ignored)
assert r"^\s*:?-+:?\s*$" in js, "the separator cell rule ^\\s*:?-+:?\\s*$ is missing"
# a table starts at a '|' line whose NEXT line is the separator
assert 'line.includes("|")' in js, "the header line must contain '|'"
assert "isSeparator(tableLines[pos + 1])" in js, (
"the NEXT line after the '|' line must be the separator"
)
# the body extends over every following line that still contains '|'
assert 'tableLines[end].includes("|")' in js, (
"the table body must extend over every following line containing '|'"
)
# zero body rows is valid — body = the lines after header + separator
assert "slice(2)" in js, "body rows are the lines after header + separator"
def test_cells_are_escaped_then_inline_transformed() -> None:
"""Escape-first is the renderer invariant: each cell (header AND
body) goes through escapeHtml() BEFORE the inline transforms, and
the inline chain is the SAME factored helper the whole-text path
uses — so cells keep `code` / **bold** / *em* exactly like prose."""
js = _text(MARKDOWN_JS)
assert js.count("inline(escapeHtml(c))") >= 2, (
"header and body cells must both run inline(escapeHtml(cell))"
)
# the whole-text path runs the same helper over the escaped text
assert "text = inline(escapeHtml(text));" in js, (
"step 2 must reuse the SAME factored inline chain"
)
# the helper carries the exact original .replace chain
helper_start = js.find("const inline = (s) =>")
assert helper_start != -1, "the inline chain must be factored into a local helper"
helper = js[helper_start : js.find(";", js.find("gm", helper_start)) + 1]
for marker in (
r"/`([^`\n]+)`/g",
r"\*\*([^*]+)\*\*",
r"(^|[\s(])\*([^*\n]+)\*",
r"/^### (.*)$/gm",
r"/^## (.*)$/gm",
r"/^# (.*)$/gm",
r"/^\s*[-*] (.*)$/gm",
r"(<li>[\s\S]*?<\/li>)(?!\s*<li>)",
r"/^\d+\. (.*)$/gm",
):
assert marker in helper, f"the factored helper lost the {marker!r} step"
assert js.count("replace(/`([^`\\n]+)`/g") == 1, (
"the code-span replace must live ONLY in the factored helper"
)
def test_table_output_markers_and_wrap() -> None:
"""The exact output shape the story pins: a semantic table inside a
horizontal-overflow wrapper — <div class=\"md-table-wrap\"> /
<table class=\"md-table\"> / <thead> with th scope=\"col\" headers /
<tbody> body rows (a header-only table omits the empty <tbody>)."""
js = _text(MARKDOWN_JS)
assert '<div class="md-table-wrap">' in js
assert '<table class="md-table">' in js
assert "<thead><tr>" in js
assert '<th scope="col">' in js, "headers must carry scope=\"col\""
assert "</thead>" in js and "</table>" in js and "</div>" in js
assert "<tbody>" in js and "</tbody>" in js
assert "<td>" in js and "</tr>" in js
# header-only tables: the tbody is conditional on having body rows
assert re.search(r"body\s*\?", js), "a header-only table must skip the <tbody>"
# the assembly order inside the push template: wrap, table, thead,
# (tbody), close — pinned on the quoted source literals (the header
# comment mentions the shapes out of order on purpose)
wrap_lit = "'<div class=\"md-table-wrap\">\\n'"
table_lit = "'<table class=\"md-table\">\\n'"
assert js.find(wrap_lit) != -1, "the template must open the .md-table-wrap div"
assert js.find(table_lit) != -1, "the template must open the table inside the wrap"
assert js.find(wrap_lit) < js.find(table_lit), "the wrap opens before the table"
assert '</table>\\n</div>' in js, "the template closes the table, then the wrap"
def test_header_and_body_rows_pad_and_truncate_to_header_width() -> None:
"""Defensive ragged rows: short rows are padded with empty <td>s,
long rows are truncated to the header width (mock and real answers
are well-formed; this only guards the parser)."""
js = _text(MARKDOWN_JS)
assert re.search(r"cells\.push\(\"\"\)", js), "short rows must be padded"
assert re.search(r"slice\(0,\s*width\)", js), "long rows must be truncated"
assert "const width = header.length;" in js, "the header row sets the column count"
def test_placeholder_restored_alongside_code_blocks() -> None:
"""Step 4 is the ONLY place placeholders re-expand — the TABLE
placeholders are restored there, next to the CODE ones, so the
final table HTML never re-enters the escape/paragraph passes."""
js = _text(MARKDOWN_JS)
fn = js.find("function renderMarkdown")
assert fn != -1
body = js[fn:]
# step 4 is the ONLY restore point and it restores BOTH placeholder
# kinds, indexing their arrays
assert r"\u0000TABLE(\d+)\u0000/g" in body, (
"step 4 must restore the \\u0000TABLEn\\u0000 placeholders"
)
assert "tables[Number(i)]" in body, "restoration must index the tables array"
assert r"\u0000CODE(\d+)\u0000/g" in body, "the CODE restore stays in step 4"
# the TABLE restore is AFTER the paragraph pass — the restored final
# HTML never re-enters it
assert body.find(r"\u0000TABLE(\d+)\u0000/g") > body.find("split(/\\n{2,}/)"), (
"placeholders are restored AFTER the paragraph pass"
)
def test_alignment_colons_are_parsed_but_ignored() -> None:
"""Owner decision (2026-08-27): the separator's alignment colons
match the cell rule (so ':---:' is a valid separator) but are never
turned into CSS alignment — the CSS pins every cell to left."""
js = _text(MARKDOWN_JS)
assert r"^\s*:?-+:?\s*$" in js, "colons must be accepted in separators"
css = _text(STYLES_CSS)
th_td = re.search(r"\.md-table th, .md-table td \{([^}]*)\}", css)
assert th_td, "styles.css must style .md-table th/td"
assert "text-align: left" in th_td.group(1), "all cells render left-aligned"
def test_header_comment_notes_the_table_pass() -> None:
"""The file's header comment records the extension (2026-08-27,
TODO.md L6) — the ~60-line no-CDN renderer now also does tables."""
header = _text(MARKDOWN_JS).split("*/", 1)[0]
assert "Phase 44" in header
assert "2026-08-27" in header
assert "TODO.md L6" in header
assert re.search(r"table", header, re.I), "the header comment must mention tables"
assert "no CDN" in header, "the no-CDN contract stays stated (A11)"
# ---------- styles.css: the .md-table / .md-table-wrap rules ----------
def test_table_wrap_is_the_horizontal_scroller() -> None:
""".md-table-wrap { overflow-x: auto } — the wrapper (not the
table) is the scroller, so a wide table scrolls inside the bubble
instead of breaking the 46rem column (story AC5)."""
css = _text(STYLES_CSS)
block = re.search(r"\.md-table-wrap\s*\{([^}]*)\}", css)
assert block, "styles.css must define .md-table-wrap"
assert re.search(r"overflow-x:\s*auto", block.group(1)), (
"the wrapper must scroll horizontally"
)
def test_table_rule_and_cell_borders_use_the_palette() -> None:
""".md-table is border-collapse, full-width (min-width: max-content
keeps a wide table at its natural width so the wrap scrolls — phase
44 task 03), 0.9rem; th/td carry the --line 1px borders, 0.4rem
0.6rem padding, left alignment, top vertical alignment; the thead is
tinted from the PLAIN surface family (PLAN §7.2: --ink on --surface
is 14.5:1) — never the brand tokens."""
css = _text(STYLES_CSS)
table = re.search(r"(?<![-\w])\.md-table\s*\{([^}]*)\}", css)
assert table, "styles.css must define .md-table"
body = table.group(1)
assert re.search(r"border-collapse:\s*collapse", body)
assert re.search(r"width:\s*100%", body), "narrow tables stretch to the column"
assert re.search(r"min-width:\s*max-content", body), (
"wide tables must keep their natural width so .md-table-wrap scrolls"
)
assert re.search(r"font-size:\s*0\.9rem", body)
cells = re.search(r"\.md-table th, .md-table td\s*\{([^}]*)\}", css)
assert cells, "styles.css must style .md-table th and td"
cell_body = cells.group(1)
assert re.search(r"border:\s*1px solid var\(--line\)", cell_body), (
"cells must carry the quiet --line hairline borders"
)
assert re.search(r"padding:\s*0\.4rem 0\.6rem", cell_body)
assert re.search(r"text-align:\s*left", cell_body)
assert re.search(r"vertical-align:\s*top", cell_body)
thead = re.search(r"\.md-table thead th\s*\{([^}]*)\}", css)
assert thead, "styles.css must tint the thead cells"
thead_body = thead.group(1)
assert "var(--surface)" in thead_body, "thead tint is the plain surface family"
assert "var(--ink)" in thead_body, "--ink on --surface is 14.5:1 (>= 4.5:1)"
assert "--brand" not in thead_body, "never the brand tokens on the thead"
def test_table_rules_carry_no_motion() -> None:
"""Reduced-motion consistency: the table rules are static content —
no animation/transition for prefers-reduced-motion to still (the
phase-44 comment states this, like the phase-08/22/36 static
components)."""
css = _text(STYLES_CSS)
rules = (
r"\.md-table-wrap",
r"(?<![-\w])\.md-table",
r"\.md-table th, .md-table td",
r"\.md-table thead th",
)
for name in rules:
block = re.search(name + r"\s*\{([^}]*)\}", css)
assert block, f"styles.css must define {name}"
assert "animation" not in block.group(1), f"{name} must not animate"
assert "transition" not in block.group(1), f"{name} must not transition"
assert re.search(r"nothing for\s+prefers-reduced-motion to still", css), (
"the table block comment must note there is nothing to still"
)
def test_table_css_lives_with_the_bubble_markdown_rules() -> None:
"""Placement (task 01 step 2): the table rules sit with the
markdown/content styling — right after the .bubble code rules,
before the .msg.user block — and are unscoped so the shared
renderer's tables in the thinking scratchpad and the document
viewer (.doc-md) get the same treatment."""
css = _text(STYLES_CSS)
assert css.find('.bubble pre code {') < css.find(".md-table-wrap"), (
"the table rules follow the bubble markdown content rules"
)
assert css.find(".md-table thead th") < css.find(".msg.user {"), (
"the table rules stay in the Messages section"
)
# no .bubble-scoped table variant: one rule set serves all consumers
assert re.search(r"\.bubble\s+\.md-table", css) is None, (
"the table rules must be unscoped (shared renderer consumers)"
)
# ---------- behavior (node): the story's acceptance shapes ----------
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_table_renders_semantic_xss_safe_and_fences_win() -> None:
"""The four story shapes executed under node: (1) a pipe table with
leading/trailing pipes + inline markdown in cells renders the
<div class=\"md-table-wrap\"><table class=\"md-table\"> shape with
<th scope=\"col\"> headers and padded ragged rows, and NO raw
separator line survives; (2) a cell with an HTML tag is escaped;
(3) a |-heavy fenced block stays <pre><code>; (4) a lone pipe in
prose, a separator without a header, and a 1-line 'table' stay
text."""
js = _text(MARKDOWN_JS)
cases = {
"table": (
"Here:\n\n| Service | Port |\n|:---|---:|\n| **api** | 8000 |\n| web |",
[
'<div class="md-table-wrap">',
'<table class="md-table">',
"<thead><tr><th scope=\"col\">Service</th><th scope=\"col\">Port</th></tr></thead>",
"<tbody><tr><td><strong>api</strong></td><td>8000</td></tr>"
"<tr><td>web</td><td></td></tr></tbody>",
"</table>\n</div>",
],
["|:---|---:|", "| **api** |"],
),
"xss": (
"| c |\n|---|\n| <img src=x onerror=alert(1)> & co |",
["&lt;img src=x onerror=alert(1)&gt; &amp; co"],
["<img"],
),
"fence": (
"```\n| a | b |\n|---|---|\n| 1 | 2 |\n```",
["<pre><code>| a | b |"],
["<table"],
),
"not_tables": (
"a | b prose\n\n---\n\n| only a line\n\nend |---|",
["<p>a | b prose</p>", "<p>---</p>", "<p>| only a line</p>", "<p>end |---|</p>"],
["<table"],
),
}
driver = (
js
+ "\nconst out = {}; "
+ "".join(
f"out.{name} = renderMarkdown({value!r}); " for name, (value, _, _) in cases.items()
)
+ "console.log(JSON.stringify(out));"
)
results = json.loads(_run_node(driver))
for name, (_, must_contain, must_not) in cases.items():
html = results[name]
for marker in must_contain:
assert marker in html, f"{name}: missing {marker!r} in {html!r}"
for marker in must_not:
assert marker not in html, f"{name}: unexpected {marker!r} in {html!r}"