**Phase 100 — final verification pass: all green.** - Verified the shipped CSS contract directly: `--chat-column: 72rem` in `:root`; 0 literal `max-width: 46rem`; no `@media (min-width: 1500px)` block; exactly 4 token-capped reading columns; tuning/theme/doc-edit shells cap-free, structurally `.sources-shell`; `mock_llm.py` diff is comment-only. - Defect found & fixed (phase-93 suite): `test_theme_semantic_completion.py::test_reset_removes_tag_byte_identical` raced theme.js's post-PUT refetch — it asserted the `#bor-theme` tag was gone right after the result line, but tag removal lands in the reconcile after the re-fetch (failed ~1 in 5 runs after `test_chat_rag`, reproduced). Fixed with an auto-waiting `expect(...).to_have_count(0)` (settled-state wait, the file's existing house pattern). 6/6 clean on the repro loop after. - Tests/lint/coverage: `uv run pytest --cov=app --cov-report=term-missing` → 2052 passed, **99%** on `app/`; `uv run ruff check .` + `uv run pyright` → 0 errors. - E2E in isolation (all passed): `test_wide_desktop_column.py` 3 (the phase suite — chat==tuning==theme==RAG ±4px at 1280 & 1920, ≈1152px; shared ≈1152px; standalone doc ≈1112px; modal unchanged ≈1100px; 360px overflow-free), `test_ui_customization` 4, `test_admin_theme_tab` 5, `test_document_viewer` 7, `test_save_share_ux` 5, `test_sticky_navbar` 3, `test_markdown_tables` 6, `test_responsive_polish` 7, `test_chat_rag` 3, `test_theme_semantic_completion` 8. - Completion criteria: ① measured 72rem everywhere (≥~1200px, ±4px) + full-width below + 360px clean — **PASS** (E2E); ② zero 46rem rules / no 1500px block / four token selectors — **PASS** (grep + unit pins); ③ B4 byte-identical no-op + mobile squeeze — **PASS** (theme + responsive suites green); ④ full suite / coverage / lint — **PASS**; ⑤ atomic commit — left to the harness per executor protocol (all changes in the working tree, uncommitted). - Deviations: none from phase decisions; only change in this pass is the race fix above (test-only, behavior unchanged). - Next pending phase: `98_sync_summary_visibility` (numeric order in `todo/`; also pending: 99, 101–105).
352 lines
16 KiB
Python
352 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 72rem column (story AC5 — the width
|
|
contract is now the 72rem container, phase 100)."""
|
|
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}"
|