Files
brain-of-reese/tests/unit/test_markdown_inline_links_images.py
ducoterra 6880bd0866
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 180_markdown_inline_links_images
All verification complete. Final state summary:

**Report — phase 180 final verification pass**
- Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`).
- Completion criteria:
  - `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E)
  - `![alt](src)`→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅
  - `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅
  - Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe)
  - Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅
  - `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅
- Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate.
- Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181.
- Next pending phase: `181_svg_document_preview`.
2026-10-14 16:03:36 -04:00

1141 lines
54 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit: the shared renderer's inline link/image/heading transforms (phase 180).
Browser behavior is E2E-covered by ``tests/e2e/test_markdown_inline_links_images.py``
(task 05); here we pin the source-level contract of the ONE ``inline()``
chain in ``frontend/assets/markdown.js`` — the chain ORDER (code → strong
→ em → linked-image → image → link → headings → lists), the exact
``<a class="md-link"`` / ``<img class="md-img"`` markers, the TWO URL
allowlist functions (locked A5), the ``target``/``rel`` rule, the
always-emitted ``alt`` + ``loading="lazy"`` and the absence of any inline
handler, and the escape-first boundary (no unescape/decode anywhere) — so
a silent regression is caught without a browser (the established frontend
pattern, cf. phases 44 and 138). A node-driven behavioral check (skipped
when node is unavailable, like the table/blockquote checks) proves the
story acceptance shapes: an absolute link with ``target``/``rel``,
``mailto:``/relative/``#`` links without them, ``javascript:``/``data:``
inert, ``&amp;`` in a query string surviving verbatim, an empty link
text, a link inside a fence staying code, a link inside a table cell
rendering; a served-path / ``data:image/*`` / absolute / ``./``/``../``
image with its alt and lazy hint, an empty alt, an escaped alt, a
non-image ``data:`` and ``javascript:`` src inert, a linked image wrapped
with no stray bracket, an image inside a fence and inside a table cell;
and non-construct input byte-identical to the pre-phase renderer (fixture
string checked into the test, per the phase-44/138 shape). Task 03
(TODO.md L5) adds the ``####``/``#####``/``######`` rung of the heading
ladder: its two rules sit deeper-first immediately before ``/^### /``, the
existing ``#``/``##`` → ``<h3>`` and ``###`` → ``<h4>`` output is
byte-identical, and the step-3 paragraph guard is UNCHANGED (its ``h\\d``
already covers ``h5``/``h6`` — pinned, not assumed).
Task 04's stylesheet contract is pinned here too (the phase's ONE unit
file): the ``.md-link`` / ``.md-img`` rules and the ``h5``/``h6`` type
scale exist, reference :root palette variables ONLY (no hex/rgb/hsl
literal — a new hue would break the monochrome invariant), the link is
underlined (a link never relies on colour alone, WCAG 1.4.1), the user
bubble carries its own ``.md-link`` inversion, every image is capped to
its container (``max-width: 100%`` + ``height: auto``), the deep rungs
extend the existing ``h3``/``h4`` scale MONOTONICALLY, and the ``.md-link`
rule carries its house contrast-ratio note.
The browser-side acceptance evidence is the phase's dedicated Playwright
suite, ``tests/e2e/test_markdown_inline_links_images.py`` (task 05).
"""
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
#: The exact output marker of the link transform (task 01).
LINK_MARKER = '<a class="md-link"'
#: The external-target pair, emitted ONLY for absolute ``http(s)://``
#: hrefs (a mailto/relative/fragment link stays in the same tab).
TARGET_REL = ' target="_blank" rel="noopener noreferrer"'
#: The link rule as written in the chain — an explicit regex plus a
#: FUNCTION replacement (the URL goes through the allowlist, never a
#: string-interpolated replacement).
LINK_RULE = r""".replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, text, url) => {"""
#: The exact output marker of the image transform (task 02).
IMG_MARKER = '<img class="md-img"'
#: Lazy decoding: every rendered image defers to the browser's own
#: loading hint (the phase's acceptance shape).
LAZY_ATTR = ' loading="lazy"'
#: The linked-image rule (LOCKED A6): THREE captures (alt, src, url) and
#: a FUNCTION replacement, so BOTH URLs go through their own allowlist.
LINKED_IMAGE_RULE = (
r""".replace(/\[!\[([^\]]*)\]\(([^)\s]+)\)\]\(([^)\s]+)\)/g, (_m, alt, src, url) => {"""
)
#: The plain image rule — same shape, two captures.
IMAGE_RULE = r""".replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, src) => {"""
#: The attributes every ``<img>`` template in the file carries: ``alt`` is
#: ALWAYS emitted (empty allowed — an ``<img>`` without alt fails WCAG
#: 1.1) next to ``loading="lazy"``, right after the allowlisted ``src``.
IMG_ALT_LAZY = 'alt="${alt}" loading="lazy">'
#: An input containing NONE of the phase-180 constructs (no ``[t](u)``,
#: no ``![a](s)``, no ``####``+) and its byte-EXACT pre-phase renderer
#: output — the byte-identity contract (phase 180 design D7): anything
#: without a link/image/deep-heading renders exactly as before. Covers
#: strong/em/code, the ``#``–``###`` ladder, both list kinds, a
#: blockquote, a fence and a pipe table.
NO_CONSTRUCT_INPUT = (
"A plain paragraph with **bold**, *em* and `code` plus 1 < 2 & 3 > 2.\n"
"\n"
"# Top heading\n"
"\n"
"## Second heading\n"
"\n"
"### Third heading\n"
"\n"
"- bullet one\n"
"- bullet two\n"
"\n"
"1. first\n"
"2. second\n"
"\n"
"> a quoted line with `code`\n"
"\n"
"```\n"
"fenced: a * b, x_y_z\n"
"```\n"
"\n"
"| Col A | Col B |\n"
"|---|---|\n"
"| 1 | 2 |\n"
)
NO_CONSTRUCT_PRE_PHASE_OUTPUT = (
"<p>A plain paragraph with <strong>bold</strong>, <em>em</em> and "
"<code>code</code> plus 1 &lt; 2 &amp; 3 &gt; 2.</p>"
"<h3>Top heading</h3><h3>Second heading</h3><h4>Third heading</h4>\n"
"<ul><li>bullet one</li>\n<li>bullet two</li></ul><li>first</li>\n<li>second</li>"
'<blockquote class="md-quote">a quoted line with <code>code</code></blockquote>'
"<p><pre><code>fenced: a * b, x_y_z</code></pre></p>"
'<p><div class="md-table-wrap">\n'
'<table class="md-table">\n'
'<thead><tr><th scope="col">Col A</th><th scope="col">Col B</th></tr></thead>\n'
"<tbody><tr><td>1</td><td>2</td></tr></tbody>\n"
"</table>\n</div></p>"
)
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _rules_for(css: str, selector: str) -> list[str]:
"""The body of every rule whose selector list contains *selector*
verbatim (comment-stripped, whitespace-normalized) — the shape the
phase-138 ``.md-quote`` pin uses, generalized to the grouped
selectors task 04 writes for ``h5``/``h6``."""
stripped = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
bodies: list[str] = []
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", stripped):
selectors = [s.strip() for s in " ".join(match.group(1).split()).split(",")]
if selector in selectors:
bodies.append(match.group(2))
return bodies
def _root_palette(css: str) -> set[str]:
"""Every variable name the ``:root`` block declares (the single
source of truth the palette-only pins check against)."""
root = re.search(r":root\s*\{", css)
assert root, "styles.css must define :root"
body = css[root.end() : css.find("}", root.end())]
palette = set(re.findall(r"(--[a-z0-9-]+)\s*:", body))
assert palette, "the :root palette must declare variables"
return palette
def _assert_palette_only(body: str, label: str) -> None:
"""No colour literal anywhere in the rule, and every ``var()`` reference
is a declared :root palette var (the monochrome invariant + the
:root-drift guard, cf. tests/unit/test_markdown_blockquotes.py)."""
refs = re.findall(r"var\((--[a-z0-9-]+)\)", body)
assert refs, f"{label} must reference palette vars"
for ref in refs:
assert ref in _ROOT, f"{label}: {ref} is not a :root palette var"
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", body), f"no hex color literal in {label}"
assert "rgb(" not in body and "rgba(" not in body and "hsl(" not in body, (
f"no rgb()/hsl() color literal in {label}"
)
_ROOT: set[str] = _root_palette(_text(STYLES_CSS))
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
def _render(cases: dict[str, str]) -> dict[str, str]:
"""Render every case through the REAL renderer under node and return
the ``{name: html}`` map (the phase-138 harness shape)."""
js = _text(MARKDOWN_JS)
driver = (
js
+ "\nconst out = {}; "
+ "".join(f"out.{name} = renderMarkdown({value!r}); " for name, value in cases.items())
+ "console.log(JSON.stringify(out));"
)
results: dict[str, str] = json.loads(_run_node(driver))
return results
# ---------- markdown.js: the link rule in the inline chain ----------
def test_inline_chain_order_is_emphasis_images_link_headings_lists() -> None:
"""The inline chain order is code → strong → em → **linked-image →
image → link** → headings → lists (phase 180 D1/D3/D4):
* the link rule runs AFTER the emphasis rules (so link text carrying
``**strong**``/``*em*``/``code`` is already transformed when it gets
wrapped) and BEFORE the ``^### `` heading rule;
* BOTH image rules sit AHEAD of the link rule — the linked-image rule
first of all, so ``[![alt](src)](url)`` cannot leave a stray ``[``
inside the link text (LOCKED A6), then the plain image rule, so the
``!`` of an image is never orphaned into ``!<a>``.
Everything else in the chain stays where the pre-phase renderer had
it; all of it lives inside the shared ``inline()`` helper, so a table
cell gets the identical pipeline (proved by the cell cases below)."""
js = _text(MARKDOWN_JS)
anchors = [
('s.replace(/`([^`\\n]+)`/g, "<code>$1</code>")', "code span"),
('.replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>")', "strong"),
('.replace(/(^|[\\s(])\\*([^*\\n]+)\\*/g, "$1<em>$2</em>")', "em"),
(LINKED_IMAGE_RULE, "linked image"),
(IMAGE_RULE, "image"),
(LINK_RULE, "link"),
(H3_RULE, "### heading"),
('.replace(/^\\s*[-*] (.*)$/gm, "<li>$1</li>")', "bullet list"),
]
positions: list[int] = []
for anchor, label in anchors:
pos = js.find(anchor)
assert pos != -1, f"missing the {label} rule in the inline chain: {anchor!r}"
assert js.count(anchor) == 1, f"the {label} rule must appear exactly once"
positions.append(pos)
assert positions == sorted(positions), (
"the inline chain order must be code → strong → em → linked-image → "
f"image → link → headings → lists, got rules at {positions!r} for "
f"{[label for _, label in anchors]!r}"
)
# every phase-180 rule is INSIDE the shared `inline` helper (so a
# table cell gets it too — the cell cases below prove the behavior)
inline_start = js.find("const inline = (s) =>")
assert inline_start != -1, "the shared inline() helper is missing"
for anchor, label in anchors:
assert inline_start < js.find(anchor) < js.find("// 1b."), (
f"the {label} rule must live in the shared inline() chain, "
"not in a one-off pass"
)
def test_link_marker_and_external_target_rel_pair() -> None:
"""The exact output marker is ``<a class="md-link"`` and the
``target="_blank" rel="noopener noreferrer"`` pair is emitted ONLY
for absolute ``http(s)://`` hrefs — the pair appears once in the
source, inside the conditional that tests ``^https?://`` (a
``mailto:``/relative/fragment link carries neither). Task 02 gave the
linked-image rule the SAME anchor markup, so both callers share the
ONE ``mdAnchorOpen`` helper instead of duplicating the pair (which is
what keeps this single-source pin true)."""
js = _text(MARKDOWN_JS)
assert LINK_MARKER in js, "the exact <a class=\"md-link\" marker must be emitted"
assert js.count(TARGET_REL) == 1, (
"the target/rel pair must appear exactly once (the external branch)"
)
assert 'const external = /^https?:\\/\\//i.test(safe);' in js, (
"the external test must be the ^https?:// shape (case-insensitive)"
)
# the one anchor builder: defined once, used by the link rule and by
# the linked-image rule (locked A6)
assert js.count("function mdAnchorOpen(safe) {") == 1
assert js.count("mdAnchorOpen(safeHref)") == 1, "the linked-image rule wraps its <img>"
assert js.count("${mdAnchorOpen(safe)}") == 1, "the link rule uses the same builder"
def test_link_url_allowlist_function_is_the_explicit_gate() -> None:
"""LOCKED A5: the URL is validated by an allowlist FUNCTION (not a
regex-in-replacement). ``allowLinkUrl`` accepts ``http(s)://``,
``mailto:``, ``/``-rooted, ``./``/``../`` relative and ``#fragment``
URLs and returns them VERBATIM; everything else (``javascript:``,
``data:``, ``vbscript:``, ``file:``, ``blob:``) returns ``null``, and
the caller then returns the ORIGINAL match — inert escaped text,
never a live element and never a dropped string."""
js = _text(MARKDOWN_JS)
assert "function allowLinkUrl(url) {" in js, "the allowlist is a named function"
allowlist = re.search(r"function allowLinkUrl\(url\) \{(?P<body>.*?)\n\}", js, re.S)
assert allowlist, "allowLinkUrl must be a top-level function"
body = allowlist.group("body")
for shape, label in [
(r"https?:\/\/", "http(s)://"),
(r"mailto:", "mailto:"),
(r"\/", "/-rooted"),
(r"\.{1,2}\/", "./ and ../ relative"),
(r"#", "#fragment"),
]:
assert shape in body, f"the allowlist must accept {label} URLs ({shape})"
assert "null" in body, "a rejected URL must return null"
assert "return _m;" in js, (
"a rejected URL must return the ORIGINAL matched text (inert, not dropped)"
)
# the allowlist is a function call inside the replacement, and the
# rejected branch is checked BEFORE any anchor is built. The anchor
# markup itself is single-sourced in mdAnchorOpen (task 02's
# linked-image rule is its second caller), so "precedes the anchor"
# is asserted per rule: the allowlist call comes first, the builder
# call only ever runs on an accepted URL.
assert "const safe = allowLinkUrl(url);" in js, "the replacement calls the allowlist"
anchor_code = js.find('<a class="md-link" href="${safe}"')
assert anchor_code != -1, "the anchor template interpolates the allowlisted URL"
helper = js.find("function mdAnchorOpen(safe) {")
assert helper != -1 and helper < anchor_code, (
"the anchor markup is built by the shared mdAnchorOpen helper"
)
assert js.find("function allowLinkUrl(url) {") < helper, (
"the helper takes an ALREADY-allowlisted URL, it does not decide"
)
for rule, end, allow_call in (
(LINKED_IMAGE_RULE, IMAGE_RULE, "const safeHref = allowLinkUrl(url);"),
(LINK_RULE, H3_RULE, "const safe = allowLinkUrl(url);"),
):
start = js.find(rule)
assert start != -1, f"missing rule: {rule!r}"
body = js[start : js.find(end, start)]
assert allow_call in body, f"the rule must call the link allowlist: {allow_call}"
assert "mdAnchorOpen(" in body, "the rule builds its anchor through the helper"
assert body.find(allow_call) < body.find("mdAnchorOpen("), (
"the allowlist check precedes the anchor output in every rule"
)
assert "return _m;" in body, "a rejected href keeps the original text"
def test_renderer_never_unescapes_or_decodes_urls() -> None:
"""Design D2 — escape-first IS the security boundary: ``inline()``
always receives HTML-escaped text, so a captured URL may contain
``&amp;``/``&#39;``/``&quot;`` (valid in an href) and is interpolated
VERBATIM. Any decoding/re-escaping would re-open the injection
surface the renderer exists to close, so the file must contain no
decoder and no HTML sink."""
js = _text(MARKDOWN_JS)
for forbidden in (
"decodeURIComponent",
"decodeURI(",
"unescape(",
"DOMParser",
"innerHTML",
"insertAdjacentHTML",
"eval(",
):
assert forbidden not in js, f"markdown.js must never use {forbidden} (design D2)"
# ---------- markdown.js: the image + linked-image rules (task 02) ----------
def test_image_rules_emit_marker_alt_and_lazy() -> None:
"""Task 02's output contract at source level: BOTH image templates
are ``<img class="md-img" src="…" alt="…" loading="lazy">`` — the
marker appears exactly twice (the plain rule + the linked-image rule)
and EACH of them carries ``alt`` (always emitted, empty allowed — an
``<img>`` without an alt attribute fails WCAG 1.1) and
``loading="lazy"``. There is deliberately no inline ``onerror``
handler anywhere in the renderer: it returns an HTML string into
innerHTML-style surfaces and the CSP/no-CDN posture forbids inline
handlers, so a broken src degrades to the browser's alt text."""
js = _text(MARKDOWN_JS)
# the two CODE templates (the marker string also appears in the prose
# that explains it, so the interpolation shape is what is counted)
assert js.count('<img class="md-img" src="${safe}"') == 1, (
"the plain image rule emits exactly one <img class=\"md-img\" template"
)
assert js.count('<img class="md-img" src="${safeSrc}"') == 1, (
"the linked-image rule emits exactly one <img class=\"md-img\" template"
)
assert js.count(IMG_ALT_LAZY) == 2, (
"every image template ends in alt=\"${alt}\" loading=\"lazy\"> — "
"alt is always emitted, next to the lazy hint"
)
assert js.count(LAZY_ATTR) >= 2
assert js.count(IMG_MARKER) >= 2
# (the word appears in the prose that explains WHY there is none — an
# inline handler would be an attribute, so that is what is forbidden)
for forbidden in (" onerror=", " onload=", " onclick=", " onerror ="):
assert forbidden not in js, f"markdown.js must never emit {forbidden.strip()!r}"
def test_linked_image_rule_checks_both_allowlists_before_emitting() -> None:
"""LOCKED A5 + A6 at source level: the linked-image rule captures
THREE groups (alt, src, url) and validates BOTH URLs — the src
through ``allowImageUrl``, the href through ``allowLinkUrl`` — with
either rejection returning the ORIGINAL match. So an image wrapped in
a link is only ever live when both halves pass."""
js = _text(MARKDOWN_JS)
start = js.find(LINKED_IMAGE_RULE)
assert start != -1, "the linked-image rule is missing"
body = js[start : js.find(IMAGE_RULE)]
assert "allowImageUrl(src);" in body, "the src goes through the image allowlist"
assert "allowLinkUrl(url);" in body, "the href goes through the link allowlist"
assert body.count("return _m;") == 2, "either rejection keeps the original text"
assert IMG_MARKER in body, "the accepted case wraps the image inside the anchor"
assert "</a>" in body, "the anchor is closed after the image"
# the plain image rule validates its src the same way
plain = js[js.find(IMAGE_RULE) : js.find(LINK_RULE)]
assert "allowImageUrl(src);" in plain and "return _m;" in plain, (
"the plain image rule is allowlist-gated too (locked A5)"
)
def test_image_url_allowlist_function_is_the_explicit_gate() -> None:
"""LOCKED A5 for images: a named ``allowImageUrl`` function is the
gate (not a regex-in-replacement). It accepts ``/``-rooted served
paths, ``./``/``../`` relative paths, ``http(s)://`` and
``data:image/…`` (the app's own CSP already allows ``data:`` images —
``img-src 'self' data:``, so no CSP change is involved) and returns
them VERBATIM; everything else returns ``null``."""
js = _text(MARKDOWN_JS)
assert "function allowImageUrl(url) {" in js, "the image allowlist is a named function"
allowlist = re.search(r"function allowImageUrl\(url\) \{(?P<body>.*?)\n\}", js, re.S)
assert allowlist, "allowImageUrl must be a top-level function"
body = allowlist.group("body")
for shape, label in [
(r"https?:\/\/", "http(s)://"),
(r"data:image\/", "data:image/*"),
(r"|\/|", "/-rooted"),
(r".{0,2}\/", "./ and ../ relative"),
]:
assert shape in body, f"the image allowlist must accept {label} URLs ({shape})"
assert r"/^(?:https?:\/\/|data:image\/|\/|.{0,2}\/)/i" in body, (
"the image allowlist is ONE anchored alternation, case-insensitive"
)
assert "null" in body, "a rejected src must return null"
# the image allowlist never accepts a link-only scheme (a fragment is
# not an image) and the link allowlist never accepts data: URLs
assert "mailto" not in body, "mailto: is not an image src"
link_allowlist = re.search(r"function allowLinkUrl\(url\) \{(?P<b>.*?)\n\}", js, re.S)
assert link_allowlist, "allowLinkUrl must be a top-level function"
assert "data:" not in link_allowlist.group("b"), "links stay closed to data: URLs (A5)"
def test_module_docstring_records_the_image_contract() -> None:
"""Task 02 work item 4: the module docstring carries the final chain
order, the image allowlist, and the documented limits — including the
size-less-SVG one that belongs to phase 181 (so the next reader does
not re-decide it)."""
js = _text(MARKDOWN_JS)
docstring = js[: js.find("*/")]
for needle, label in [
("linked-image → image → link", "the final chain order"),
("allowImageUrl", "the image allowlist by name"),
('alt=""', "the empty-alt (decorative image) case"),
("img-src 'self' data:", "the CSP reason data: images are allowed"),
("viewBox", "the size-less-SVG limit"),
("phase 181", "where the vector sizing contract lives instead"),
("NO `onerror` handler", "the deliberate no-inline-handler choice"),
]:
assert needle in docstring, f"the module docstring must record {label}: {needle!r}"
# ---------- markdown.js: the heading ladder (task 03) ----------
#: The three pre-existing heading rules, whose output must stay exactly as
#: it was (LOCKED A4 — the phase ADDS levels below ``###``, it does not
#: re-rank the existing ones).
H3_RULE = '.replace(/^### (.*)$/gm, "<h4>$1</h4>")'
H2_RULE = '.replace(/^## (.*)$/gm, "<h3>$1</h3>")'
H1_RULE = '.replace(/^# (.*)$/gm, "<h3>$1</h3>")'
#: The two rules task 03 adds (locked A4): ``####`` → ``<h5>`` and
#: ``#####``/``######`` → ``<h6>`` (HTML has no ``<h7>``, and the app's
#: type scale stops two levels below ``###``).
H4_RULE = '.replace(/^#### (.*)$/gm, "<h5>$1</h5>")'
H56_RULE = '.replace(/^#{5,6} (.*)$/gm, "<h6>$1</h6>")'
#: The step-3 paragraph guard. ``h\\d`` already covers ``h5``/``h6``, so
#: task 03 deliberately changed NOTHING here — pinned, not assumed.
PARAGRAPH_GUARD = "/^<(h\\d|ul|ol|pre|li|blockquote)/"
def test_heading_ladder_rules_sit_deeper_first_before_h3() -> None:
"""Task 03 work items 1–2 (LOCKED A4) at source level: the two new
rules are written DEEPER-FIRST — ``/^#### /`` then ``/^#{5,6} /`` —
immediately BEFORE the existing ``/^### /`` rule, and the existing
``#``/``##``/``###`` rules are untouched (``#``/``##`` still map to
``<h3>``, ``###`` still to ``<h4>``).
Every rule requires a SPACE after its hash run, which is what makes
cross-matching impossible (``#### x`` never matches ``/^### /``,
``##### x`` never matches ``/^#### /``) so a deeper heading can never
be partially eaten by a shallower rule. Every rung lives in the shared
``inline()`` chain, so a table cell gets the ladder too."""
js = _text(MARKDOWN_JS)
ladder = [
(H4_RULE, "#### → <h5>"),
(H56_RULE, "#####/###### → <h6>"),
(H3_RULE, "### → <h4>"),
(H2_RULE, "## → <h3>"),
(H1_RULE, "# → <h3>"),
]
for rule, label in ladder:
assert js.count(rule) == 1, f"the {label} rule must appear exactly once: {rule!r}"
# every rule captures AFTER a literal space, which is what makes
# cross-matching impossible (`#### x` never matches `/^### /`)
assert " (.*)$/gm" in rule, f"{rule!r} must require a space after its hashes"
# the ladder reads top-down by depth: the new rungs come first
for earlier, later, label in [
(H4_RULE, H56_RULE, "#### before #####/######"),
(H56_RULE, H3_RULE, "#####/###### before ###"),
(H3_RULE, H2_RULE, "### before ##"),
(H2_RULE, H1_RULE, "## before #"),
]:
assert js.find(earlier) < js.find(later), f"ladder order broken: {label}"
assert js.find(H1_RULE) < js.find('.replace(/^\\s*[-*] '), (
"the whole ladder must stay above the list rules"
)
inline_start = js.find("const inline = (s) =>")
for rule, label in [(H4_RULE, "####"), (H56_RULE, "#####/######")]:
assert inline_start < js.find(rule) < js.find("// 1b."), (
f"the {label} rule must live in the shared inline() chain"
)
def test_step3_paragraph_guard_already_covers_h5_h6() -> None:
"""Task 03 work item 3: the step-3 block guard is ``h\\d``-based, so it
ALREADY lets an ``<h5>``/``<h6>`` block through unwrapped and NO change
was needed there — this pins that the guard is still the untouched one
(narrowing it to ``h[1-4]`` would silently re-wrap every deep heading
in a ``<p>``)."""
js = _text(MARKDOWN_JS)
assert PARAGRAPH_GUARD in js, (
f"the step-3 guard must stay {PARAGRAPH_GUARD!r} (h\\d covers h5/h6)"
)
assert js.count(PARAGRAPH_GUARD) == 1, "there is exactly one paragraph guard"
assert "h[1-4]" not in js, "the guard must not be narrowed below h\\d"
def test_module_docstring_records_the_heading_ladder() -> None:
"""Task 03 work item 4: the module docstring documents the deliberate
flattening (``#``/``##`` both → ``<h3>``, ``#####``/``######`` both →
``<h6>``) and the space-after-hashes / column-0 contract, so the next
reader does not re-decide it."""
js = _text(MARKDOWN_JS)
docstring = js[: js.find("*/")]
# strip the comment-continuation ` * ` markers, then re-flow: the pin is
# about the CONTENT of the docstring, not its line-wrapping
flat = " ".join(
" ".join(line.strip().removeprefix("*").strip() for line in docstring.splitlines()).split()
)
for needle, label in [
("`#`/`##` → `<h3>`", "the deliberate #/## flattening"),
("`###` → `<h4>`", "the ### rung"),
("`####` → `<h5>`", "the #### rung"),
("`#####` and `######` → `<h6>`", "the #####/###### rung"),
("locked A4", "the locked decision behind the ladder"),
("column 0", "the column-0 (m-flag) contract"),
("DELIBERATE", "that the flattening is deliberate"),
]:
assert needle in flat, f"the module docstring must record {label}: {needle!r}"
# ---------- styles.css: the task 04 stylesheet contract ----------
def test_md_link_rules_exist_and_reference_palette_vars_only() -> None:
"""Task 04's link styling: ``.md-link`` paints the theme's AA-safe
brand text colour (``--brand-ink``, 9.9:1 on ``--surface``) and is
underlined UNCONDITIONALLY — a link in running text must not rely on
colour alone (WCAG 1.4.1) — with ``--brand-hover`` on ``:hover``;
``.msg.user .bubble .md-link`` is the ``.md-quote`` inversion (the
bubble's own ``--bg`` ink on the brand fill, 5.2:1) and keeps its own
``:hover``. Every one of those rules references :root palette vars
ONLY (no hex/rgb/hsl literal: the :root-drift unit test parses the
built-ins out of this file, so a literal would be a second source of
truth and a new hue would break the monochrome invariant), and the
rule block carries the house contrast-ratio note."""
css = _text(STYLES_CSS)
for selector in (
".md-link",
".md-link:hover",
".msg.user .bubble .md-link",
".msg.user .bubble .md-link:hover",
):
bodies = _rules_for(css, selector)
assert bodies, f"styles.css must define {selector}"
for body in bodies:
_assert_palette_only(body, selector)
link_body = _rules_for(css, ".md-link")[0]
assert "color: var(--brand-ink)" in link_body, (
".md-link must use the :root AA-safe brand text token"
)
assert "text-decoration: underline" in link_body, (
"a link must never rely on colour alone (WCAG 1.4.1)"
)
assert "color: var(--brand-hover)" in _rules_for(css, ".md-link:hover")[0], (
".md-link:hover must brighten on the shared --brand-hover token"
)
# the user-bubble inversion (the .md-quote precedent at L588)
inversion = _rules_for(css, ".msg.user .bubble .md-link")[0]
assert "color: var(--bg)" in inversion, (
"the link inside the brand-filled user bubble keeps the bubble's ink"
)
# the house convention: the comment above the rule carries the ratio
rule_pos = css.find(".md-link {")
assert rule_pos != -1
comment_block = css[css.rfind("/*", 0, rule_pos) : rule_pos]
ratios = [float(r) for r in re.findall(r"(\d+(?:\.\d+)?)\s*:\s*1", comment_block)]
assert ratios and max(ratios) >= 4.5, (
f"the .md-link rule must carry a >=4.5:1 contrast note, got {ratios!r}"
)
def test_md_img_rule_caps_the_image_and_uses_the_card_language() -> None:
"""Task 04's image styling: ``.md-img`` is capped to its container
(``max-width: 100%`` + ``height: auto`` + ``object-fit: contain``, no
fixed width/height) so a wide diagram can never push a horizontal
scrollbar out of an answer at 375px or 1440px, and it carries the
app's existing bordered-image card language (the ``.source-image-img`
treatment: ``--surface`` plate, ``--line`` hairline, ``--radius-sm``).
Palette vars only — no literal."""
bodies = _rules_for(_text(STYLES_CSS), ".md-img")
assert bodies, "styles.css must define .md-img"
body = bodies[0]
_assert_palette_only(body, ".md-img")
for declaration in ("max-width: 100%", "height: auto", "object-fit: contain"):
assert declaration in body, f".md-img must declare {declaration}"
for token in ("var(--surface)", "var(--line)", "var(--radius-sm)"):
assert token in body, f".md-img must carry the card language ({token})"
assert not re.search(r"^\s*(?:width|height):\s*\d", body, re.M), (
".md-img must not pin a fixed width/height (any aspect ratio fits)"
)
def test_h5_h6_type_scale_extends_the_ladder_monotonically() -> None:
"""Task 04's heading ladder: the renderer emits ``<h5>``/``<h6>`` and
neither tag had a rule before the phase, so ONE grouped rule per level
covers every renderer context (the viewer's ``.doc-md``, the chat and
shared-page ``.msg .bubble``, the ``.thinking-text`` scratchpad — no
chat-specific duplication) and sizes them into the existing
``.doc-md`` scale MONOTONICALLY (h3 1.15rem → h4 1rem → h5 0.95rem →
h6 0.88rem), in rem (never px) so the ladder survives zoom, and with
NO colour declared (each context keeps its own AA ink, so the two new
rungs add no new contrast pair)."""
css = _text(STYLES_CSS)
for level, context in (("h5", ".doc-md h5"), ("h6", ".doc-md h6")):
bodies = _rules_for(css, context)
assert bodies, f"styles.css must size the {level} rung ({context})"
body = bodies[0]
size = re.search(r"font-size:\s*([\d.]+)rem", body)
assert size, f"{context} must set font-size in rem"
assert "px" not in body.split("font-size")[1].split(";")[0], (
f"{context} font-size must be rem, not px"
)
assert "color" not in body, f"{context} must not declare a colour"
# ONE rule set for every renderer context (no chat duplication)
assert _rules_for(css, f".msg .bubble {level}"), f"missing .msg .bubble {level}"
assert _rules_for(css, f".thinking-text {level}"), f"missing .thinking-text {level}"
def rem(selector: str) -> float:
found = re.search(
r"font-size:\s*([\d.]+)rem", _rules_for(css, selector)[0]
)
assert found, f"{selector} must set a rem font-size"
return float(found.group(1))
ladder = [rem(s) for s in (".doc-md h3", ".doc-md h4", ".doc-md h5", ".doc-md h6")]
assert ladder == sorted(ladder, reverse=True) and len(set(ladder)) == 4, (
f"the type scale must be strictly monotonic, got {ladder!r}"
)
# ---------- behavior (node): the story's acceptance shapes ----------
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_link_transform_shapes_under_node() -> None:
"""The story shapes executed under node (TODO.md L4): (1) an absolute
link renders with ``target="_blank" rel="noopener noreferrer"`` and a
``&`` in the query string keeping its escaped ``&amp;`` form verbatim;
(2) ``http://`` too; (3) ``mailto:``, ``/``-rooted, ``./``/``../``
and ``#fragment`` links render WITHOUT target/rel (same tab);
(4) link text carrying already-transformed markup keeps it inside the
anchor; (5) HTML in the link text/URL stays escaped — the anchor is
the only live element; (6) an empty link text is allowed; (7) a link
inside a fenced block stays fence content (step 1 protects first);
(8) a link inside a table cell renders (the cell goes through the
same ``inline()``)."""
empty = "[](https://x.test)"
cases = {
"abs": "see [the docs](https://example.com/a?x=1&y=2)",
"http": "[a](http://x.test)",
"mailto": "[m](mailto:reese@example.com)",
"root": "[r](/docs/thing.md)",
"dotrel": "[d](./x.md)\n\n[u](../y.md)",
"frag": "[f](#section-2)",
"rich": "[**bold** *i* `c`](https://x.test)",
"esc": "[<script>alert(1)</script>](https://x.test?a=1&b=2)",
"empty": empty,
"fence": "```\n[a](https://x.test)\n```",
"cell": "| h |\n|---|\n| [cell](https://x.test) |",
}
exact = {
"abs": (
'<p>see <a class="md-link" href="https://example.com/a?x=1&amp;y=2"'
' target="_blank" rel="noopener noreferrer">the docs</a></p>'
),
"http": (
'<p><a class="md-link" href="http://x.test"'
' target="_blank" rel="noopener noreferrer">a</a></p>'
),
"mailto": '<p><a class="md-link" href="mailto:reese@example.com">m</a></p>',
"root": '<p><a class="md-link" href="/docs/thing.md">r</a></p>',
"dotrel": (
'<p><a class="md-link" href="./x.md">d</a></p>'
'<p><a class="md-link" href="../y.md">u</a></p>'
),
"frag": '<p><a class="md-link" href="#section-2">f</a></p>',
"rich": (
'<p><a class="md-link" href="https://x.test"'
' target="_blank" rel="noopener noreferrer">'
"<strong>bold</strong> <em>i</em> <code>c</code></a></p>"
),
"esc": (
'<p><a class="md-link" href="https://x.test?a=1&amp;b=2"'
' target="_blank" rel="noopener noreferrer">'
"&lt;script&gt;alert(1)&lt;/script&gt;</a></p>"
),
"empty": (
'<p><a class="md-link" href="https://x.test"'
' target="_blank" rel="noopener noreferrer"></a></p>'
),
"fence": "<p><pre><code>[a](https://x.test)</code></pre></p>",
}
results = _render(cases)
for name, html in results.items():
if name in exact:
assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}"
assert (
'<td><a class="md-link" href="https://x.test"'
' target="_blank" rel="noopener noreferrer">cell</a></td>'
) in results["cell"], "a link inside a table cell renders through inline()"
assert "<blockquote" not in results["cell"], "the link must not disturb the table pass"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_disallowed_urls_stay_inert_text() -> None:
"""LOCKED A5 under node: a ``javascript:``, ``data:``, ``vbscript:``,
``file:`` or ``blob:`` URL (any case) never becomes a live element —
the whole ``[text](url)`` construct renders verbatim as the inert
escaped text it was before, with no ``<a`` and no ``href`` anywhere
in the output."""
bad = {
"js": "[click](javascript:alert(1))",
"js_upper": "[click](JaVaScRiPt:alert(1))",
"data": "[d](data:text/html;base64,PHN2Zz4=)",
"vbscript": "[v](vbscript:msgbox(1))",
"file": "[f](file:///etc/passwd)",
"blob": "[b](blob:https://x.test/uuid)",
}
results = _render(bad)
for name, src in bad.items():
html = results[name]
assert html == f"<p>{src}</p>", f"{name}: {html!r} must stay verbatim text"
assert "<a" not in html and "href" not in html, f"{name}: {html!r} is a live anchor"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_allowlink_url_function_accepts_and_rejects() -> None:
"""The allowlist function itself under node: accepted URLs come back
VERBATIM (escape-first — a ``&amp;`` in the query string is not
decoded), everything else comes back ``null``."""
cases: list[tuple[str, str | None]] = [
# accepted — returned VERBATIM (escape-first: the &amp; stays)
("https://x.test/a?x=1&amp;y=2", "https://x.test/a?x=1&amp;y=2"),
("http://x.test", "http://x.test"),
("HTTPS://X.TEST", "HTTPS://X.TEST"),
("mailto:a@x.test", "mailto:a@x.test"),
("/docs/x.md", "/docs/x.md"),
("./x.md", "./x.md"),
("../x.md", "../x.md"),
("#frag", "#frag"),
# rejected -> null (LOCKED A5): the construct stays inert text
("javascript:alert(1)", None),
("JaVaScRiPt:alert(1)", None),
("data:text/html,x", None),
("vbscript:x", None),
("file:///etc/passwd", None),
("blob:https://x.test/u", None),
("x.test/no-scheme", None),
("", None),
("..%2fetc%2fpasswd", None),
("javascript&#58;alert(1)", None),
]
driver = (
_text(MARKDOWN_JS)
+ "\nconst in_ = "
+ json.dumps([c[0] for c in cases])
+ ";\nconsole.log(JSON.stringify(in_.map(allowLinkUrl)));"
)
got = json.loads(_run_node(driver))
assert got == [expected for _, expected in cases], f"allowLinkUrl mismatch: {got!r}"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_image_transform_shapes_under_node() -> None:
"""The image shapes executed under node (TODO.md L3): a served-path
image (the app's own ``/api/documents/<id>/image`` shape), a
``data:image/*`` image (CSP ``img-src 'self' data:`` already permits
it), an absolute ``http(s)://`` image, ``./``/``../`` relative images,
an EMPTY alt (decorative — the attribute is still there), HTML in the
alt staying escaped (so the attribute cannot be broken out of), two
images on one line, an image inside a blockquote, an image inside a
fenced block staying code (step 1 protects first), and an image inside
a table cell rendering through the same ``inline()``."""
cases = {
"served": "![topology](/api/documents/aaaa/image)",
"assets": "![logo](/assets/brand.svg)",
"data": "![i](data:image/svg+xml;base64,PHN2Zz4=)",
"abs": "![a](https://x.test/i.png)",
"dotrel": "![img](./x.png)\n\n![up](../y.png)",
"empty_alt": "![](/a.png)",
"esc_alt": "![he said \"hi\" & <b>more</b>](/a.png)",
"two": "![a](/a.png)![b](/b.png)",
"quote": "> ![q](/a.png)",
"fence": "```\n![a](/a.png)\n```",
"cell": "| h |\n|---|\n| ![c](/a.png) |",
}
exact = {
"served": (
'<p><img class="md-img" src="/api/documents/aaaa/image" '
'alt="topology" loading="lazy"></p>'
),
"assets": (
'<p><img class="md-img" src="/assets/brand.svg" alt="logo" loading="lazy"></p>'
),
"data": (
'<p><img class="md-img" src="data:image/svg+xml;base64,PHN2Zz4=" '
'alt="i" loading="lazy"></p>'
),
"abs": (
'<p><img class="md-img" src="https://x.test/i.png" alt="a" loading="lazy"></p>'
),
"dotrel": (
'<p><img class="md-img" src="./x.png" alt="img" loading="lazy"></p>'
'<p><img class="md-img" src="../y.png" alt="up" loading="lazy"></p>'
),
"empty_alt": '<p><img class="md-img" src="/a.png" alt="" loading="lazy"></p>',
"esc_alt": (
'<p><img class="md-img" src="/a.png" '
'alt="he said &quot;hi&quot; &amp; &lt;b&gt;more&lt;/b&gt;" '
'loading="lazy"></p>'
),
"two": (
'<p><img class="md-img" src="/a.png" alt="a" loading="lazy">'
'<img class="md-img" src="/b.png" alt="b" loading="lazy"></p>'
),
"quote": (
'<blockquote class="md-quote">'
'<img class="md-img" src="/a.png" alt="q" loading="lazy">'
"</blockquote>"
),
"fence": "<p><pre><code>![a](/a.png)</code></pre></p>",
}
results = _render(cases)
for name, html in results.items():
if name in exact:
assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}"
assert '<td><img class="md-img" src="/a.png" alt="c" loading="lazy"></td>' in results[
"cell"
], "an image inside a table cell renders through inline()"
# every live image carries an alt AND the lazy hint — no exceptions
for name in ("served", "assets", "data", "abs", "empty_alt", "cell"):
start = results[name].find(IMG_MARKER)
assert start != -1, f"{name}: no <img> rendered — {results[name]!r}"
tag = results[name][start : results[name].find(">", start) + 1]
assert "alt=" in tag and LAZY_ATTR in tag, f"{name}: {tag!r} misses alt/lazy"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_disallowed_image_srcs_stay_inert_text() -> None:
"""LOCKED A5 under node: a ``javascript:``, ``file:``, ``blob:``,
``vbscript:`` or NON-image ``data:`` src (any case) never becomes a
live ``<img>`` — the whole ``![alt](src)`` construct renders verbatim
as the inert escaped text it was before, with no ``<img`` and no
``src=`` anywhere in the output. A bare relative path whose first
segment is longer than two characters is the documented miss."""
# value = the expected output: the construct verbatim, i.e. exactly as
# escape-first leaves it (an `&` in the source is `&amp;` on the way out)
bad = {
"js": ("![x](javascript:alert(1))", "![x](javascript:alert(1))"),
"js_upper": ("![x](JaVaScRiPt:alert(1))", "![x](JaVaScRiPt:alert(1))"),
"data_html": (
"![d](data:text/html;base64,PHN2Zz4=)",
"![d](data:text/html;base64,PHN2Zz4=)",
),
# the `&#58;` in the source is escaped like any other `&`, so the
# inert output carries `&amp;#58;`
"data_escaped_colon": (
"![d](data&#58;image/png;base64,YQ==)",
"![d](data&amp;#58;image/png;base64,YQ==)",
),
"file": ("![f](file:///etc/passwd)", "![f](file:///etc/passwd)"),
"blob": ("![b](blob:https://x.test/uuid)", "![b](blob:https://x.test/uuid)"),
"vbscript": ("![v](vbscript:msgbox(1))", "![v](vbscript:msgbox(1))"),
"bare_relative": ("![big](images/x.png)", "![big](images/x.png)"),
"empty_src": ("![decorative]()", "![decorative]()"),
}
results = _render({name: src for name, (src, _e) in bad.items()})
for name, (src, expected) in bad.items():
html = results[name]
assert html == f"<p>{expected}</p>", (
f"{name}: {html!r} must stay verbatim text for {src!r}"
)
assert "<img" not in html and "src=" not in html, f"{name}: {html!r} is a live img"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_link_only_image_src_falls_through_to_the_link_rule() -> None:
"""Documented limit (pinned so a change is deliberate): an image src
rejected by ``allowImageUrl`` but ACCEPTED by ``allowLinkUrl`` — a
link-only shape used as an image src, i.e. ``#fragment`` or
``mailto:`` — reaches the link rule, so ``![alt](url)`` renders as a
link behind the leftover ``!``. The rejected src never becomes a live
image, and the surviving element is an ordinary allowlisted link (the
same shape the pre-phase renderer produced for that input)."""
cases = {
"frag": (
"![f](#frag)",
'<p>!<a class="md-link" href="#frag">f</a></p>',
),
"mailto": (
"![a](mailto:x@y.test)",
'<p>!<a class="md-link" href="mailto:x@y.test">a</a></p>',
),
}
results = _render({name: src for name, (src, _e) in cases.items()})
for name, (src, expected) in cases.items():
assert results[name] == expected, f"{name} ({src}): {results[name]!r}"
assert IMG_MARKER not in results[name], f"{name}: {src} must not become an <img>"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_linked_image_wraps_without_stray_brackets() -> None:
"""LOCKED A6 under node: ``[![alt](src)](url)`` renders as ONE
``<a class="md-link">`` wrapping ONE ``<img class="md-img">`` with no
stray ``[``/`]` left in the output — for an absolute href (which
carries ``target``/``rel``, and keeps ``&amp;`` in a query string
verbatim), for a root-relative href and for a ``mailto:`` href (both
without ``target``/``rel``). When only the HREF is rejected the
construct falls through to the plain image rule (the image renders
inside the still-inert ``[…](url)`` text — documented limit, never a
live anchor); when only the SRC is rejected the whole construct stays
verbatim text."""
cases = {
"abs": "[![d](/a.svg)](https://example.com)",
"query": "[![linked](data:image/png;base64,YQ==)](https://x.test/x?a=1&b=2)",
"relative": "[![d](/a.svg)](/page)",
"mailto": "[![d](/a.svg)](mailto:a@x.test)",
"bad_href": "[![d](/a.svg)](javascript:alert(1))",
"bad_src": "[![d](javascript:alert(1))](https://example.com)",
}
exact = {
"abs": (
'<a class="md-link" href="https://example.com"'
' target="_blank" rel="noopener noreferrer">'
'<img class="md-img" src="/a.svg" alt="d" loading="lazy"></a>'
),
"query": (
'<a class="md-link" href="https://x.test/x?a=1&amp;b=2"'
' target="_blank" rel="noopener noreferrer">'
'<img class="md-img" src="data:image/png;base64,YQ==" alt="linked" '
'loading="lazy"></a>'
),
"relative": (
'<a class="md-link" href="/page">'
'<img class="md-img" src="/a.svg" alt="d" loading="lazy"></a>'
),
"mailto": (
'<a class="md-link" href="mailto:a@x.test">'
'<img class="md-img" src="/a.svg" alt="d" loading="lazy"></a>'
),
}
results = _render(cases)
for name, inner in exact.items():
assert results[name] == f"<p>{inner}</p>", (
f"{name}: {results[name]!r} != contract {inner!r}"
)
assert "[" not in results[name][3:-4] and "]" not in results[name][3:-4], (
f"{name}: {results[name]!r} keeps a stray bracket"
)
for name in ("abs", "query", "relative", "mailto"):
html = results[name]
assert html.count(LINK_MARKER) == 1 and html.count(IMG_MARKER) == 1
assert f"{LINK_MARKER} " in html and "</a></p>" in html
# the href-only rejection: the image still renders, the anchor does not
href_rejected = results["bad_href"]
assert LINK_MARKER not in href_rejected and "href" not in href_rejected
assert IMG_MARKER in href_rejected, "documented fall-through to the image rule"
# the src-only rejection: everything stays verbatim text
src_rejected = results["bad_src"]
assert src_rejected == f"<p>{cases['bad_src']}</p>", f"bad_src: {src_rejected!r}"
assert IMG_MARKER not in src_rejected and LINK_MARKER not in src_rejected
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_allowimage_url_function_accepts_and_rejects() -> None:
"""The image allowlist itself under node: accepted srcs come back
VERBATIM (escape-first — an ``&amp;`` inside the src is not decoded),
everything else comes back ``null``. ``data:`` is accepted ONLY for
``image/*`` (the app's CSP shape), and a link-only shape (``#frag``,
``mailto:``) is not an image."""
cases: list[tuple[str, str | None]] = [
# accepted — returned VERBATIM
("/api/documents/aaaa/image", "/api/documents/aaaa/image"),
("/assets/brand.svg", "/assets/brand.svg"),
("./x.png", "./x.png"),
("../y.png", "../y.png"),
("https://x.test/i.png", "https://x.test/i.png"),
("HTTP://X.TEST/I.PNG", "HTTP://X.TEST/I.PNG"),
("data:image/svg+xml;base64,PHN2Zz4=", "data:image/svg+xml;base64,PHN2Zz4="),
("DATA:IMAGE/PNG;base64,YQ==", "DATA:IMAGE/PNG;base64,YQ=="),
("/x.png?a=1&amp;b=2", "/x.png?a=1&amp;b=2"),
# rejected -> null (LOCKED A5): the construct stays inert text
("javascript:alert(1)", None),
("JaVaScRiPt:alert(1)", None),
("data:text/html,x", None),
("data&#58;image/png;base64,YQ==", None),
("file:///etc/passwd", None),
("blob:https://x.test/u", None),
("vbscript:x", None),
("x.test/i.png", None),
("images/x.png", None),
("#frag", None),
("mailto:a@x.test", None),
("", None),
("..%2fetc%2fpasswd", None),
]
driver = (
_text(MARKDOWN_JS)
+ "\nconst in_ = "
+ json.dumps([c[0] for c in cases])
+ ";\nconsole.log(JSON.stringify(in_.map(allowImageUrl)));"
)
got = json.loads(_run_node(driver))
assert got == [expected for _, expected in cases], f"allowImageUrl mismatch: {got!r}"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_heading_ladder_shapes_under_node() -> None:
"""TODO.md L5 under node: ``#### Deep`` renders ``<h5>Deep</h5>`` with
NO ``<p>`` wrapper and no literal ``####``; ``##### `` and ``###### ``
both render ``<h6>`` (two consecutive deep lines become two ``<h6>``
elements, still unwrapped); ``###``/``##``/``#`` are BYTE-IDENTICAL to
the pre-phase output; a deep heading immediately followed by a
paragraph is not wrapped in ``<p>``; inline markup inside a deep
heading still transforms; a ``####`` line inside a fence stays fence
content (step 1 protects first); a ``####`` in a table cell renders
through the same ``inline()``; and the non-heading shapes (a mid-line
``####``, an indented ``####``, a hash run with no space) stay text."""
cases = {
"h4": "#### Deep",
"h5_h6": "##### Deeper\n###### Deepest",
"h6": "###### Deepest",
"existing": "### A\n\n## B\n\n# C",
"then_para": "#### H\n\nA paragraph after.",
"rich": "#### Deep with **bold** and `c`",
"fence": "```\n#### Deep\n```",
"cell": "| h |\n|---|\n| #### cell head |",
"midline": "text with a #### mid-line hash",
"indented": " #### Not a heading",
"no_space": "####No space",
}
exact = {
"h4": "<h5>Deep</h5>",
"h5_h6": "<h6>Deeper</h6>\n<h6>Deepest</h6>",
"h6": "<h6>Deepest</h6>",
# the pre-existing ladder, unchanged (LOCKED A4)
"existing": "<h4>A</h4><h3>B</h3><h3>C</h3>",
"then_para": "<h5>H</h5><p>A paragraph after.</p>",
"rich": "<h5>Deep with <strong>bold</strong> and <code>c</code></h5>",
"fence": "<p><pre><code>#### Deep</code></pre></p>",
"midline": "<p>text with a #### mid-line hash</p>",
# the paragraph pass trims the block, the heading rule still does not fire
"indented": "<p>#### Not a heading</p>",
"no_space": "<p>####No space</p>",
}
results = _render(cases)
for name, html in results.items():
if name in exact:
assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}"
# the untouched step-3 guard does its job: no heading inside a <p>
for name in ("h4", "h5_h6", "h6", "then_para", "rich"):
assert "<p><h" not in results[name], f"{name}: a heading was wrapped in <p>"
assert results["h5_h6"].count("<h6>") == 2 and results["h5_h6"].count("</h6>") == 2, (
"##### and ###### both render <h6>"
)
assert "####" not in results["h4"], "the hash syntax must not survive"
assert "<h5>cell head</h5>" in results["cell"], (
"a deep heading inside a table cell renders through inline()"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_heading_ladder_stops_at_h6_and_never_invents_levels() -> None:
"""LOCKED A4 under node: the deep run is ``#{5,6}``, so a 7+ hash run
is NOT a heading (``####### Seven`` has no space after five/six hashes
at column 0) and the renderer never invents an element HTML does not
have — there is no ``<h7>``/``<h8>`` in the source or the output."""
js = _text(MARKDOWN_JS)
# (`<h7>` appears in the prose that explains WHY there is none, so the
# pin is on the output-template form, not the bare tag name)
for forbidden in ('"<h7', '"</h7', '"<h8', '"</h8', "^#{7"):
assert forbidden not in js, f"markdown.js must never emit {forbidden!r}"
bad = {"h7": "####### Seven", "h8": "######## Eight"}
results = _render(bad)
for name, src in bad.items():
assert results[name] == f"<p>{src}</p>", f"{name}: {results[name]!r} stays text"
assert "<h" not in results[name], f"{name}: {results[name]!r} invented a heading"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_input_without_links_renders_byte_identical() -> None:
"""The byte-identity contract (phase 180 design D7): an input with no
link, no image ref and no ``####``+ heading renders BYTE-EXACTLY as
the pre-phase renderer did (the fixture string and its pre-phase
output are checked into this test, per the phase-44/138 shape) —
which is what keeps the phase-44 table suite and the phase-138
blockquote suite green unedited."""
results = _render({"no_construct": NO_CONSTRUCT_INPUT})
assert results["no_construct"] == NO_CONSTRUCT_PRE_PHASE_OUTPUT
assert LINK_MARKER not in results["no_construct"]
assert IMG_MARKER not in results["no_construct"]
assert LAZY_ATTR not in results["no_construct"]
# the ladder added no levels ABOVE ####, so an input whose deepest
# heading is ### gains no h5/h6 either
for tag in ("<h5", "<h6"):
assert tag not in results["no_construct"], f"{tag} appeared unasked"
for expected in ("<h3>Top heading</h3>", "<h3>Second heading</h3>", "<h4>Third heading</h4>"):
assert expected in results["no_construct"], f"ladder regression: {expected}"