latexml/util/test.rs
1use std::{path::PathBuf, sync::Once};
2
3use glob::glob;
4use latexml_core::{Core, CoreOptions, common::BindingDispatcher, document::Document, s};
5use once_cell::sync::Lazy;
6
7use crate::core_interface::DigestionAPI;
8
9// Process-once cached env vars (see WISDOM #56 — getenv hot-path race).
10// Sampled at static init; subsequent reads are atomic loads.
11static TEST_LOG: Lazy<bool> = Lazy::new(|| std::env::var("LATEXML_TEST_LOG").is_ok());
12static SIGSEGV_TRACE: Lazy<bool> = Lazy::new(|| std::env::var("LATEXML_SIGSEGV_TRACE").is_ok());
13static SAVE_ACTUAL: Lazy<bool> = Lazy::new(|| std::env::var("LATEXML_SAVE_ACTUAL").is_ok());
14/// "Bless" / regenerate mode — the Rust equivalent of Perl's `tools/maketests`.
15/// When `LATEXML_BLESS=1`, a `…_ok` test writes the ACTUAL conversion output to
16/// its golden `.xml` (overwriting it) instead of comparing+asserting. Run via
17/// `tools/maketests.sh` (optionally with a test-name filter). Because it reuses
18/// the exact harness conversion + serialization (`process_texfile`), the
19/// regenerated golden is byte-identical to what the comparison expects.
20static BLESS: Lazy<bool> = Lazy::new(|| std::env::var("LATEXML_BLESS").is_ok());
21
22pub fn latexml_tests(
23 dirpath: &str,
24 requires: Option<&phf::Map<&str, &str>>,
25 dispatcher_opt: Option<BindingDispatcher>,
26) {
27 latexml_tests_internal(dirpath, requires, dispatcher_opt)
28}
29pub fn latexml_tests_internal(
30 dirpath: &str,
31 requires: Option<&phf::Map<&str, &str>>,
32 dispatcher_opt: Option<BindingDispatcher>,
33) {
34 if !validate_requirements(dirpath, requires) {
35 return; // test group only if required files are found.
36 }
37 for tex_file in glob(&s!("{}/*.tex", dirpath)).unwrap().flatten() {
38 let name = tex_file.file_stem().unwrap().to_str().unwrap();
39 let xml_file = tex_file.with_extension("xml");
40
41 let tex_file_string = tex_file.to_str().unwrap();
42 let xml_file_str = xml_file.to_str().unwrap();
43 if xml_file.exists() {
44 latexml_ok_internal(tex_file_string, xml_file_str, name, dispatcher_opt.clone());
45 } else {
46 // Skip, these could be tex fragment files.
47 }
48 }
49}
50
51static INIT_LOGGER: Once = Once::new();
52pub fn init_logger() {
53 INIT_LOGGER.call_once(|| {
54 // Use Off level for clean test output. Error/Warn counting still works
55 // via note_status(); set LATEXML_TEST_LOG=1 to see warnings during debugging.
56 let level = if *TEST_LOG {
57 log::LevelFilter::Warn
58 } else {
59 log::LevelFilter::Off
60 };
61 latexml_core::util::logger::init(level).unwrap();
62 });
63}
64
65static INIT_TEST_RSS_CAP: Once = Once::new();
66/// Raise the per-process RSS fuse for the multi-conversion test harness.
67///
68/// `latexml_core::stomach`'s memory budget defaults to **4.5 GB**, sized to
69/// bound a *single* conversion — that low default is load-bearing in production,
70/// where a massively parallel fleet runs many one-paper processes at once and
71/// the aggregate host RSS is `N × cap` (raising it would OOM the machine).
72///
73/// `cargo test` is the one place that runs many conversions in ONE process:
74/// libtest spawns a thread per test, so at high parallelism (e.g. `-j128` on a
75/// many-core box) the process-wide RSS is the *sum* of all in-flight
76/// conversions and trips the single-conversion fuse on otherwise-fine
77/// documents (a false `MemoryBudget` cascade on article/book/report …). So the
78/// harness — not the production default — raises the cap, once, here. An
79/// explicit `LATEXML_RSS_CAP_BYTES` (or `--test-threads=N`) still wins.
80///
81/// `pub` so hand-written integration suites (e.g. `06_cluster_regressions.rs`)
82/// that drive `Converter` directly — bypassing `latexml_test_single` — can opt
83/// into the same raised cap. Otherwise their conversions run under the low
84/// production default and trip a false `MemoryBudget` cascade at
85/// `--test-threads=2` once the file accumulates enough in-flight RSS.
86pub fn init_test_rss_cap() {
87 INIT_TEST_RSS_CAP.call_once(|| {
88 if std::env::var_os("LATEXML_RSS_CAP_BYTES").is_none() {
89 // SAFETY: set exactly once under `Once`, at the very top of every
90 // generated test (before any conversion thread reads the env in
91 // `stomach::check_timeout`). `Once`'s release/acquire ordering
92 // happens-before all those reads, so there is no setenv/getenv race.
93 unsafe {
94 std::env::set_var("LATEXML_RSS_CAP_BYTES", "9000000000");
95 }
96 }
97 });
98}
99
100// Linker-section trick: register a SIGSEGV handler via `.init_array` so
101// it runs BEFORE `main()` (and therefore before any thread the test
102// harness spawns can crash). `init_logger` was too late — by the time
103// the first test thread called it, sibling threads had already crashed.
104//
105// Gated by `LATEXML_SIGSEGV_TRACE` so the handler is opt-in; signal()
106// + std::backtrace inside a SIGSEGV handler is technically not async-
107// signal-safe (uses heap), but for diagnostic purposes a best-effort
108// stack dump is far more useful than the bare `signal: 11` line cargo
109// reports today.
110#[used]
111#[cfg_attr(target_os = "linux", unsafe(link_section = ".init_array"))]
112static SIGSEGV_INSTALLER: extern "C" fn() = sigsegv_installer;
113
114extern "C" fn sigsegv_installer() {
115 // Read env at process start. SAFETY: nothing has run yet, env is
116 // populated by the kernel/loader.
117 if *SIGSEGV_TRACE {
118 eprintln!("[sigsegv_installer] installing SIGSEGV handler");
119 install_sigsegv_handler();
120 }
121}
122
123/// Install a SIGSEGV handler that prints the crashing thread's name and a
124/// best-effort backtrace before the kernel terminates the process. This
125/// is a diagnostic-only hook — the handler is not async-signal-safe (it
126/// uses `std::backtrace` and `eprintln!`, which malloc), but for
127/// post-mortem of the libxml2-suspected multi-thread crash that's
128/// observed under `cargo test --release --tests`, even an unsafe
129/// stack print is more useful than the bare `signal: 11` line cargo
130/// reports today.
131fn install_sigsegv_handler() {
132 // SAFETY: declares the libc `signal(2)`/`raise(3)` FFI bindings for this
133 // test-only crash-backtrace handler. The signatures match libc's
134 // (`sighandler_t` is a `usize`-wide fn pointer here; `raise` returns int);
135 // calls below uphold the platform contract.
136 unsafe extern "C" {
137 fn signal(sig: i32, handler: extern "C" fn(i32)) -> usize;
138 fn raise(sig: i32) -> i32;
139 }
140 // Linux SIGSEGV = 11, SIGBUS = 7, SIGABRT = 6.
141 const SIGSEGV: i32 = 11;
142 const SIGBUS: i32 = 7;
143 const SIGABRT: i32 = 6;
144 const SIG_DFL: usize = 0;
145
146 extern "C" fn handler(sig: i32) {
147 // Capture context synchronously and persist to a per-pid file —
148 // cargo test buffers/discards stderr from binaries that exit by
149 // signal, so eprintln!() never reaches the user. Writing to
150 // `<temp_dir>/latexml_sigsegv_<pid>.txt` survives the kill.
151 let tid = std::thread::current().id();
152 let name = std::thread::current()
153 .name()
154 .unwrap_or("<unnamed>")
155 .to_string();
156 let pid = std::process::id();
157 let path = std::env::temp_dir()
158 .join(format!("latexml_sigsegv_{pid}.txt"))
159 .display()
160 .to_string();
161 let bt = std::backtrace::Backtrace::force_capture();
162 let exe = std::env::current_exe()
163 .map(|p| p.display().to_string())
164 .unwrap_or_else(|_| "<unknown>".into());
165 let body = format!(
166 "=== SIGSEGV-handler ===\nsignal={sig}\nthread={name:?}\nid={tid:?}\nexe={exe}\npid={pid}\n\n{bt}\n"
167 );
168 let _ = std::fs::write(&path, &body);
169 // Also try eprintln (best effort; usually lost by cargo on signal).
170 eprintln!("{body}");
171 eprintln!("[SIGSEGV-handler] full trace written to {path}");
172 // Reset to default and re-raise so cargo still sees the original signal.
173 // SAFETY: test-only crash-backtrace handler (not async-signal-safe by
174 // design — see the fn docs). `transmute(SIG_DFL)` reinterprets the
175 // null/0 SIG_DFL sentinel as the `sighandler_t`-shaped fn pointer libc
176 // expects, restoring the default disposition; `signal`/`raise` then
177 // re-raise `sig` on the current thread so cargo observes the original
178 // fatal signal.
179 unsafe {
180 let raw_dfl: extern "C" fn(i32) = std::mem::transmute(SIG_DFL);
181 signal(sig, raw_dfl);
182 raise(sig);
183 }
184 }
185
186 // SAFETY: installs the test-only `handler` as the disposition for SIGSEGV/
187 // SIGBUS/SIGABRT via libc `signal(2)`. `handler` is a valid `extern "C"
188 // fn(i32)` matching the expected `sighandler_t`; it is intentionally
189 // limited to (mostly) async-signal-safe work — see the fn docs for the
190 // accepted best-effort/heap caveat.
191 unsafe {
192 signal(SIGSEGV, handler);
193 signal(SIGBUS, handler);
194 signal(SIGABRT, handler);
195 }
196}
197
198/// **Intentionally-failing tests** — a *permanent contract* that this input
199/// SHOULD produce errors. The TeX is genuinely ill-formed / pathological, so
200/// erroring is the CORRECT, desired outcome forever — NOT something to "fix".
201/// (The discriminator is the input's validity, NOT whether Perl also errors:
202/// see `ERROR_DEBT` for valid inputs that merely error today.)
203///
204/// The contract is a SOFT, RECOVERABLE error: the harness asserts the **exact**
205/// `Error:` count AND that there was **no `Fatal:`** — the whole point is that
206/// the engine recovers and completes the conversion (graceful degradation),
207/// never crashes. Drift fails BOTH ways: *more*/fatal = a handling regression;
208/// *zero* = we silently STOPPED detecting the bad input. Logged
209/// `[intentional-fail]`.
210const INTENTIONALLY_FAILING: &[(&str, usize, &str)] = &[
211 (
212 "protect_self_ref",
213 1,
214 "intrinsic self-recursion (\\def\\cs{\\protect\\cs} typeset): pdflatex HANGS, \
215 Perl+Rust both emit 1 SOFT error — the recursion guard prevents the hang. \
216 Verified 2026-06-10 (latexml --verbose=1 error, pdflatex=timeout).",
217 ),
218 (
219 "io",
220 2,
221 "deliberate malformed read content: `exists.data` line 21 has an unbalanced \
222 brace (`line { with extra } }`) to verify the engine emits a SOFT, \
223 recoverable error (not Fatal) and completes. pdflatex also errors+recovers \
224 (`! Too many }'s, silently discards }`); Perl+Rust both emit 2 soft errors. \
225 Verified 2026-06-10.",
226 ),
227 (
228 "undefined_env",
229 1,
230 "undefined environment `\\begin{undefinedenv}`: genuinely erroneous (missing \
231 defining package). Both Perl+Rust emit 1 SOFT error AND a visible \
232 `<ltx:ERROR class='undefined'>{undefinedenv}</ltx:ERROR>` marker (Perl \
233 `makeError`, latex_constructs.pool.ltxml:207-208). Guards the fix where Rust \
234 formerly dropped the ERROR element (no-op trigger). Verified vs \
235 /usr/local/bin/latexml.",
236 ),
237];
238
239/// **Error debt** — valid input we INTEND to convert cleanly (a desired,
240/// surpass-Perl success), but which errors today. TEMPORARY: each MUST be
241/// driven to zero by improving the Rust core, then removed. The harness
242/// tolerates ANY count (logged `[error-debt]`) and does NOT fail at zero,
243/// because the count is **environment-dependent** for some entries (e.g.
244/// `glossary` errors on one host's datatool/expl3 but converts clean in CI) —
245/// failing at zero would break whichever environment is already clean. When an
246/// entry's `[error-debt] … 0 errors` shows up EVERYWHERE, remove it by review.
247/// Each note records Perl's current behavior (verify with `latexml --verbose`
248/// — `--quiet` HIDES Perl errors). Tracked in `docs/SYNC_STATUS.md`.
249///
250/// Currently EMPTY: the last entry (`figure_mixed_content` —
251/// `ltx:theorem`/`ltx:proof` not allowed in `ltx:figure`/`ltx:table`/`ltx:float`)
252/// was drained 2026-06-27 by the schema expansion in
253/// `resources/RelaxNG/LaTeXML.model` + `LaTeXML-para.{rng,rnc}` (a boxed theorem/
254/// proof inside a float is valid LaTeX; both engines previously rejected it). The
255/// fix is output-neutral — the builder already placed the theorem inside the
256/// figure, so only the spurious malformed-error is gone (XML byte-identical).
257const ERROR_DEBT: &[(&str, &str)] = &[];
258
259/// Emit a line to the process's REAL stderr, SURVIVING libtest's per-test
260/// output capture. libtest only intercepts the `print!`/`eprint!` macros and
261/// replays them solely on FAILURE, so a plain `eprintln!` from a PASSING test
262/// is swallowed — which defeats the `[error-debt] … review for removal` and
263/// `[intentional-fail]` notices, whose entire purpose is to be SEEN on a green
264/// run (review m2). A direct `write(2)` bypasses the capture. One syscall per
265/// line is atomic up to PIPE_BUF, so concurrent test threads don't interleave.
266#[cfg(unix)]
267fn note_uncaptured(line: &str) {
268 use std::{io::Write, os::unix::io::FromRawFd};
269 // SAFETY: fd 2 is the process stderr, valid for the whole run. `ManuallyDrop`
270 // stops the `File`'s Drop from `close()`-ing the shared descriptor.
271 let mut f = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(2) });
272 let _ = f.write_all(format!("{line}\n").as_bytes());
273}
274#[cfg(not(unix))]
275fn note_uncaptured(line: &str) {
276 eprintln!("{line}");
277}
278
279pub fn latexml_test_single(
280 tex_file_str: &str,
281 name: &str,
282 dirpath: &str,
283 requires: Option<&phf::Map<&str, &str>>,
284 dispatcher_opt: Option<BindingDispatcher>,
285) {
286 init_logger();
287 init_test_rss_cap();
288 if !validate_requirements(dirpath, requires) {
289 return; // test group only if required files are found.
290 }
291 // Platform-skipped golden fixtures: kept live (and compared) on the
292 // platforms whose TeX distribution matches the committed golden, but
293 // skipped where an UNPINNABLE upstream package version differs. This is
294 // a Linux↔Windows portability difference, not a code divergence — the
295 // engine faithfully renders whatever the package emits. See SYNC_STATUS.md.
296 #[cfg(windows)]
297 {
298 // circuitikz ≥ 1.8.0 lengthens drawn capacitor plates (12.4 → 12.68 in
299 // our SVG space). The Windows TeX (setup-texlive net-install / a fresh
300 // `install-tl`) ships the NEWEST circuitikz; Linux/macOS apt/brew ship
301 // an older one that matches the golden. circuitikz can't be version-
302 // pinned in the fixture (Perl/Rust both version-strip the request), so
303 // skip on Windows only; Linux + macOS still run and compare it.
304 const WINDOWS_GOLDEN_SKIP: &[&str] = &["ac-drive-components"];
305 if WINDOWS_GOLDEN_SKIP.contains(&name) {
306 eprintln!(
307 "SKIP (Windows): {name} — circuitikz-version-nondeterministic golden; \
308 kept live on Linux/macOS. See docs/SYNC_STATUS.md."
309 );
310 return;
311 }
312 }
313 // Suppress log output for any test expected to emit errors (both categories)
314 // so single-test runs stay readable; the gate still counts + classifies them.
315 let suppress = INTENTIONALLY_FAILING.iter().any(|(n, ..)| *n == name)
316 || ERROR_DEBT.iter().any(|(n, _)| *n == name);
317 if suppress {
318 latexml_core::common::error::set_suppress_log_output(true);
319 }
320 let tex_file = PathBuf::from(tex_file_str);
321 let xml_file = tex_file.with_extension("xml");
322 if matches!(xml_file.try_exists(), Ok(true)) {
323 latexml_ok_internal(
324 tex_file_str,
325 &xml_file.to_string_lossy(),
326 name,
327 dispatcher_opt,
328 );
329 } else {
330 // Skip, these could be tex fragment files.
331 }
332 if suppress {
333 latexml_core::common::error::set_suppress_log_output(false);
334 }
335}
336
337fn validate_requirements(_dirpath: &str, _requires: Option<&phf::Map<&str, &str>>) -> bool {
338 // TODO
339 true
340}
341
342// fn latexml_ok(tex_path: &str, xml_path: &str, name: &str) { latexml_ok_internal(tex_path,
343// xml_path, name, None) }
344
345fn latexml_ok_internal(
346 tex_path: &str,
347 xml_path: &str,
348 name: &str,
349 extra_bindings_dispatcher: Option<BindingDispatcher>,
350) {
351 let tex_strings = process_texfile(tex_path, name, extra_bindings_dispatcher);
352 // Bless / regenerate mode (Perl `tools/maketests` equivalent): overwrite the
353 // golden with the actual output instead of comparing. Git is the backup.
354 if *BLESS {
355 if tex_strings.is_empty() {
356 eprintln!("BLESS skip {name:?}: conversion produced no output (not overwriting {xml_path})");
357 return;
358 }
359 let body = format!("{}\n", tex_strings.join("\n"));
360 match std::fs::write(xml_path, &body) {
361 Ok(()) => eprintln!("BLESS wrote {xml_path} ({} lines)", tex_strings.len()),
362 Err(e) => eprintln!("BLESS FAILED to write {xml_path}: {e}"),
363 }
364 return;
365 }
366 if !tex_strings.is_empty() {
367 let xml_strings = process_xmlfile(xml_path, name);
368 if !xml_strings.is_empty() {
369 let mut found_diff = false;
370 for (lineno, (tex_line, xml_line)) in tex_strings.iter().zip(xml_strings.iter()).enumerate() {
371 if tex_line != xml_line {
372 found_diff = true;
373 eprintln!(
374 "DIFF line {lineno} in {xml_path}:\n ACTUAL: {tex_line}\n EXPECTED: {xml_line}"
375 );
376 }
377 }
378 if tex_strings.len() != xml_strings.len() {
379 found_diff = true;
380 eprintln!(
381 "DIFF length mismatch for {name:?}: actual {} lines, expected {} lines",
382 tex_strings.len(),
383 xml_strings.len()
384 );
385 // Print extra lines
386 let min_len = tex_strings.len().min(xml_strings.len());
387 if tex_strings.len() > min_len {
388 for (i, line) in tex_strings[min_len..].iter().enumerate() {
389 eprintln!(" ACTUAL extra line {}: {line}", min_len + i);
390 }
391 }
392 if xml_strings.len() > min_len {
393 for (i, line) in xml_strings[min_len..].iter().enumerate() {
394 eprintln!(" EXPECTED extra line {}: {line}", min_len + i);
395 }
396 }
397 }
398 if found_diff {
399 panic!("Differences found in {xml_path} — see DIFF lines above");
400 }
401 }
402 }
403}
404
405/// Returns the list-of-strings form of whatever was requested, if successful,
406/// otherwise empty; and they will have reported the failure
407fn process_texfile(
408 tex_path: &str,
409 name: &str,
410 extra_bindings_dispatcher: Option<BindingDispatcher>,
411) -> Vec<String> {
412 let mut latexml = Core::new(CoreOptions {
413 verbosity: Some(-2),
414 search_paths: None,
415 preload: None,
416 include_comments: Some(false),
417 ..CoreOptions::default()
418 });
419 // Install the SAME binding-resolution priority chain a real conversion uses
420 // (rhai > extra/contrib > package), via the shared helper. This is what lets a
421 // test resolve a local `<pkg>.<ext>.rhai` fixture sitting next to its `.tex`
422 // (found through the source-dir search path) — exactly as the Perl suite
423 // resolves a local `<pkg>.<ext>.ltxml`. The `extra` dispatcher (passed by test
424 // groups that still rely on compiled `latexml_contrib` fixtures) is folded in
425 // as tier 2.
426 crate::converter::install_binding_dispatch(extra_bindings_dispatcher);
427 let r = match latexml.convert_file(tex_path.to_owned()) {
428 Err(e) => panic!("{:?}: Couldn't convert {:?}; {:?}", name, tex_path, e),
429 Ok(doc) => process_ltx_doc(doc, name),
430 };
431 // Drop the engine, then free this thread's accumulated thread-local
432 // state. libtest spawns a fresh thread per test, and the engine's
433 // roots are `#[thread_local]` *attribute* statics, which do NOT run
434 // destructors on thread exit — so without this each test would leak
435 // its ~110 MB engine, accumulating to ~4.9 GB across the suite. The
436 // output is already owned `String`s by now, so no live `SymStr`
437 // survives the reset. See `latexml_core::reset_thread_engine`.
438 // Error gate: every `.tex`/`.xml` regression test is an error-regression
439 // sentinel, not just an XML-shape check. `note_status` counts `Error:`/
440 // `Fatal:` even when log output is off, so this is the canonical signal.
441 // Three contracts:
442 // • normal test → MUST be error-clean (n_err == 0).
443 // • INTENTIONALLY_FAILING → MUST emit its exact SOFT count, NEVER fatal
444 // (graceful recovery is the contract; permanent).
445 // • ERROR_DEBT → tolerated (count is env-dependent for some);
446 // logged, never fails; manual review for removal.
447 //
448 // Perf (runs on every test): the hot path is two thread-local integer reads
449 // via `get_status` (no allocation, no log-string scan) plus tiny slice
450 // lookups; messages build only on the cold panic path. `convert_file` reset
451 // the report at conversion start, so this count is exactly this conversion's.
452 // Read BEFORE `reset_thread_engine`.
453 use latexml_core::common::error::{LogStatus, get_status};
454 let n_soft = get_status(LogStatus::Error);
455 let n_fatal = get_status(LogStatus::Fatal);
456 let n_err = n_soft + n_fatal;
457 let intentional = INTENTIONALLY_FAILING
458 .iter()
459 .find(|(n, ..)| *n == name)
460 .copied();
461 let debt = ERROR_DEBT.iter().find(|(n, _)| *n == name).copied();
462 // Decide the verdict (and any cold-path message) before tearing down.
463 let verdict: Result<(), String> = match (intentional, debt) {
464 // Permanent contract: exact SOFT-error count, and NEVER fatal — the point is
465 // graceful recovery. Drift fails both ways; a Fatal is always a regression.
466 (Some((_, expect, reason)), _) => {
467 note_uncaptured(&format!(
468 "[intentional-fail] {name}: {n_soft} soft errors, {n_fatal} fatal (expect {expect}, 0) — {reason}"
469 ));
470 if n_fatal > 0 {
471 Err(format!(
472 "{name}: INTENTIONALLY_FAILING must degrade to a SOFT error, but got a Fatal \
473 ({}) — graceful recovery regressed. Reason: {reason}",
474 latexml_core::common::error::get_status_message()
475 ))
476 } else if n_soft == expect {
477 Ok(())
478 } else if n_soft == 0 {
479 Err(format!(
480 "{name}: INTENTIONALLY_FAILING expects {expect} error(s) but got 0 — error \
481 detection regressed (this input must still error). Reason: {reason}"
482 ))
483 } else {
484 Err(format!(
485 "{name}: INTENTIONALLY_FAILING expects exactly {expect} soft error(s), got {n_soft} \
486 ({}) — handling drifted. Reason: {reason}",
487 latexml_core::common::error::get_status_message()
488 ))
489 }
490 },
491 // Temporary debt: tolerate whatever it does today (the count is
492 // environment-dependent for some entries — e.g. `glossary` errors on one
493 // box but converts clean in CI, per the host's datatool/expl3 version), so
494 // the gate does NOT fail at zero. Removal is a manual review step when an
495 // entry is clean EVERYWHERE (the `[error-debt] … 0 errors` log flags it).
496 (None, Some((_, reason))) => {
497 if n_err == 0 {
498 note_uncaptured(&format!(
499 "[error-debt] {name}: 0 errors HERE — clean in this \
500 environment; review for removal once clean everywhere — {reason}"
501 ));
502 } else {
503 note_uncaptured(&format!("[error-debt] {name}: {n_err} errors — {reason}"));
504 }
505 Ok(())
506 },
507 // Normal test: must be error-clean.
508 (None, None) => {
509 if n_err == 0 {
510 Ok(())
511 } else {
512 Err(format!(
513 "{name}: conversion logged errors ({}) — a normal-TeX test must be error-clean. \
514 Fix the engine/binding/specimen. If the input SHOULD error (verify with \
515 bin/latexml --verbose), add to INTENTIONALLY_FAILING; if it should convert \
516 clean but doesn't yet, add to ERROR_DEBT with a SYNC_STATUS entry. See \
517 docs/reproducers/MALFORMED_CLOSE_NUMBERED_2026-06-10.md.",
518 latexml_core::common::error::get_status_message()
519 ))
520 }
521 },
522 };
523 drop(latexml);
524 latexml_core::reset_thread_engine();
525 if let Err(msg) = verdict {
526 panic!("{msg}");
527 }
528 r
529}
530
531/// Loads the reference XML file as raw text lines, avoiding libxml2
532/// re-serialization which would normalize `<p></p>` to `<p/>`.
533fn process_xmlfile<'a>(xml_path: &'a str, _name: &'a str) -> Vec<String> {
534 match std::fs::read_to_string(xml_path) {
535 Err(e) => panic!("Failed to read XML file {:?}: {:?}", xml_path, e),
536 Ok(contents) => {
537 let mut lines: Vec<String> = contents.split('\n').map(ToString::to_string).collect();
538 // Remove trailing empty line from final newline
539 if lines.last().is_some_and(|l| l.is_empty()) {
540 lines.pop();
541 }
542 lines
543 },
544 }
545}
546fn process_ltx_doc(doc: Document, name: &str) -> Vec<String> {
547 let doc_str = doc.serialize_to_string();
548 if *SAVE_ACTUAL {
549 let tmp = std::env::temp_dir();
550 let path = tmp
551 .join(format!("latexml_actual_{name}.xml"))
552 .display()
553 .to_string();
554 std::fs::write(&path, &doc_str).ok();
555 eprintln!("Saved actual XML to {path}");
556 // Also save using libxml's built-in serializer for comparison
557 let path2 = tmp
558 .join(format!("latexml_actual_{name}_libxml.xml"))
559 .display()
560 .to_string();
561 let libxml_str = doc
562 .document
563 .to_string_with_options(libxml::tree::SaveOptions {
564 format: true,
565 ..libxml::tree::SaveOptions::default()
566 });
567 std::fs::write(&path2, &libxml_str).ok();
568 eprintln!("Saved libxml XML to {path2}");
569 }
570 let mut lines: Vec<String> = doc_str.split('\n').map(ToString::to_string).collect();
571 // Remove trailing empty line from final newline
572 if lines.last().is_some_and(|l| l.is_empty()) {
573 lines.pop();
574 }
575 lines
576}
577
578// `new_test_engine` and `lex_single_tex_formula` moved to
579// `crate::util::preset` (audit DEP-02, 2026-05-18). They have no
580// dependency on `glob`/`phf` and need to be callable from the
581// `latexmlmath_oxide` production binary, which builds without the
582// `test-utils` feature. Re-exported here so the dominant
583// `use latexml::util::test::*;` pattern in integration tests
584// continues to work unchanged.
585pub use super::preset::{lex_single_tex_formula, new_test_engine};
586
587/// Build a test function for each "*.tex" source found in a given directory path.
588/// The path should be absolute, or relative to the root latexml-oxide checkout.
589#[macro_export]
590macro_rules! tex_tests {
591 ($dir:literal) => {
592 tex_tests!($dir, None, None);
593 };
594 ($dir:literal, $requires:expr_2021, $dispatch:expr_2021) => {
595 macro_rules! this_test_requires {
596 () => {
597 $requires
598 };
599 }
600 macro_rules! this_test_dispatch {
601 () => {
602 $dispatch
603 };
604 }
605 use latexml_codegen::GlobTeXTests;
606 #[derive(GlobTeXTests)]
607 #[directory=$dir]
608 struct _TestDirective;
609 };
610}
611
612// ======================================================================
613// Shared helpers for the standalone integration tests (PR #249 review P3-10).
614// The lax error grep, the dump/kpsewhich gates, and the converter
615// boilerplate previously lived as per-test-file copies — a drift hazard for
616// the project's #1 signal-integrity rule (robust error-log counting).
617
618/// Count inline `Error:<class>:` markers (parity_check.sh's lax pattern, see
619/// feedback_strict_vs_lax_error_grep.md). Errors are emitted INLINE within
620/// `(Building...Error:..)` envelopes, not at line starts.
621pub fn error_count(log: &str) -> usize {
622 log
623 .match_indices("Error:")
624 .filter(|(i, _)| {
625 let tail = &log.as_bytes()[*i + 6..];
626 let n_class = tail.iter().take_while(|b| b.is_ascii_lowercase()).count();
627 n_class > 0 && tail.get(n_class) == Some(&b':')
628 })
629 .count()
630}
631
632/// True iff a year-versioned latex kernel dump is present in the dev tree.
633/// Without it the engine raw-loads `expl3-code.tex` (degraded mode) and the
634/// error landscape is dominated by unrelated raw-load cascades — dump-gated
635/// tests should SKIP rather than measure the wrong thing. Delegates to the
636/// engine's own dump-name convention so a filename-scheme change cannot make
637/// the tests silently self-skip forever.
638pub fn dump_available() -> bool {
639 let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../resources/dumps"));
640 !latexml_engine::dump_paths::available_years_in_dir(dir, "latex").is_empty()
641}
642
643/// True iff `kpsewhich` resolves the named file in the host TeX tree.
644pub fn kpse_has(file: &str) -> bool {
645 std::process::Command::new("kpsewhich")
646 .arg(file)
647 .output()
648 .map(|o| o.status.success() && !o.stdout.is_empty())
649 .unwrap_or(false)
650}
651
652/// Convert a test fixture with the standard HTML5 config and return the full
653/// response (result/log/status). The shared boilerplate for the standalone
654/// regression tests.
655pub fn convert_fixture(source: &str) -> crate::converter::ConversionResponse {
656 init_test_rss_cap();
657 let _ = latexml_core::util::logger::init(log::LevelFilter::Warn);
658 let cfg = latexml_core::common::Config {
659 format: latexml_core::common::OutputFormat::HTML5,
660 ..latexml_core::common::Config::default()
661 };
662 let mut c = crate::converter::Converter::from_config(cfg);
663 c.initialize_session().expect("initialize");
664 c.convert(source.to_string())
665}
666
667#[cfg(test)]
668mod exemption_audit {
669 use std::path::{Path, PathBuf};
670
671 use super::{ERROR_DEBT, INTENTIONALLY_FAILING};
672
673 /// Recursively collect `(file_stem, path)` for every `.tex` fixture under `dir`.
674 /// A missing/unreadable `dir` yields nothing (not an error): the caller
675 /// interprets "no fixtures" as "no corpus to audit".
676 fn collect_tex_stems(dir: &Path, out: &mut Vec<(String, PathBuf)>) {
677 let Ok(rd) = std::fs::read_dir(dir) else {
678 return;
679 };
680 for ent in rd.flatten() {
681 let p = ent.path();
682 if p.is_dir() {
683 collect_tex_stems(&p, out);
684 } else if p.extension().and_then(|e| e.to_str()) == Some("tex")
685 && let Some(stem) = p.file_stem().and_then(|s| s.to_str())
686 {
687 out.push((stem.to_string(), p.clone()));
688 }
689 }
690 }
691
692 /// Return every exemption key that matches MORE THAN ONE of the collected
693 /// `(file_stem, path)` fixtures — the collision the audit exists to catch.
694 /// Pure (no I/O), so it can be exercised on a synthetic in-memory corpus.
695 fn find_stem_collisions(all: &[(String, PathBuf)]) -> Vec<(String, Vec<PathBuf>)> {
696 let keys = INTENTIONALLY_FAILING
697 .iter()
698 .map(|(n, ..)| *n)
699 .chain(ERROR_DEBT.iter().map(|(n, _)| *n));
700 let mut collisions = Vec::new();
701 for key in keys {
702 let hits: Vec<PathBuf> = all
703 .iter()
704 .filter(|(s, _)| s == key)
705 .map(|(_, p)| p.clone())
706 .collect();
707 if hits.len() > 1 {
708 collisions.push((key.to_string(), hits));
709 }
710 }
711 collisions
712 }
713
714 /// Audit the exemption tables against the `.tex` corpus rooted at `tests_dir`,
715 /// returning every exemption key that matches MORE THAN ONE fixture.
716 ///
717 /// Returns `None` when `tests_dir` holds no corpus at all — kept distinct from
718 /// `Some(vec![])` (corpus present, no collisions). The published `latexml`
719 /// crate EXCLUDES `tests/` (Cargo.toml `exclude`, to fit crates.io's 10 MiB
720 /// cap), so a downstream `cargo test` on the packaged crate legitimately has
721 /// nothing to audit: that is a skip, not a failure (issue #301).
722 fn exemption_stem_collisions(tests_dir: &Path) -> Option<Vec<(String, Vec<PathBuf>)>> {
723 let mut all = Vec::new();
724 collect_tex_stems(tests_dir, &mut all);
725 if all.is_empty() {
726 return None;
727 }
728 Some(find_stem_collisions(&all))
729 }
730
731 /// Review m1: the exemption tables match on the bare `file_stem`, which is
732 /// NOT unique across the suite's globbed test dirs. A future `glossary.tex`
733 /// (etc.) under a different directory would silently inherit the
734 /// ERROR_DEBT / INTENTIONALLY_FAILING exemption — an ERROR_DEBT collision
735 /// could then MASK a real regression. Guard the invariant the match relies
736 /// on: every exemption key resolves to AT MOST ONE `.tex` across `tests/`.
737 /// (Cheaper and lower-churn than dir-qualifying the keys; if this ever fails,
738 /// rename the fixture or dir-qualify that entry.)
739 #[test]
740 fn exemption_keys_have_unique_stems() {
741 // Locate the corpus via CARGO_MANIFEST_DIR, NOT a CWD-relative path: the scan
742 // must not depend on the test binary's working directory, and the packaged
743 // crate (which excludes `tests/`) must skip cleanly instead of tripping a
744 // "wrong CWD" assert (issue #301).
745 let tests_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests");
746 let Some(collisions) = exemption_stem_collisions(&tests_dir) else {
747 // No corpus on disk — packaged crate. Nothing to audit.
748 return;
749 };
750 assert!(
751 collisions.is_empty(),
752 "exemption keys match multiple .tex fixtures {collisions:?} — the bare-stem \
753 match would apply the exemption to ALL of them, potentially masking a \
754 regression. Dir-qualify the entry or rename the fixture."
755 );
756 }
757
758 /// Regression guard for issue #301: the published crate EXCLUDES `tests/`, so
759 /// the audit must treat a missing corpus as "nothing to check" (`None`) and the
760 /// caller must skip — never panic. Before the fix the audit asserted on the
761 /// empty scan, blowing up a downstream `cargo test` on the packaged crate.
762 #[test]
763 fn audit_skips_when_corpus_absent() {
764 let absent = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests-this-dir-does-not-exist");
765 assert!(
766 exemption_stem_collisions(&absent).is_none(),
767 "an absent corpus must yield None (skip), not a spurious audit result"
768 );
769 }
770
771 /// The audit still FIRES on a real duplicate-stem collision, exercised on an
772 /// IN-MEMORY corpus so the check is deterministic, independent of the live
773 /// exemption tables staying collision-free, and needs no filesystem at all —
774 /// not even a writable temp dir, so it survives the same hostile
775 /// packaged/sandboxed environments as the rest of the suite (issue #301). Two
776 /// fixtures under different dirs share a real exemption key; the bare-stem
777 /// match must flag it.
778 #[test]
779 fn audit_detects_duplicate_stems() {
780 let key = INTENTIONALLY_FAILING
781 .iter()
782 .map(|(n, ..)| *n)
783 .chain(ERROR_DEBT.iter().map(|(n, _)| *n))
784 .next()
785 .expect("at least one exemption key");
786 let all = vec![
787 (
788 key.to_string(),
789 PathBuf::from(format!("tests/here/{key}.tex")),
790 ),
791 (
792 key.to_string(),
793 PathBuf::from(format!("tests/nested/{key}.tex")),
794 ),
795 ];
796 let collisions = find_stem_collisions(&all);
797 assert!(
798 collisions
799 .iter()
800 .any(|(k, hits)| k == key && hits.len() == 2),
801 "audit should flag the duplicate {key:?}: {collisions:?}"
802 );
803 }
804}