Skip to main content

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}