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.
351 lines
16 KiB
Python
351 lines
16 KiB
Python
"""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 |",
|
|
["<img src=x onerror=alert(1)> & 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}"
|