"""Behavioural tests that drive the ACTUAL renderMd() in static/ui.js via node. The Python mirrors in test_blockquote_rendering.py and test_renderer_comprehensive.py validate intent, but they can drift from the JS. Twice now (PR #1073 commit 94d63d0 — phantom
; PR #1073 commit 04e7b53 — leading-space-in-blockquote prefix-strip regex) the Python mirror was correct while the JS was not, so the static-mirror tests passed even though the live UI was broken. This file closes that gap by spawning ``node`` on the real ui.js and asserting the rendered HTML for the most common LLM-output shapes. Add a case here whenever the renderer fix targets a class of input the Python mirror cannot exercise faithfully. """ import os import re import shutil import subprocess import sys 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: '' }), baseURI: 'http://localhost/app/' }; function _sessionUrlForSid(sid) { return '/app/session/' + encodeURIComponent(String(sid || '')); } 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; // Minimal stand-in for ui.js' _inlineMediaHtmlForRef used when the driver // extracts only renderMd(). Mirrors the live UI for the cases the existing // tests assert against (https image, bare file:// image). The full renderer // (ui.js) is preferred on the real page — these nodes only cover what is // reachable from a standalone renderMd() invocation. function _inlineMediaHtmlForRef(ref){ const r = String(ref || ''); if (/^https?:\/\//.test(r)) return `image`; if (/^file:\/\//.test(r)){ const m = r.replace(/^file:\/\//i, ''); return `image`; } return `image`; } 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): """Write the node driver to a tmp file (works around `node -e` arg quirks).""" 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: """Run renderMd against the actual ui.js and return the rendered HTML.""" result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH)], input=markdown, capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") return result.stdout class TestSessionInternalLinks: """Drive renderMd() so session:// hardening covers the real sanitizer path.""" def test_session_scheme_renders_same_origin_internal_anchor(self, driver_path): out = _render(driver_path, "[Open session](session://abc123)") assert 'class="session-link"' in out assert 'href="/app/session/abc123"' in out assert 'target="_blank"' not in out assert 'rel="noopener"' not in out def test_hostile_session_scheme_collapses_to_encoded_session_path(self, driver_path): out = _render(driver_path, "[bad](session://javascript:alert(1))") assert 'class="session-link"' in out assert 'href="/app/session/javascript%3Aalert(1"' in out assert 'href="javascript:' not in out assert 'target="_blank"' not in out def test_unrelated_same_origin_session_segment_is_not_allowlisted(self, driver_path): out = _render(driver_path, 'bad') assert 'href="/anything/foo/session/abc"' not in out assert 'bad' in out # ───────────────────────────────────────────────────────────────────────────── # Blockquote prefix strip — the bug commit 04e7b53 introduced was a one-char # regex regression where `^>[\t]?` (only tab) replaced `^>[ \t]?` (space or # tab), producing leading-space artifacts and breaking lists-in-quotes # because the list-detection regex `^( )?[-*+]` couldn't match the # space-prefixed lines. These tests exercise the actual JS so the regex # can't silently regress to tab-only again. # ───────────────────────────────────────────────────────────────────────────── class TestBlockquotePrefixStrip: """Drive the actual renderMd to confirm `> ` is fully stripped.""" def test_single_line_blockquote_no_leading_space(self, driver_path): out = _render(driver_path, "> Hello world").strip() # New shape: recursive renderMd wraps content in

(CommonMark-correct). assert "

Hello world

" in out, ( f"`> Hello world` must render as

Hello world

" f"with no leading space. Got: {out!r}." ) def test_multiline_blockquote_no_leading_space(self, driver_path): out = _render(driver_path, "> Line one\n> Line two").strip() # New shape: single paragraph with
between soft-wrapped lines. assert "

Line one
Line two

" in out, ( f"Multi-line blockquote must strip the space after each `>` and " f"render as a single paragraph. Got: {out!r}" ) # Belt-and-braces: there must be no space-after-newline-in-content assert "\n " not in out.replace("", ""), ( f"Inner content of blockquote should not contain leading-space " f"lines. Got: {out!r}" ) def test_list_inside_blockquote_renders_as_ul(self, driver_path): """The PR explicitly added 'lists inside blockquotes' as a feature. With the prefix-strip bug, the list-detection regex can't match the space-prefixed lines, so the list never renders. This pins it.""" out = _render(driver_path, "> Steps:\n> - one\n> - two") assert "