mirror of
https://github.com/tw93/Kami.git
synced 2026-09-18 10:18:26 +08:00
a72af5674a
Match HTMLParser line accounting to keep Unicode separators from hiding raw formulas. Restore incomplete checkout fonts from validated sources and retain healthy copies. Regression tests reproduce both failures on the previous code.
530 lines
20 KiB
Python
530 lines
20 KiB
Python
"""Strict LaTeX rendering and locked MathJax runtime tests."""
|
|
from __future__ import annotations
|
|
|
|
from support import REPO_ROOT, SKILL_ROOT, check, skip
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import tracemalloc
|
|
from pathlib import Path
|
|
|
|
|
|
def test_math_render_finds_standard_delimiters_only_in_text() -> None:
|
|
from math_render import _latex_spans
|
|
|
|
source = (
|
|
r'<p data-formula="\(attribute\)">'
|
|
r'Inline \(x^2\), display \[E = mc^2\], '
|
|
r'escaped \\(literal\\).</p>'
|
|
r'<code>\(code\)</code><pre>\[pre\]</pre>'
|
|
r'<script>const formula = "\(script\)";</script>'
|
|
)
|
|
spans, issues = _latex_spans(source)
|
|
check("math parser accepts standard inline and display delimiters",
|
|
[(span.tex, span.display) for span in spans]
|
|
== [("x^2", False), ("E = mc^2", True)],
|
|
f"spans={spans} issues={issues}")
|
|
check("math parser ignores attributes, escaped delimiters, code, pre, and script",
|
|
not issues, str(issues))
|
|
|
|
|
|
def test_math_parser_offsets_follow_htmlparser_newlines() -> None:
|
|
import math_render as math_mod
|
|
from math_render import _latex_spans, check_latex_html
|
|
|
|
for separator in ("\u2028", "\u2029", "\u0085", "\r", "\r\n", "\n"):
|
|
source = f"<p>alpha{separator}beta</p>\n" + r"<p>\(x\)</p>"
|
|
spans, issues = _latex_spans(source)
|
|
check(f"math offsets preserve formula after {separator!r}",
|
|
not issues and len(spans) == 1
|
|
and source[spans[0].start:spans[0].end] == r"\(x\)"
|
|
and spans[0].tex == "x", repr(spans))
|
|
check(f"math checker catches raw formula after {separator!r}",
|
|
bool(check_latex_html(source)))
|
|
original_renderer = math_mod._render_svg
|
|
try:
|
|
math_mod._render_svg = lambda formulas: ["<svg></svg>" for _ in formulas]
|
|
rendered = math_mod.render_latex_in_html(source)
|
|
finally:
|
|
math_mod._render_svg = original_renderer
|
|
check(f"math replacement preserves surrounding HTML after {separator!r}",
|
|
rendered.startswith(f"<p>alpha{separator}beta</p>\n<p>")
|
|
and rendered.endswith("</p>") and "latex-inline-svg" in rendered
|
|
and r"\(x\)" not in rendered, repr(rendered))
|
|
|
|
|
|
def test_math_check_rejects_raw_unmatched_and_legacy_sources() -> None:
|
|
from math_render import check_latex_html
|
|
|
|
cases = [
|
|
r"<p>\(x^2\)</p>",
|
|
r"<p>\[x^2</p>",
|
|
r"<p>x^2\)</p>",
|
|
'<span class="latex-inline" data-latex="x^2"></span>',
|
|
'<span class="math latex-display extra" data-latex="x^2"></span>',
|
|
]
|
|
findings = [check_latex_html(case) for case in cases]
|
|
rendered = (
|
|
'<span class="latex-inline-svg"><mjx-container>'
|
|
'<svg><text>done</text></svg></mjx-container></span>'
|
|
)
|
|
check("math check rejects raw, unmatched, and legacy formula sources",
|
|
all(findings), repr(findings))
|
|
check("math check accepts rendered MathJax SVG",
|
|
check_latex_html(rendered) == [],
|
|
str(check_latex_html(rendered)))
|
|
|
|
|
|
def test_math_render_replaces_delimiters_and_preserves_other_html() -> None:
|
|
import math_render as math_mod
|
|
|
|
source = r'<p>Before \(x < y\) after.</p><code>\(code\)</code>'
|
|
original_renderer = math_mod._render_svg
|
|
seen = []
|
|
try:
|
|
def fake_renderer(formulas):
|
|
seen.extend(formulas)
|
|
return [
|
|
'<mjx-container><svg role="img"><path d="M0 0"/></svg></mjx-container>'
|
|
for _ in formulas
|
|
]
|
|
math_mod._render_svg = fake_renderer
|
|
rendered = math_mod.render_latex_in_html(source)
|
|
finally:
|
|
math_mod._render_svg = original_renderer
|
|
check("math render decodes entities and sends the standard delimiter source",
|
|
seen == [{"tex": "x < y", "display": False}], repr(seen))
|
|
check("math render replaces only the formula text node",
|
|
"latex-inline-svg" in rendered
|
|
and r"\(x < y\)" not in rendered
|
|
and r"<code>\(code\)</code>" in rendered
|
|
and rendered.startswith("<p>Before "),
|
|
rendered[:500])
|
|
|
|
|
|
def test_math_parser_decodes_entity_delimiters_and_ignores_metadata() -> None:
|
|
from math_render import _latex_spans, check_latex_html
|
|
|
|
source = (
|
|
r"<title>Analysis \(x^2\)</title>"
|
|
r"<p>\(x^2\)</p>"
|
|
)
|
|
spans, issues = _latex_spans(source)
|
|
check("math parser decodes HTML entities before delimiter scanning",
|
|
[(span.tex, span.display) for span in spans] == [("x^2", False)]
|
|
and source[spans[0].start:spans[0].end] == "\(x^2\)",
|
|
f"spans={spans} issues={issues}")
|
|
check("math parser treats title metadata as literal text",
|
|
not issues and not check_latex_html(r"<title>Analysis \(x^2\)</title>"),
|
|
str(issues))
|
|
|
|
|
|
def test_math_parser_fails_closed_on_malformed_ignored_regions() -> None:
|
|
from math_render import check_latex_html
|
|
|
|
malformed = [
|
|
r"<pre>literal </code> \(x^2\)</pre>",
|
|
r'<script/>const formula = "\(x^2\)";',
|
|
r"<pre>literal <p>Formula: \(x^2\)</p>",
|
|
]
|
|
legacy_example = (
|
|
'<pre><code>class="latex-inline"</code></pre>'
|
|
'<!-- class="latex-display" -->'
|
|
)
|
|
check("math parser rejects ambiguous ignored-tag structure",
|
|
all(check_latex_html(source) for source in malformed),
|
|
repr([check_latex_html(source) for source in malformed]))
|
|
check("legacy placeholder examples inside literal regions stay literal",
|
|
check_latex_html(legacy_example) == [],
|
|
str(check_latex_html(legacy_example)))
|
|
|
|
|
|
def test_math_parser_accepts_optional_option_end_tags() -> None:
|
|
from math_render import _latex_spans
|
|
|
|
source = (
|
|
r"<select><option>A<option>B</select>"
|
|
r"<datalist><option>C<option>D</datalist>"
|
|
r"<p>\(x^2\)</p>"
|
|
)
|
|
spans, issues = _latex_spans(source)
|
|
check("math parser follows optional HTML option end-tag rules",
|
|
[(span.tex, span.display) for span in spans] == [("x^2", False)]
|
|
and not issues,
|
|
f"spans={spans} issues={issues}")
|
|
|
|
|
|
def test_math_render_real_runtime_is_strict_and_safe() -> None:
|
|
from math_render import MathRenderError, probe_mathjax, render_latex_in_html
|
|
|
|
status = probe_mathjax()
|
|
if status["status"] != "available":
|
|
skip("MathJax strict and safe end-to-end render",
|
|
status.get("detail", "locked runtime unavailable"),
|
|
ci_required=True)
|
|
return
|
|
|
|
rendered = render_latex_in_html(
|
|
r"<p>Inline \(x^2 + \frac{1}{2}\).</p><p>\[E = mc^2\]</p>"
|
|
)
|
|
safe_words = render_latex_in_html(
|
|
r"<p>\(\text{src = x, href=1}, x < y\)</p>"
|
|
)
|
|
rejected = 0
|
|
for source in (
|
|
r"<p>\(\frac{1\)</p>",
|
|
r"<p>\(\href{javascript:alert(1)}{x}\)</p>",
|
|
r"<p>\(\class{evil}{x}\)</p>",
|
|
r"<p>\(\color{red}{x}\)</p>",
|
|
):
|
|
try:
|
|
render_latex_in_html(source)
|
|
except MathRenderError:
|
|
rejected += 1
|
|
check("locked MathJax renders valid inline and display formulas end to end",
|
|
rendered.count("<mjx-container") == 2
|
|
and "latex-inline-svg" in rendered
|
|
and "latex-display-svg" in rendered,
|
|
rendered[:500])
|
|
check("invalid TeX and author-controlled markup or color fail closed",
|
|
rejected == 4, f"rejected={rejected}")
|
|
check("rendered SVG contains no active link, source, script, or event attribute",
|
|
not re.search(
|
|
r"<\s*(?:script|foreignObject)\b|(?:href|src|on[a-z]+)\s*=",
|
|
rendered,
|
|
re.IGNORECASE,
|
|
),
|
|
rendered[:500])
|
|
check("SVG safety scan parses attributes rather than formula text substrings",
|
|
"<mjx-container" in safe_words,
|
|
safe_words[:500])
|
|
|
|
|
|
def test_math_svg_safety_parser_rejects_malformed_structure() -> None:
|
|
from math_render import _SvgSafetyParser
|
|
|
|
parser = _SvgSafetyParser()
|
|
parser.feed("<mjx-container><svg></mjx-container>")
|
|
parser.close()
|
|
check("SVG safety scan rejects mismatched or unclosed renderer markup",
|
|
parser.malformed or bool(parser._stack),
|
|
f"malformed={parser.malformed} stack={parser._stack}")
|
|
|
|
|
|
def test_math_cli_failure_preserves_source_and_success_is_atomic() -> None:
|
|
from math_render import probe_mathjax
|
|
|
|
status = probe_mathjax()
|
|
if status["status"] != "available":
|
|
skip("MathJax in-place atomic CLI",
|
|
status.get("detail", "locked runtime unavailable"),
|
|
ci_required=True)
|
|
return
|
|
script = SKILL_ROOT / "scripts" / "math_render.py"
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = Path(d) / "filled.html"
|
|
invalid = b"<p>\\(\\frac{1\\)</p>"
|
|
path.write_bytes(invalid)
|
|
path.chmod(0o640)
|
|
failed = subprocess.run(
|
|
[sys.executable, str(script), "--in-place", str(path)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
unchanged = path.read_bytes() == invalid
|
|
path.write_text(r"<p>Energy: \(E = mc^2\).</p>", encoding="utf-8")
|
|
path.chmod(0o640)
|
|
succeeded = subprocess.run(
|
|
[sys.executable, str(script), "--in-place", str(path)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
checked = subprocess.run(
|
|
[sys.executable, str(script), "--check", str(path)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
output = path.read_text(encoding="utf-8")
|
|
mode = stat.S_IMODE(path.stat().st_mode)
|
|
check("failed strict in-place render preserves the original source bytes",
|
|
failed.returncode == 1 and unchanged,
|
|
(failed.stdout + failed.stderr)[:500])
|
|
check("successful in-place render is check-clean and preserves file mode",
|
|
succeeded.returncode == 0
|
|
and checked.returncode == 0
|
|
and "<mjx-container" in output
|
|
and mode == 0o640,
|
|
(succeeded.stdout + checked.stdout + checked.stderr)[:500])
|
|
|
|
|
|
def test_math_render_enforces_formula_count_limit_before_node() -> None:
|
|
from math_render import MathRenderError, _latex_spans, render_latex_in_html
|
|
|
|
source = "<p>" + r"\(x\)" * 50_000 + "</p>"
|
|
tracemalloc.start()
|
|
try:
|
|
render_latex_in_html(source)
|
|
except MathRenderError as exc:
|
|
rejected = "formula safety limit" in str(exc)
|
|
else:
|
|
rejected = False
|
|
_current, peak = tracemalloc.get_traced_memory()
|
|
tracemalloc.stop()
|
|
check("math renderer bounds formula count before allocating every span",
|
|
rejected and peak < 5 * 1024 * 1024,
|
|
f"rejected={rejected} peak={peak} bytes")
|
|
|
|
fragmented = r"<p>x\)</p>" * 10_000
|
|
tracemalloc.start()
|
|
try:
|
|
_latex_spans(fragmented)
|
|
except MathRenderError as exc:
|
|
fragmented_rejected = "TeX delimiters exceed" in str(exc)
|
|
else:
|
|
fragmented_rejected = False
|
|
_current, fragmented_peak = tracemalloc.get_traced_memory()
|
|
tracemalloc.stop()
|
|
check("math renderer shares the delimiter budget across text nodes",
|
|
fragmented_rejected and fragmented_peak < 8 * 1024 * 1024,
|
|
f"rejected={fragmented_rejected} peak={fragmented_peak} bytes")
|
|
|
|
|
|
def test_math_delimiter_scan_is_linear_for_long_backslash_runs() -> None:
|
|
from math_render import _latex_spans
|
|
|
|
source = "<p>" + "\\" * 200_000 + "literal</p>"
|
|
started = time.monotonic()
|
|
spans, issues = _latex_spans(source)
|
|
elapsed = time.monotonic() - started
|
|
check("math delimiter scan stays linear on adversarial backslash runs",
|
|
not spans and not issues and elapsed < 1.0,
|
|
f"elapsed={elapsed:.3f}s spans={len(spans)} issues={issues[:2]}")
|
|
|
|
|
|
def test_math_no_formula_text_avoids_boundary_map_allocation() -> None:
|
|
from math_render import _latex_spans
|
|
|
|
source = "<p>" + "ordinary text " * 100_000 + "</p>"
|
|
tracemalloc.start()
|
|
spans, issues = _latex_spans(source)
|
|
_current, peak = tracemalloc.get_traced_memory()
|
|
tracemalloc.stop()
|
|
check("math parser avoids per-character boundary maps without delimiters",
|
|
not spans and not issues and peak < 8 * 1024 * 1024,
|
|
f"peak={peak} bytes spans={len(spans)} issues={issues[:2]}")
|
|
|
|
source = "<p>" + "a" * 1_000_000 + r"\(x^2\)</p>"
|
|
tracemalloc.start()
|
|
spans, issues = _latex_spans(source)
|
|
_current, tail_peak = tracemalloc.get_traced_memory()
|
|
tracemalloc.stop()
|
|
check("math parser maps only formula boundaries in long text nodes",
|
|
[(span.tex, span.display) for span in spans] == [("x^2", False)]
|
|
and not issues
|
|
and tail_peak < 8 * 1024 * 1024,
|
|
f"peak={tail_peak} bytes spans={len(spans)} issues={issues[:2]}")
|
|
|
|
|
|
def test_mathjax_probe_and_install_ignore_polluted_node_options() -> None:
|
|
from math_render import probe_mathjax
|
|
|
|
status = probe_mathjax()
|
|
if status["status"] != "available":
|
|
skip("MathJax polluted NODE_OPTIONS handling",
|
|
status.get("detail", "locked runtime unavailable"),
|
|
ci_required=True)
|
|
return
|
|
original = os.environ.get("NODE_OPTIONS")
|
|
try:
|
|
os.environ["NODE_OPTIONS"] = "--definitely-invalid-kami-option"
|
|
polluted_probe = probe_mathjax()
|
|
environment = os.environ.copy()
|
|
installed = subprocess.run(
|
|
["bash", str(SKILL_ROOT / "scripts" / "ensure_mathjax.sh")],
|
|
cwd=REPO_ROOT,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
finally:
|
|
if original is None:
|
|
os.environ.pop("NODE_OPTIONS", None)
|
|
else:
|
|
os.environ["NODE_OPTIONS"] = original
|
|
check("doctor probe and installer ignore inherited NODE_OPTIONS",
|
|
polluted_probe["status"] == "available" and installed.returncode == 0,
|
|
f"probe={polluted_probe} install={(installed.stdout + installed.stderr)[:300]}")
|
|
|
|
|
|
def test_mathjax_runtime_rejects_unsupported_node_21() -> None:
|
|
import math_render as math_mod
|
|
|
|
manifest = json.loads(
|
|
(SKILL_ROOT / "scripts" / "mathjax-runtime" / "package.json")
|
|
.read_text(encoding="utf-8")
|
|
)
|
|
with tempfile.TemporaryDirectory() as d:
|
|
fake_bin = Path(d) / "bin"
|
|
fake_bin.mkdir()
|
|
node = fake_bin / "node"
|
|
npm = fake_bin / "npm"
|
|
node.write_text("#!/bin/sh\nprintf '21\\n'\n", encoding="utf-8")
|
|
npm.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
node.chmod(0o755)
|
|
npm.chmod(0o755)
|
|
environment = os.environ.copy()
|
|
environment["PATH"] = f"{fake_bin}:/usr/bin:/bin"
|
|
result = subprocess.run(
|
|
["bash", str(SKILL_ROOT / "scripts" / "ensure_mathjax.sh")],
|
|
cwd=REPO_ROOT,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
original_path = os.environ.get("PATH")
|
|
try:
|
|
os.environ["PATH"] = environment["PATH"]
|
|
math_mod._validated_node.cache_clear()
|
|
probe = math_mod.probe_mathjax()
|
|
finally:
|
|
if original_path is None:
|
|
os.environ.pop("PATH", None)
|
|
else:
|
|
os.environ["PATH"] = original_path
|
|
math_mod._validated_node.cache_clear()
|
|
check("MathJax runtime manifest and installer reject unsupported Node 21",
|
|
manifest.get("engines", {}).get("node") == "20 || >=22"
|
|
and result.returncode == 1
|
|
and "Node.js 20 or Node.js 22+" in result.stderr
|
|
and probe.get("status") == "missing"
|
|
and "Node.js 20 or Node.js 22+" in probe.get("detail", ""),
|
|
f"manifest={manifest.get('engines')} result={result.returncode} "
|
|
f"stderr={result.stderr[:300]} probe={probe}")
|
|
|
|
|
|
def test_mathjax_installer_reclaims_dead_owner_lock() -> None:
|
|
with tempfile.TemporaryDirectory() as d:
|
|
temp_root = Path(d)
|
|
fake_bin = temp_root / "bin"
|
|
fake_bin.mkdir()
|
|
node = fake_bin / "node"
|
|
npm = fake_bin / "npm"
|
|
node.write_text(
|
|
"#!/bin/sh\n"
|
|
"if [ \"$1\" = \"-p\" ]; then\n"
|
|
" case \"$2\" in *process.versions*) printf '22\\n' ;; *) printf '4.1.3\\n' ;; esac\n"
|
|
" exit 0\n"
|
|
"fi\n"
|
|
"if [ \"$1\" = \"-\" ]; then exit 0; fi\n"
|
|
"case \"$*\" in\n"
|
|
" *--probe*) test -d \"$HOME/.cache/kami/mathjax/4.1.3/node_modules\" ;;\n"
|
|
" *) exit 1 ;;\n"
|
|
"esac\n",
|
|
encoding="utf-8",
|
|
)
|
|
npm.write_text(
|
|
"#!/bin/sh\nmkdir -p node_modules\n",
|
|
encoding="utf-8",
|
|
)
|
|
node.chmod(0o755)
|
|
npm.chmod(0o755)
|
|
math_parent = temp_root / ".cache" / "kami" / "mathjax"
|
|
lock = math_parent / ".install-4.1.3.lock"
|
|
stale = math_parent / ".install-4.1.3-stale"
|
|
lock.mkdir(parents=True)
|
|
stale.mkdir()
|
|
(stale / "partial").write_text("partial", encoding="utf-8")
|
|
(lock / "pid").write_text("99999999\n", encoding="utf-8")
|
|
(lock / "staging").write_text(f"{stale}\n", encoding="utf-8")
|
|
environment = os.environ.copy()
|
|
environment["HOME"] = str(temp_root)
|
|
environment["PATH"] = f"{fake_bin}:/usr/bin:/bin"
|
|
environment.pop("XDG_CACHE_HOME", None)
|
|
result = subprocess.run(
|
|
["bash", str(SKILL_ROOT / "scripts" / "ensure_mathjax.sh")],
|
|
cwd=REPO_ROOT,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
target = math_parent / "4.1.3" / "node_modules"
|
|
recovered = (
|
|
result.returncode == 0
|
|
and target.is_dir()
|
|
and not lock.exists()
|
|
and not stale.exists()
|
|
)
|
|
truncated_results = []
|
|
for owner_text in ("", "99999999"):
|
|
shutil.rmtree(math_parent / "4.1.3")
|
|
lock.mkdir()
|
|
(lock / "pid").write_text(owner_text, encoding="utf-8")
|
|
truncated = subprocess.run(
|
|
["bash", str(SKILL_ROOT / "scripts" / "ensure_mathjax.sh")],
|
|
cwd=REPO_ROOT,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
truncated_results.append(
|
|
truncated.returncode == 0
|
|
and target.is_dir()
|
|
and not lock.exists()
|
|
)
|
|
recovered = recovered and all(truncated_results)
|
|
check("MathJax installer reclaims a dead owner's lock and controlled staging",
|
|
recovered,
|
|
f"initial={(result.stdout + result.stderr)[:300]} "
|
|
f"truncated={truncated_results}")
|
|
|
|
|
|
def test_mathjax_cache_survives_weasyprint_runtime_configuration() -> None:
|
|
from math_render import probe_mathjax
|
|
|
|
status = probe_mathjax()
|
|
if status["status"] != "available":
|
|
skip("MathJax cache after WeasyPrint configuration",
|
|
status.get("detail", "locked runtime unavailable"),
|
|
ci_required=True)
|
|
return
|
|
environment = os.environ.copy()
|
|
environment.pop("XDG_CACHE_HOME", None)
|
|
environment.pop("NODE_OPTIONS", None)
|
|
code = (
|
|
"import json, sys;"
|
|
"sys.path.insert(0, 'skills/kami/scripts');"
|
|
"from optional_deps import require_weasyprint_html;"
|
|
"from math_render import probe_mathjax;"
|
|
"require_weasyprint_html();"
|
|
"print(json.dumps(probe_mathjax()))"
|
|
)
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
cwd=REPO_ROOT,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
try:
|
|
configured = json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
configured = {}
|
|
check("WeasyPrint initialization does not redirect the MathJax cache",
|
|
result.returncode == 0 and configured.get("status") == "available",
|
|
(result.stdout + result.stderr)[:500])
|