mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 02:36:27 +00:00
192 lines
7.0 KiB
Python
192 lines
7.0 KiB
Python
"""
|
|
Regression tests for #1154 — fenced code block content leaking into
|
|
markdown passes and corrupting subsequent message rendering.
|
|
|
|
Root cause: fenced code blocks were rendered to <pre><code> early in
|
|
the pipeline, BEFORE list/heading/table regexes ran. Lines inside
|
|
code blocks that looked like markdown (e.g. `- removed`, `+ added`
|
|
in diff blocks) were matched by those regexes, injecting <ul>/<li>
|
|
HTML inside <pre>, breaking </pre> closure and corrupting layout.
|
|
|
|
Fix: fenced blocks are converted to <pre><code> HTML inside the stash
|
|
callback and kept as \\x00P tokens until AFTER all markdown passes.
|
|
"""
|
|
import re
|
|
import shutil
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = __import__("pathlib").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]));
|
|
|
|
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("renderer_driver") / "driver.js"
|
|
p.write_text(_DRIVER_SRC, encoding="utf-8")
|
|
return str(p)
|
|
|
|
|
|
def _render(driver_path, markdown: str) -> str:
|
|
import subprocess
|
|
result = subprocess.run(
|
|
[NODE, driver_path, str(UI_JS_PATH)],
|
|
input=markdown, capture_output=True, text=True, timeout=10,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"node driver failed: {result.stderr}")
|
|
return result.stdout
|
|
|
|
|
|
class TestFencedCodeBlockIsolation:
|
|
"""Content inside fenced code blocks must never be interpreted as markdown."""
|
|
|
|
def test_diff_block_plus_minus_lines_not_treated_as_list(self, driver_path):
|
|
html = _render(driver_path,
|
|
"before\n\n```diff\n- removed line\n+ added line\n```\nafter")
|
|
pre = _extract_pre(html)
|
|
assert pre is not None, "Expected a <pre> block"
|
|
assert "<ul>" not in pre, "List HTML must not appear inside <pre>"
|
|
assert "<li>" not in pre, "List items must not appear inside <pre>"
|
|
assert "- removed line" in pre
|
|
assert "+ added line" in pre
|
|
assert html.count("<pre") == html.count("</pre>"), \
|
|
"Every <pre> must have a matching </pre>"
|
|
|
|
def test_bash_block_asterisk_lines_not_treated_as_list(self, driver_path):
|
|
html = _render(driver_path,
|
|
"```bash\n* not a list item\n* another one\n```")
|
|
pre = _extract_pre(html)
|
|
assert pre is not None
|
|
assert "<ul>" not in pre
|
|
assert "<li>" not in pre
|
|
|
|
def test_markdown_block_heading_not_treated_as_h1(self, driver_path):
|
|
html = _render(driver_path,
|
|
"```markdown\n# Not a heading\n## Also not\n```")
|
|
pre = _extract_pre(html)
|
|
assert pre is not None
|
|
assert "<h1>" not in pre
|
|
assert "<h2>" not in pre
|
|
assert "# Not a heading" in pre
|
|
|
|
def test_markdown_block_bold_not_treated_as_strong(self, driver_path):
|
|
html = _render(driver_path, "```text\n**not bold**\n```")
|
|
pre = _extract_pre(html)
|
|
assert pre is not None
|
|
assert "<strong>" not in pre
|
|
assert "**not bold**" in pre
|
|
|
|
def test_content_after_code_block_renders_normally(self, driver_path):
|
|
html = _render(driver_path,
|
|
"```diff\n- old\n+ new\n```\n\n"
|
|
"# Real Heading\n\n- real list item\n\n**bold text**")
|
|
assert "<h1>Real Heading</h1>" in html
|
|
assert "<ul>" in html
|
|
assert "<strong>bold text</strong>" in html
|
|
pre = _extract_pre(html)
|
|
assert "<ul>" not in pre
|
|
|
|
def test_no_escaped_pre_closing_tag(self, driver_path):
|
|
html = _render(driver_path,
|
|
"```diff\n- old line\n+ new line\n```\n\nAfter the block")
|
|
assert "</pre>" not in html, \
|
|
"Escaped </pre> indicates broken HTML nesting"
|
|
|
|
def test_code_block_without_language(self, driver_path):
|
|
html = _render(driver_path,
|
|
"```\n- item\n+ item\n```\n\n- real list")
|
|
pre = _extract_pre(html)
|
|
assert pre is not None
|
|
assert "<ul>" not in pre
|
|
assert html.count("<pre") == html.count("</pre>")
|
|
|
|
def test_inline_backtick_still_works(self, driver_path):
|
|
html = _render(driver_path, "Some **`code`** here")
|
|
assert "<strong><code>code</code></strong>" in html
|
|
|
|
def test_inline_backtick_inside_bold(self, driver_path):
|
|
html = _render(driver_path,
|
|
"Text with `inline code` and **bold**")
|
|
assert "<code>inline code</code>" in html
|
|
assert "<strong>bold</strong>" in html
|
|
|
|
def test_large_diff_output_no_corruption(self, driver_path):
|
|
diff_lines = "\n".join(
|
|
f"- old_line_{i}" if i % 2 == 0 else f"+ new_line_{i}"
|
|
for i in range(50)
|
|
)
|
|
html = _render(driver_path,
|
|
f"```diff\n{diff_lines}\n```\n\nSummary after diff")
|
|
pre = _extract_pre(html)
|
|
assert pre is not None
|
|
assert "<ul>" not in pre
|
|
assert "<li>" not in pre
|
|
assert html.count("<pre") == html.count("</pre>")
|
|
assert "Summary after diff" in html
|
|
|
|
def test_mixed_fenced_and_inline_code(self, driver_path):
|
|
html = _render(driver_path,
|
|
"Use `rm -rf` to delete:\n\n"
|
|
"```bash\nrm -rf /tmp/stuff\n```\n\nDone.")
|
|
assert "<code>rm -rf</code>" in html
|
|
pre = _extract_pre(html)
|
|
assert pre is not None
|
|
assert "rm -rf /tmp/stuff" in pre
|
|
|
|
|
|
class TestFencedBlockPreHeaderPreserved:
|
|
"""Pre-header div (language label) must still be generated."""
|
|
|
|
def test_language_header_present(self, driver_path):
|
|
html = _render(driver_path,
|
|
"```python\ndef hello():\n pass\n```")
|
|
assert '<div class="pre-header">python</div>' in html
|
|
|
|
def test_no_language_no_header(self, driver_path):
|
|
html = _render(driver_path, "```\nsome code\n```")
|
|
assert "pre-header" not in html
|
|
|
|
def test_language_class_on_code_tag(self, driver_path):
|
|
html = _render(driver_path,
|
|
"```javascript\nconsole.log('hi')\n```")
|
|
assert 'class="language-javascript"' in html
|
|
|
|
|
|
def _extract_pre(html):
|
|
m = re.search(r"<pre[^>]*>[\s\S]*?</pre>", html)
|
|
return m.group(0) if m else None
|