"""Behavioural tests for the CJK full-width punctuation autolink fix (#6792). These tests drive the ACTUAL renderMd() in static/ui.js via node — the same harness used by test_renderer_js_behaviour.py — so they exercise the real production autolink passes (inlineMd + outer paragraph pass) instead of a test-owned copy of the regex. Bug: the autolink regex only excluded ASCII ')' (U+0029) from URL matches. When a URL appeared before a CJK full-width right parenthesis )(U+FF09) — extremely common in Chinese LLM output like "(或 https://lmarena.ai)" — the )was swallowed into the link. Fix: add \uFF09 to the regex exclusion set AND to the trailing-punctuation strip. Also covers full-width comma/period/colon/semicolon/exclamation/ question mark/ideographic comma for completeness. Every case asserts BOTH directions: - the punctuation never enters href - the punctuation remains visible after the closing """ import shutil import subprocess 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). 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("autolink_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 # Every CJK full-width mark the fix adds to the trailing-punctuation strip: # )(U+FF09) ,(U+FF0C) 。(U+3002) ;(U+FF1B) :(U+FF1A) !(U+FF01) ?(U+FF1F) 、(U+3001) CJK_TRAILING_MARKS = [")", ",", "。", ";", ":", "!", "?", "、"] # ASCII punctuation the existing autolink strip already handled (regression guard). ASCII_TRAILING_MARKS = [")", ",", ".", ";", ":", "!", "?"] class TestAutolinkOuterParagraphPass: """Plain-paragraph URLs go through the OUTER autolink pass in renderMd().""" @pytest.mark.parametrize("mark", CJK_TRAILING_MARKS) def test_cjk_trailing_mark_stripped_and_visible_after_anchor(self, driver_path, mark): out = _render(driver_path, f"See https://example.com{mark}") assert 'href="https://example.com"' in out, ( f"CJK mark {mark!r} must not enter href. Got: {out!r}" ) assert f'href="https://example.com{mark}"' not in out, ( f"CJK mark {mark!r} leaked into href. Got: {out!r}" ) assert f"{mark}" in out, ( f"CJK mark {mark!r} must stay visible after . Got: {out!r}" ) @pytest.mark.parametrize("mark", ASCII_TRAILING_MARKS) def test_ascii_trailing_mark_stripped_and_visible_after_anchor(self, driver_path, mark): out = _render(driver_path, f"See https://example.com{mark}") assert 'href="https://example.com"' in out, ( f"ASCII mark {mark!r} must not enter href. Got: {out!r}" ) assert f'href="https://example.com{mark}"' not in out assert f"{mark}" in out def test_fullwidth_paren_wrapped_url(self, driver_path): """The original bug shape: (或 https://lmarena.ai).""" out = _render(driver_path, "(或 https://lmarena.ai)") assert 'href="https://lmarena.ai"' in out assert 'href="https://lmarena.ai)"' not in out assert ")" in out def test_normal_url_with_query_params_untouched(self, driver_path): out = _render(driver_path, "https://example.com/path?q=1&x=2") # href keeps the raw & (esc() is NOT applied to href — that would # corrupt query strings)… assert 'href="https://example.com/path?q=1&x=2"' in out # …while the visible link text IS escaped (XSS safety). assert ">https://example.com/path?q=1&x=2" in out def test_markdown_link_not_double_autolinked(self, driver_path): """[label](url) must be stashed before autolink so its URL is not re-linked.""" out = _render(driver_path, "[see docs](https://example.com/doc)") assert 'href="https://example.com/doc"' in out # The link text must be the label, not a second autolinked URL. assert "see docs" in out assert out.count(" blocks must be stashed so autolink never runs inside them.""" out = _render(driver_path, 'pre') assert 'href="https://example.com/pre"' in out assert out.count("{mark}" in out @pytest.mark.parametrize("mark", CJK_TRAILING_MARKS) def test_blockquote_cjk_mark_stripped(self, driver_path, mark): out = _render(driver_path, f"> See https://example.com{mark}") assert 'href="https://example.com"' in out, ( f"Inline pass leaked CJK mark {mark!r} into href. Got: {out!r}" ) assert f'href="https://example.com{mark}"' not in out assert f"{mark}" in out def test_ordered_list_item_cjk_mark_stripped(self, driver_path): out = _render(driver_path, "1. See https://example.com!") assert 'href="https://example.com"' in out assert 'href="https://example.com!"' not in out assert "!" in out def test_inline_fullwidth_paren_wrapped_url(self, driver_path): out = _render(driver_path, "- (或 https://lmarena.ai)") assert 'href="https://lmarena.ai"' in out assert 'href="https://lmarena.ai)"' not in out assert ")" in out def test_inline_markdown_link_not_double_autolinked(self, driver_path): out = _render(driver_path, "> [link](https://example.com/x)") assert 'href="https://example.com/x"' in out assert out.count("