"""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"(
  • [\s\S]*?<\/li>)(?!\s*
  • )", 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 —
    / / with th scope=\"col\" headers / body rows (a header-only table omits the empty ).""" js = _text(MARKDOWN_JS) assert '
    ' in js assert '
    ' in js assert "" in js assert '" in js and "
    ' in js, "headers must carry scope=\"col\"" assert "
    " in js and "
    " in js assert "" in js and "" in js assert "" in js and "" 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 " # 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 = "'
    \\n'" table_lit = "'\\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 '
    \\n
    ' 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 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"(?= 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"(? 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
    shape with
    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
    ; (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 |",
                [
                    '
    ', '', "", "" "", "
    ServicePort
    api8000
    web
    \n
    ", ], ["|:---|---:|", "| **api** |"], ), "xss": ( "| c |\n|---|\n| & co |", ["<img src=x onerror=alert(1)> & co"], ["| a | b |"], ["a | b prose

    ", "

    ---

    ", "

    | only a line

    ", "

    end |---|

    "], ["