"""Regression tests for PR #2428 — table renderer pipe protection.
These pin three classes of input that previously mis-split into too many table
cells because `parseRow` / `parseHeader` ran a naive `split('|')` over the row
text:
1. Single-pipe inside parens/brackets (e.g. ``(a|b)``, ``[int|float]``) — was
the original #2428 bug.
2. Multi-pipe inside one bracket pair (e.g. ``(a|b|c)``, ``Union[int|float|str]``) —
common Python type hint / regex character class shape; first version of the
#2428 fix protected only the first pipe per pair and still mis-split here.
3. Cells containing apostrophes alongside protected pipes (e.g. ``('a'|'b')``) —
the intermediate version of the fix added ``'`` to the negated character
classes which caused a regression on string-literal-in-union input.
Tests drive the real `renderMd()` in `static/ui.js` via the same node-driver
pattern used by `test_renderer_js_behaviour.py` so the regex can't silently
regress in either direction.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
_DRIVER_SRC = r"""
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
global.window = {};
global.document = { createElement: () => ({ innerHTML: '', textContent: '' }) };
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => (
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
const _SVG_EXTS=/\.svg$/i;
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
function extractFunc(name) {
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {
if (src[i] === '{') depth++;
else if (src[i] === '}') depth--;
i++;
}
return src.slice(start, i);
}
eval(extractFunc('_matchBacktickFenceLine'));
eval(extractFunc('_isBacktickFenceClose'));
eval(extractFunc('renderMd'));
let buf = '';
process.stdin.on('data', c => { buf += c; });
process.stdin.on('end', () => { process.stdout.write(renderMd(buf)); });
"""
@pytest.fixture(scope="module")
def driver_path(tmp_path_factory):
p = tmp_path_factory.mktemp("table_pipe_protect_driver") / "driver.js"
p.write_text(_DRIVER_SRC, encoding="utf-8")
return str(p)
def _render(driver_path: str, markdown: str) -> str:
result = subprocess.run(
[NODE, driver_path, str(UI_JS_PATH)],
input=markdown,
capture_output=True,
text=True,
timeout=15,
)
if result.returncode != 0:
raise RuntimeError(f"node driver failed: {result.stderr}")
return result.stdout
def _table_cells(html: str) -> list[str]:
"""Extract td/th text content in document order.
Returns the inner HTML of each
or | . Two-pass: match | cells
and | cells separately so the same regex can use a single fixed tag
name (avoiding the open-th-close-td mismatch that a `...`
pattern would allow).
"""
import re
out = []
for tag in ("th", "td"):
# We need positions to preserve document order across the two tags.
for m in re.finditer(rf"<{tag}\b[^>]*>(.*?){tag}>", html, re.S):
out.append((m.start(), m.group(1).strip()))
out.sort(key=lambda p: p[0])
return [c for _, c in out]
class TestSinglePipeInBracketsStaysInOneCell:
"""Original #2428 bug: cells containing a single pipe inside parens or
brackets used to split into two cells. _protectPipes was added to handle
this. These tests pin the single-pipe case."""
def test_pipe_inside_parens_stays_in_one_cell(self, driver_path):
md = "| `(a|b)` | x |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
# 2 columns × 2 rows = 4 cells total (header row + 1 data row).
assert len(cells) == 4, f"expected 4 cells, got {len(cells)}: {cells!r}"
# The first header cell must contain the full (a|b) backtick span.
assert "(a|b)" in cells[0], (
f"`(a|b)` inside backticks must stay in one cell. Got cells: {cells!r}"
)
def test_pipe_inside_square_brackets_stays_in_one_cell(self, driver_path):
md = "| `Union[int|float]` | bracket |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, f"expected 4 cells, got {len(cells)}: {cells!r}"
assert "Union[int|float]" in cells[0], (
f"`Union[int|float]` must stay in one cell. Got: {cells!r}"
)
class TestMultiPipeInBracketsStaysInOneCell:
"""First version of #2428 fix only protected the first pipe per bracket
pair. The shipped version uses a `do { ... } while (r !== prev)` loop so
iterative passes catch all pipes inside one pair. These tests pin that
iteration."""
def test_three_pipes_inside_parens(self, driver_path):
md = "| `(a|b|c)` | x |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, (
f"three-pipe paren cell must not split. Got {len(cells)} cells: {cells!r}"
)
assert "(a|b|c)" in cells[0], f"got cells: {cells!r}"
def test_three_pipes_inside_square_brackets(self, driver_path):
md = "| `Union[int|float|str]` | bracket |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, (
f"three-pipe square-bracket cell must not split. "
f"Got {len(cells)} cells: {cells!r}"
)
assert "Union[int|float|str]" in cells[0], f"got cells: {cells!r}"
def test_four_pipes_inside_parens(self, driver_path):
md = "| `(a|b|c|d)` | x |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, (
f"four-pipe paren cell must not split. Got {len(cells)} cells: {cells!r}"
)
assert "(a|b|c|d)" in cells[0], f"got cells: {cells!r}"
class TestApostrophesInsideProtectedBrackets:
"""The first iterative version of the #2428 fix added `'` to the negated
character classes (`[^)\\]}'>]`), which broke cells containing string
literals separated by pipes (e.g. `('a'|'b')` in Python type-union
examples). The shipped form drops the `'`-stop so apostrophes can live
inside protected content."""
def test_apostrophes_inside_parens_with_pipe(self, driver_path):
md = "| `('a'|'b')` | x |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, (
f"apostrophe-and-pipe-in-parens must not split. "
f"Got {len(cells)} cells: {cells!r}"
)
# The apostrophes survive inlineMd's esc() pass as `'`, so look
# for either form depending on whether the cell content was escaped.
first = cells[0]
assert "'a'" in first or "'a'" in first, (
f"cell content must contain the escaped or literal `'a'`. Got: {first!r}"
)
def test_apostrophes_inside_square_brackets_with_pipe(self, driver_path):
md = "| `Tuple['a'|'b']` | x |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, (
f"apostrophe-and-pipe-in-brackets must not split. "
f"Got {len(cells)} cells: {cells!r}"
)
def test_apostrophe_outside_bracket_pair(self, driver_path):
"""Smoke test — apostrophes that are not part of a pipe-protect range
must still render normally."""
md = "| `it's a test` | x |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, f"plain apostrophe must not split. Got: {cells!r}"
class TestKatexDollarInTableCell:
"""The second half of #2428 — KaTeX `$...$` math spans containing ` | `
must not stash math when they straddle table column separators."""
def test_katex_with_pipe_separator_inside_does_not_stash_as_math(self, driver_path):
# Two cells, second cell starts with `$x` and first ends with `$`.
# Pre-fix, the inline-math regex would greedily match across `|` and
# turn the row into one mathy cell. Post-fix, the `m.includes(' | ')`
# guard skips the math stash for this shape.
md = "| $5 | $10 |\n|---|---|\n| ok | y |"
out = _render(driver_path, md)
cells = _table_cells(out)
assert len(cells) == 4, (
f"`$5 | $10` must remain two cells, not collapse to one math span. "
f"Got {len(cells)} cells: {cells!r}"
)
class TestComparisonOperatorsAcrossColumns:
"""The first cut of #2428 included `<` and `>` in the protected-bracket
set. That caused tables containing comparison operators across adjacent
columns to mis-collapse: ``| x < 5 | y > 10 |`` matched `< … >` as a
bracket pair and stashed the inner pipe, producing one cell instead of
two. Stage-fix removed `<` / `>` from the bracket set because real LLM
output uses angle brackets as comparison operators far more often than
as a content-grouping pair."""
def test_less_than_greater_than_across_columns_stays_two_cells(self, driver_path):
md = "| x < 5 | y > 10 |\n|---|---|\n| less | more |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, (
f"`| x < 5 | y > 10 |` must produce 4 cells (2 cols × 2 rows). "
f"Got {len(cells)} cells: {cells!r}"
)
def test_less_than_alone_in_cell(self, driver_path):
md = "| a < b | c |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, f"got cells: {cells!r}"
def test_greater_than_alone_in_cell(self, driver_path):
md = "| a > b | c |\n|---|---|\n| ok | y |"
cells = _table_cells(_render(driver_path, md))
assert len(cells) == 4, f"got cells: {cells!r}"
|