34 test files read static/*.js via read_text()/open() without an encoding, so Python's cp1252 default on Windows choked on non-Latin1 bytes and errored at collection (Ubuntu CI defaults to utf-8, so it never saw this). Add explicit encoding="utf-8" to those reads — purely mechanical, no assertion changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
276 lines
11 KiB
Python
276 lines
11 KiB
Python
"""Regression tests for #1438 — triple backticks mid-line should not terminate fences.
|
|
|
|
Bug shape (issue #1438): the fence regex `/```([\\s\\S]*?)```/g` had no line
|
|
anchoring. A literal triple backtick inside a code-block content (e.g. a regex
|
|
pattern with ``` in a lookbehind) terminated the outer fence at the wrong place.
|
|
The leaked tail then went through bold/italic/inline-code passes, eating `*`
|
|
characters and leaking literal `</strong>` tags into the rendered output.
|
|
|
|
Reported by Cygnus (Discord, May 1 2026), relayed by @AvidFuturist.
|
|
|
|
Fix: anchor the fence regex per CommonMark §4.5 — opening fence must start a
|
|
line (with up to 3 spaces of indent), closing fence must also start a line.
|
|
|
|
Sites patched:
|
|
static/ui.js:1557 — renderMd() fenced-block stash
|
|
static/ui.js:74 — _renderUserFencedBlocks() (user message renderer)
|
|
static/ui.js:2597 — _stripForTTS() (TTS pre-strip)
|
|
tests/test_sprint16.py — Python mirror (two regexes to keep in sync)
|
|
|
|
Note on the Python mirror: it does not implement `_ob_stash` (which protects
|
|
`<code>` from outer bold/italic) or `_pre_stash` (which protects `<pre>` from
|
|
paragraph-wrap). So mirror output may show extra `<em>` inside `<pre>` blocks
|
|
or `<pre>` wrapped in `<p>`. These are mirror-only artifacts; the actual JS
|
|
pipeline produces clean output. Tests below focus on the structural property
|
|
that the fence regex captures the correct extent — the part that's identical
|
|
in both implementations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.test_sprint16 import render_md # Python mirror of renderMd()
|
|
|
|
|
|
UI_JS = (Path(__file__).resolve().parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _strip_pre_blocks(out: str) -> str:
|
|
"""Remove all <pre>...</pre> blocks for assertions about the surrounding text."""
|
|
return re.sub(r"<pre[\s\S]*?</pre>", "", out)
|
|
|
|
|
|
# ── 1. THE BUG: Cygnus's exact repro ──────────────────────────────────────────
|
|
|
|
|
|
def test_inner_triple_backtick_inside_regex_does_not_terminate_outer_fence():
|
|
"""Cygnus's exact input — a regex literal with ``` in a lookbehind."""
|
|
text = (
|
|
"Here's the regex:\n\n"
|
|
"```regex\n"
|
|
"(?<!\\n)(?<!(?:^|\\n)[ \\t]*(?:```[^\\n]*|%%[ \\t]*(?:\\n|$)))\n"
|
|
"```\n\n"
|
|
"uses **bold** for emphasis."
|
|
)
|
|
out = render_md(text)
|
|
|
|
# ── Structural property: exactly one fence captured the right extent.
|
|
assert out.count("<pre>") == 1, f"expected 1 <pre>, got {out.count('<pre>')}"
|
|
assert out.count("</pre>") == 1
|
|
|
|
# ── The inner ``` is preserved as literal text inside the code block.
|
|
# Before the fix, the `</code></pre>` would appear MID-content and the
|
|
# inner ``` would NOT survive (the regex ate it as a delimiter).
|
|
pre_block = re.search(r"<pre[\s\S]*?</pre>", out)
|
|
assert pre_block, "no <pre> block found"
|
|
assert "```[^" in pre_block.group(0), (
|
|
"inner triple backtick should be preserved as literal inside the code block"
|
|
)
|
|
assert "%%" in pre_block.group(0), "tail of regex must survive inside code block"
|
|
|
|
# ── No orphaned ``` outside any <pre> block.
|
|
# Before the fix, the trailing ``` (which used to be a closing fence)
|
|
# leaked into the surrounding markdown stream as literal text.
|
|
outside = _strip_pre_blocks(out)
|
|
assert "```" not in outside, f"orphaned ``` leaked outside <pre>: {outside!r}"
|
|
|
|
# ── Bold renders correctly AFTER the fence.
|
|
assert "<strong>bold</strong>" in out, "bold must survive intact"
|
|
# Strong tags balanced.
|
|
assert out.count("<strong>") == out.count("</strong>")
|
|
|
|
|
|
# ── 2. Inline triple-backtick in running text must NOT open a fence ────────────
|
|
|
|
|
|
def test_inline_triple_backtick_in_paragraph_does_not_open_fence():
|
|
"""A ``` in the middle of a sentence must not be treated as a fence opener."""
|
|
text = "Plain text with ``` in the middle of a sentence."
|
|
out = render_md(text)
|
|
assert "<pre>" not in out
|
|
# The literal ``` survives somewhere in the rendered output.
|
|
assert "```" in out
|
|
|
|
|
|
def test_three_backticks_at_end_of_sentence_no_fence():
|
|
"""``` immediately after text on the same line — not a fence."""
|
|
text = "End with``` not a fence"
|
|
out = render_md(text)
|
|
assert "<pre>" not in out
|
|
|
|
|
|
def test_unmatched_partial_fence_does_not_eat_message():
|
|
"""Partial/streaming input with no closing fence — must not match anything.
|
|
|
|
Before the fix, an inner ``` could pair with itself and produce a bogus <pre>.
|
|
With anchoring, no match occurs and content stays as plain text.
|
|
"""
|
|
text = "```python\nincomplete code, no close yet"
|
|
out = render_md(text)
|
|
assert "<pre>" not in out
|
|
assert "incomplete code" in out
|
|
|
|
|
|
# ── 3. Existing happy paths must keep working ─────────────────────────────────
|
|
|
|
|
|
def test_simple_python_fence_renders():
|
|
text = "```python\nprint('hi')\n```"
|
|
out = render_md(text)
|
|
assert "<pre>" in out
|
|
# esc() may HTML-encode quotes; either form is fine.
|
|
assert "print('hi')" in out or "print('hi')" in out
|
|
# Language tag preserved.
|
|
assert 'class="pre-header">python' in out
|
|
|
|
|
|
def test_fence_after_paragraph_no_blank_line_required():
|
|
"""CommonMark allows a fence directly after text — `\\n` is enough."""
|
|
text = "Some text\n```\nx = 1\n```"
|
|
out = render_md(text)
|
|
assert "<pre>" in out
|
|
assert "x = 1" in out
|
|
|
|
|
|
def test_fence_at_end_of_input_no_trailing_newline():
|
|
"""Closing fence at the very end of input (no newline after)."""
|
|
text = "Intro\n\n```\nfoo\n```"
|
|
out = render_md(text)
|
|
assert out.count("<pre>") == 1
|
|
assert "foo" in out
|
|
|
|
|
|
def test_two_adjacent_fenced_blocks_render_independently():
|
|
text = "```\nA\n```\n\n```\nB\n```"
|
|
out = render_md(text)
|
|
assert out.count("<pre>") == 2
|
|
assert "A" in out and "B" in out
|
|
|
|
|
|
def test_three_space_indented_fence_still_recognised():
|
|
"""CommonMark allows up to 3 spaces of indent on fence lines."""
|
|
text = " ```\nfoo\n ```"
|
|
out = render_md(text)
|
|
assert "<pre>" in out
|
|
assert "foo" in out
|
|
|
|
|
|
def test_four_space_indent_is_not_a_fence():
|
|
"""4+ spaces is an indented code block in CommonMark, not a fence.
|
|
|
|
With the line-anchored regex, this no longer matches the fence regex, so
|
|
no <pre> with bogus content. We don't implement strict CommonMark
|
|
indented-code-block behaviour — just verify we don't false-positive.
|
|
"""
|
|
text = " ```py\n foo\n ```"
|
|
out = render_md(text)
|
|
assert "<pre>" not in out
|
|
|
|
|
|
# ── 4. Bold/italic/inline-code outside a fence still work after the fix ───────
|
|
|
|
|
|
def test_bold_after_fence_renders_correctly():
|
|
text = "```\ncode\n```\n\nThen **bold** text."
|
|
out = render_md(text)
|
|
assert "<pre>" in out
|
|
assert "<strong>bold</strong>" in out
|
|
|
|
|
|
def test_italic_after_fence_renders_correctly():
|
|
text = "```\ncode\n```\n\nThen *italic* text."
|
|
out = render_md(text)
|
|
assert "<pre>" in out
|
|
assert "<em>italic</em>" in out
|
|
|
|
|
|
def test_inline_code_after_fence():
|
|
text = "```\nblock\n```\n\nThen `inline` code."
|
|
out = render_md(text)
|
|
assert "<pre>" in out
|
|
assert "<code>inline</code>" in out
|
|
|
|
|
|
# ── 5. SOURCE-LEVEL guards (catch regression of any of the 3 patched sites) ───
|
|
|
|
|
|
def test_renderMd_fence_regex_is_line_anchored():
|
|
"""The fence regex in renderMd must keep line anchoring and fence-length matching."""
|
|
pattern = r"s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g"
|
|
assert pattern in UI_JS, "renderMd fence regex lost line anchoring or #1696 fence-length matching"
|
|
|
|
|
|
def test_renderUserFencedBlocks_fence_regex_is_line_anchored():
|
|
"""The fence regex in _renderUserFencedBlocks must also be line-anchored."""
|
|
pattern = r"s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g"
|
|
assert UI_JS.count(pattern) >= 2, "render/user fence regexes lost line anchoring or #1696 fence-length matching"
|
|
|
|
|
|
def test_stripForTTS_fence_regex_is_line_anchored():
|
|
"""_stripForTTS must use the line-anchored fence regex too."""
|
|
assert re.search(
|
|
r"text=text\.replace\(/\(\^\|\\n\)\[ \]\{0,3\}```\(\?:\[\\s\\S\]\*\?\\n\)\?\[ \]\{0,3\}```\(\?=\\n\|\$\)/g",
|
|
UI_JS,
|
|
), "_stripForTTS fence regex is not line-anchored — regression of #1438"
|
|
|
|
|
|
def test_renderMd_callback_prefixes_lead():
|
|
"""Without `lead+`, the leading newline gets stripped and paragraphs above
|
|
bleed into the <pre> after fence stash restore.
|
|
"""
|
|
assert "return lead+'\\x00P'+(_preBlock_stash.length-1)+'\\x00';" in UI_JS, (
|
|
"renderMd fence callback must return lead+stashtoken to preserve newlines"
|
|
)
|
|
|
|
|
|
def test_renderUserFencedBlocks_callback_prefixes_lead():
|
|
assert "return lead+'\\x00UF'+(stash.length-1)+'\\x00';" in UI_JS, (
|
|
"_renderUserFencedBlocks callback must return lead+stashtoken to preserve newlines"
|
|
)
|
|
|
|
|
|
def test_no_unanchored_fence_regex_remains_in_render_paths():
|
|
"""Belt-and-suspenders: assert the OLD vulnerable patterns are gone from
|
|
the three render/strip paths. (Documenting the regex literal is fragile;
|
|
we just verify the bare unanchored form isn't present in the patched
|
|
sites as a literal substring.)
|
|
"""
|
|
# The exact OLD vulnerable forms that this PR replaces.
|
|
old_render_md = "s=s.replace(/```([\\s\\S]*?)```/g,(_,raw)=>{"
|
|
old_user_fenced = "s=s.replace(/```([a-zA-Z0-9_+-]*)\\n([\\s\\S]*?)```/g,(_,lang,code)=>{"
|
|
old_tts_strip = "text=text.replace(/```[\\s\\S]*?```/g,' ');"
|
|
assert old_render_md not in UI_JS, "old unanchored renderMd fence regex still present"
|
|
assert old_user_fenced not in UI_JS, "old unanchored _renderUserFencedBlocks regex still present"
|
|
assert old_tts_strip not in UI_JS, "old unanchored _stripForTTS regex still present"
|
|
|
|
|
|
# ── 6. Diff/patch fence with inner ``` in content ─────────────────────────────
|
|
|
|
|
|
def test_diff_fence_with_inner_backticks_in_content():
|
|
"""Diff/patch blocks where a content line contains ``` mid-line — the inner
|
|
``` must not be treated as a close fence.
|
|
|
|
Verified at the source level: the JS regex requires the close fence to be
|
|
on its own line (preceded by `\\n`), so a mid-line ``` cannot match.
|
|
The Python mirror doesn't implement diff/patch styling or _preBlock_stash
|
|
so we don't assert against its output here.
|
|
"""
|
|
# Source-level: the renderMd fence regex requires `\n[ ]{0,3}\`\`\`(?=\n|$)`
|
|
# for the close fence — a mid-line ``` cannot match.
|
|
# Three sites must all use this pattern.
|
|
# Pattern explanation: ui.js source contains literal backslash-n in regex literals
|
|
# (ONE backslash + 'n'). In a Python raw string, r"\\n" compiles to a regex pattern
|
|
# matching ONE literal backslash followed by 'n'.
|
|
new_matches = UI_JS.count(r"[ ]{0,3}\2`*[ \t]*(?=\n|$)")
|
|
old_tts_matches = re.findall(r"```\(\?=\\n\|\$\)", UI_JS)
|
|
assert new_matches >= 2 and len(old_tts_matches) >= 1, (
|
|
f"renderMd/_renderUserFencedBlocks must have fence-length-aware line-anchored "
|
|
f"closers and _stripForTTS must keep a line-anchored closer; found "
|
|
f"new={new_matches}, tts={len(old_tts_matches)}"
|
|
)
|