SPB Git

Repository Public Research preview

integration.test.mjs 18.6 KB 303 lines javascript

1// Integration tests: real compiles of the fixtures, driven through the MCP protocol over stdio.2// Needs a TeX distribution; tests for optional tools are skipped when the tool is absent.3import assert from "node:assert/strict";4import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";5import { tmpdir } from "node:os";6import { dirname, join } from "node:path";7import { after, before, test } from "node:test";8import { fileURLToPath } from "node:url";9import { inflateRawSync } from "node:zlib";10import { Client } from "@modelcontextprotocol/sdk/client/index.js";11import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";1213const root = dirname(dirname(fileURLToPath(import.meta.url)));14const work = realpathSync(mkdtempSync(join(tmpdir(), "latex-mcp-it-")));15cpSync(join(root, "test/fixtures"), work, { recursive: true });16let c, tools;17const call = async (name, args = {}) => { const r = await c.callTool({ name, arguments: args }, undefined, { timeout: 170_000 }); return { data: r.structuredContent, isError: !!r.isError }; };18const good = async (name, args) => { const r = await call(name, args); assert.equal(r.isError, false, `${name}: ${JSON.stringify(r.data)}`); return r.data; };19const errCode = async (name, args) => { const r = await call(name, args); assert.equal(r.isError, true, `${name} should fail`); return r.data.error.code; };20const has = (t) => tools[t].installed;2122/** Read one member of a zip archive (enough for a .docx). */23function unzip(buf, member) {24  for (let i = buf.length - 22; i >= 0; i--) {25    if (buf.readUInt32LE(i) !== 0x06054b50) continue;26    let p = buf.readUInt32LE(i + 16);27    for (let n = buf.readUInt16LE(i + 10); n > 0; n--) {28      assert.equal(buf.readUInt32LE(p), 0x02014b50);29      const method = buf.readUInt16LE(p + 10), size = buf.readUInt32LE(p + 20), nameLen = buf.readUInt16LE(p + 28), extra = buf.readUInt16LE(p + 30), comment = buf.readUInt16LE(p + 32), local = buf.readUInt32LE(p + 42);30      if (buf.toString("utf8", p + 46, p + 46 + nameLen) === member) {31        const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);32        const data = buf.subarray(start, start + size);33        return (method === 0 ? data : inflateRawSync(data)).toString("utf8");34      }35      p += 46 + nameLen + extra + comment;36    }37  }38  return null;39}4041before(async () => {42  c = new Client({ name: "integration-test", version: "0" });43  await c.connect(new StdioClientTransport({ command: process.execPath, args: [join(root, "server.mjs")], env: { PATH: process.env.PATH, HOME: process.env.HOME, LATEX_MCP_ROOT: work }, stderr: "pipe" }));44  tools = (await good("toolchain_status")).tools;45});46after(async () => { await c?.close(); rmSync(work, { recursive: true, force: true }); });4748test("toolchain_status reports the tools and the root", async () => {49  const s = await good("toolchain_status");50  assert.equal(s.root, work);51  assert.equal(s.tools.pdflatex.installed, true, "pdflatex is required for these tests");52  assert.match(s.tools.pdflatex.version, /pdfTeX/);53  assert.ok(s.engines_available.includes("pdflatex"));54  assert.match(s.shell_escape, /disabled/);55});5657test("clean article: builds from scratch with no diagnostic, and only the build directory is written", async () => {58  const beforeFiles = readdirSync(join(work, "clean")).sort();59  const r = await good("compile", { file: "clean/main.tex", clean: true });60  assert.equal(r.success, true);61  assert.equal(r.pages, 2);62  assert.equal(r.pdf, "clean/.latex-build/main.pdf");63  assert.ok(existsSync(join(work, r.pdf)));64  assert.equal(r.shell_escape, "disabled");65  for (const k of ["errors", "overfull", "undefined_references", "undefined_citations", "duplicate_labels", "missing_files"]) assert.equal(r.summary[k], 0, k);66  assert.deepEqual(readdirSync(join(work, "clean")).sort(), [".latex-build", ...beforeFiles].sort());67  const o = await good("check_overflow", { file: "clean/main.tex" });68  assert.equal(o.passed, true);69  const a = await good("audit_references", { file: "clean/main.tex" });70  assert.equal(a.passed, true);71  assert.deepEqual(a.counts, { labels: 6, references: 5, citations: 2, bib_entries: 2 });72  assert.deepEqual(a.unreferenced_floats, []);73  const b = await good("bib_audit", { file: "clean/main.tex" });74  assert.equal(b.passed, true);75  assert.equal(b.backend_run, "bibtex");76});7778test("defects article: exact diagnostics from the log", async () => {79  const r = await good("compile", { file: "defects/main.tex" });80  assert.equal(r.success, true, "warnings do not fail a build");81  assert.deepEqual(r.undefined_references, [{ label: "fig:missing", page: 1, file: "main.tex", line: 6 }]);82  assert.deepEqual(r.undefined_citations, [{ key: "nobody2099", page: 1, file: "main.tex", line: 6 }]);83  assert.deepEqual(r.duplicate_labels, [{ label: "sec:first" }]);84  const log = await good("parse_log", { file: "defects/main.tex" });85  assert.equal(log.summary.overfull, 1);86  const o = await good("check_overflow", { file: "defects/main.tex" });87  assert.equal(o.passed, false);88  assert.equal(o.overfull_hbox.length, 1);89  const box = o.overfull_hbox[0];90  assert.deepEqual({ file: box.file, line_start: box.line_start, line_end: box.line_end, page: box.page }, { file: "sections/body.tex", line_start: 4, line_end: 5, page: 1 });91  assert.ok(box.amount_pt > 300 && box.amount_pt < 420, String(box.amount_pt));92  assert.match(box.source, /^\\noindent\\mbox\{Averyvery/);93  const loose = await good("check_overflow", { file: "defects/main.tex", hbox_threshold_pt: 500 });94  assert.equal(loose.passed, true);95  assert.equal(loose.ignored_below_threshold, 1);96});9798test("defects article: static audit of labels, references and citations", async () => {99  const a = await good("audit_references", { file: "defects/main.tex" });100  assert.equal(a.passed, false);101  assert.deepEqual(a.files_scanned, ["main.tex", "sections/body.tex"]);102  assert.deepEqual(a.undefined_references, [{ label: "fig:missing", command: "ref", file: "main.tex", line: 6 }]);103  assert.deepEqual(a.duplicate_labels, [{ label: "sec:first", locations: [{ file: "main.tex", line: 5 }, { file: "main.tex", line: 11 }] }]);104  assert.deepEqual(a.unreferenced_floats, [{ label: "fig:orphan", float: "figure", file: "main.tex", line: 16 }]);105  assert.deepEqual(a.undefined_citations, [{ key: "nobody2099", command: "cite", file: "main.tex", line: 6 }]);106  assert.deepEqual(a.unused_bib_entries.map((e) => e.key), ["munk1966"]);107  const b = await good("bib_audit", { file: "defects/main.tex" });108  assert.equal(b.passed, false);109  assert.equal(b.expected_backend, "bibtex");110  assert.deepEqual(b.missing_entries, ["nobody2099"]);111});112113test("a missing file stops the build: error with file, line and explanation", async () => {114  const r = await good("compile", { file: "broken/main.tex" });115  assert.equal(r.success, false);116  assert.equal(r.pdf, null);117  assert.equal(r.errors.length, 1);118  assert.deepEqual({ file: r.errors[0].file, line: r.errors[0].line }, { file: "main.tex", line: 6 });119  assert.match(r.errors[0].message, /figures\/absent-image' not found/);120  assert.match(r.errors[0].explanation, /cannot be found/);121  assert.deepEqual(r.missing_files, [{ name: "figures/absent-image", file: "main.tex", line: 6 }]);122  assert.equal((await good("bib_audit", { file: "broken/main.tex" })).log, null);123});124125test("Beamer deck: the overflowing frame is named by number, title and line", async () => {126  const r = await good("compile", { file: "deck/deck.tex" });127  assert.equal(r.success, true);128  assert.equal(r.pages, 5);129  const o = await good("check_overflow", { file: "deck/deck.tex" });130  assert.equal(o.passed, false);131  assert.equal(o.beamer.frames_total, 5);132  assert.equal(o.beamer.frame_page_mapping, "exact (.nav)");133  assert.equal(o.beamer.overflowing_frames.length, 1);134  const f = o.beamer.overflowing_frames[0];135  assert.deepEqual({ number: f.number, title: f.title, file: f.file, line: f.line, pages: f.pages }, { number: 3, title: "An overflowing frame", file: "deck.tex", line: 15, pages: { first: 3, last: 3 } });136  assert.ok(f.overflow_pt > 20 && f.overflow_pt < 80, String(f.overflow_pt));137  const b = await good("beamer_report", { file: "deck/deck.tex" });138  assert.deepEqual(b.frames.map((x) => x.title), [null, "A clean frame", "An overflowing frame", "Titled with a command", null]);139  assert.deepEqual(b.frames.map((x) => x.line), [6, 9, 15, 23, 27]);140  assert.deepEqual(b.frames_without_title, [{ number: 5, file: "deck.tex", line: 27 }]);141  assert.deepEqual(b.overflowing_frames.map((x) => x.number), [3]);142  assert.equal(await errCode("beamer_report", { file: "clean/main.tex" }), "not_beamer");143});144145test("extract_numbering reads numbers, kinds, captions and citation labels from the .aux", async () => {146  const n = await good("extract_numbering", { file: "clean/main.tex" });147  const by = Object.fromEntries(n.labels.map((l) => [l.label, l]));148  assert.deepEqual({ number: by["sec:results"].number, kind: by["sec:results"].kind }, { number: "3", kind: "section" });149  assert.deepEqual({ number: by["eq:tide"].number, kind: by["eq:tide"].kind }, { number: "1", kind: "equation" });150  assert.deepEqual({ number: by["tab:values"].number, kind: by["tab:values"].kind, page: by["tab:values"].page }, { number: "1", kind: "table", page: "2" });151  assert.deepEqual(n.captions.map((x) => `${x.kind} ${x.number}: ${x.caption}`), ["figure 1: A placeholder shape.", "table 1: Amplitudes by station."]);152  assert.deepEqual(n.citations, [{ key: "darwin1898", label: "1" }, { key: "munk1966", label: "2" }]);153  assert.equal(await errCode("extract_numbering", { file: "loop/main.tex" }), "no_build");154});155156test("hyperref, cleveref, \\include in a subdirectory: kinds and numbers from the .aux, resolved in the Word source", async (t) => {157  const r = await good("compile", { file: "multi/main.tex" });158  assert.equal(r.success, true, JSON.stringify(r.errors));159  assert.equal(r.summary.undefined_references, 0);160  assert.ok(existsSync(join(work, "multi/.latex-build/chapters/two.aux")), "the .aux of the included file is in the build directory");161  const n = await good("extract_numbering", { file: "multi/main.tex" });162  assert.deepEqual(n.labels.map((l) => `${l.label}=${l.kind} ${l.number}`).sort(), ["eq:one=equation 1", "eq:two=equation 2", "fig:a=figure 1", "fig:b=figure 2", "sec:one=section 1", "sec:two=section 2", "tab:t=table 1"]);163  const a = await good("audit_references", { file: "multi/main.tex" });164  assert.deepEqual(a.files_scanned, ["main.tex", "chapters/two.tex"]);165  assert.equal(a.passed, true);166  if (!has("pandoc")) return t.diagnostic("pandoc not installed: conversion not tested");167  const d = await good("to_docx", { file: "multi/main.tex" });168  assert.deepEqual(d.unresolved_labels, []);169  assert.deepEqual(d.resolved, { references: 6, equations: 2, captions: 3 });170  const flat = readFileSync(join(work, d.flattened_source), "utf8");171  assert.match(flat, /See figures~1 and 2, Table~1 and \(2\); also Section~2\./);172  assert.match(flat, /a &= b \\qquad \(1\)/);173  assert.match(flat, /\\caption\{Figure 2\. Second box\}/);174  assert.match(flat, /see Section~1 and equation~\(1\)/);175  const text = unzip(readFileSync(join(work, d.docx)), "word/document.xml").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");176  assert.match(text, /See figures 1 and 2, Table 1 and \(2\); also Section 2\./);177  assert.match(text, /Table 1\. A table/);178});179180test("Beamer overlays: a frame spanning three pages keeps the mapping exact", async () => {181  assert.equal((await good("compile", { file: "deck/overlays.tex" })).pages, 4);182  const b = await good("beamer_report", { file: "deck/overlays.tex" });183  assert.equal(b.frame_page_mapping, "exact (.nav)");184  assert.deepEqual(b.frames.map((f) => [f.number, f.pages.first, f.pages.last, f.slides]), [[1, 1, 3, 3], [2, 4, 4, 1]]);185  assert.deepEqual(b.overflowing_frames.map((f) => [f.number, f.title, f.line, f.pages.first]), [[2, "Too tall", 6, 4]]);186});187188test("to_docx writes a Word file with the cross-reference numbers resolved", async (t) => {189  if (!has("pandoc")) return t.skip("pandoc not installed");190  const r = await good("to_docx", { file: "clean/main.tex" });191  assert.equal(r.docx, "clean/.latex-build/main.docx");192  assert.deepEqual(r.resolved, { references: 5, equations: 1, captions: 2 });193  assert.deepEqual(r.unresolved_labels, []);194  assert.ok(r.does_not_survive.length >= 4);195  const buf = readFileSync(join(work, r.docx));196  assert.equal(buf.toString("latin1", 0, 2), "PK");197  const xml = unzip(buf, "word/document.xml");198  assert.ok(xml, "word/document.xml is in the archive");199  const text = xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");200  assert.match(text, /Section 2 gives the model and Section 3 the results/);201  assert.match(text, /equation \(1\)/);202  assert.match(text, /Figure 1 shows the shape and Table 1 the values/);203  assert.match(text, /Figure 1\. A placeholder shape\./);204  assert.match(text, /Table 1\. Amplitudes by station\./);205  assert.match(text, /Darwin/);206  assert.doesNotMatch(text, /sec:results|\\ref|\?\?/);207  // A chosen output path: inside the root only, and never replaced silently.208  const o = await good("to_docx", { file: "clean/main.tex", output: "exports/paper.docx" });209  assert.ok(existsSync(join(work, o.docx)));210  assert.equal(await errCode("to_docx", { file: "clean/main.tex", output: "exports/paper.docx" }), "exists");211  assert.equal(await errCode("to_docx", { file: "clean/main.tex", output: "../escaped.docx" }), "outside_root");212  // Unresolved labels are reported, not hidden.213  const d = await good("to_docx", { file: "defects/main.tex" });214  assert.deepEqual(d.unresolved_labels, ["fig:missing"]);215});216217test("shell escape is off for every engine, and a project latexmkrc is not executed", async () => {218  writeFileSync(join(work, "escape/latexmkrc"), 'system("touch rc-proof.txt");\n');219  writeFileSync(join(work, "escape/.latexmkrc"), 'system("touch rc-proof.txt");\n');220  for (const engine of ["pdflatex", "xelatex", "lualatex"].filter(has)) {221    const r = await good("compile", { file: "escape/main.tex", engine, clean: true });222    assert.equal(r.success, true, engine);223    assert.equal(r.shell_escape, "disabled", engine);224    assert.deepEqual(r.shell_commands_executed, [], engine);225    if (engine !== "lualatex") assert.deepEqual(r.shell_commands_blocked, ["touch shell-escape-proof.txt", "kpsewhich article.cls"], engine);226    assert.equal(existsSync(join(work, "escape/shell-escape-proof.txt")), false, engine);227    assert.equal(existsSync(join(work, "escape/.latex-build/shell-escape-proof.txt")), false, engine);228  }229  assert.equal(existsSync(join(work, "escape/rc-proof.txt")), false);230  assert.equal(existsSync(join(work, "escape/.latex-build/rc-proof.txt")), false);231});232233test("built-in driver (no latexmk) with XeLaTeX runs the bibliography and the reruns", async (t) => {234  if (!has("xelatex")) return t.skip("xelatex not installed");235  const r = await good("compile", { file: "clean/main.tex", engine: "xelatex", driver: "builtin", build_dir: "out-xe" });236  assert.equal(r.success, true);237  assert.equal(r.driver, "built-in");238  assert.equal(r.pages, 2);239  assert.deepEqual(r.steps.map((s) => s.tool), ["xelatex", "bibtex", "xelatex", "xelatex"]);240  assert.equal(r.summary.undefined_references + r.summary.undefined_citations, 0);241  assert.equal(r.summary.rerun_needed, false);242  assert.equal(r.pdf, "clean/out-xe/main.pdf");243});244245test("biblatex with Biber: missing entry found in the Biber log", async (t) => {246  if (!has("biber")) return t.skip("biber not installed");247  const r = await good("compile", { file: "biber/main.tex" });248  assert.equal(r.success, true);249  assert.deepEqual(r.undefined_citations, [{ key: "ghost2001", page: 1, file: "main.tex", line: 5 }]);250  const b = await good("bib_audit", { file: "biber/main.tex" });251  assert.equal(b.expected_backend, "biber");252  assert.equal(b.backend_run, "biber");253  assert.deepEqual(b.missing_entries, ["ghost2001"]);254  assert.equal(b.passed, false);255});256257test("pdf_text, word_count and lint", async (t) => {258  if (has("pdftotext")) {259    const p = await good("pdf_text", { file: "clean/main.tex", first_page: 2, last_page: 2 });260    assert.equal(p.pages.length, 1);261    assert.equal(p.pages[0].page, 2);262    assert.match(p.pages[0].text, /Table 1: Amplitudes by station/);263    assert.equal(await errCode("pdf_text", { pdf_path: "/etc/hosts" }), "outside_root");264  }265  const w = await good("word_count", { file: "clean/main.tex" });266  if (has("texcount")) { assert.equal(w.method, "texcount"); assert.equal(w.words_text, 45); assert.equal(w.words_captions, 6); }267  else assert.equal(w.method, "built-in approximation");268  if (!has("chktex")) return t.diagnostic("chktex not installed: lint not tested");269  const l = await good("lint", { file: "clean/main.tex" });270  assert.deepEqual(l.items.map((i) => [i.line, i.rule]), [[16, 2], [19, 2]]);271});272273test("a runaway document is stopped by the timeout", async () => {274  const r = await good("compile", { file: "loop/main.tex", timeout_s: 5 });275  assert.equal(r.success, false);276  assert.equal(r.timed_out, true);277  assert.match(r.note, /timeout_s/);278});279280test("confinement through the tools, and clean_build removes only what this server created", async () => {281  writeFileSync(join(dirname(work), "latex-mcp-outside.tex"), "x");282  assert.equal(await errCode("compile", { file: "../latex-mcp-outside.tex" }), "outside_root");283  rmSync(join(dirname(work), "latex-mcp-outside.tex"));284  assert.equal(await errCode("compile", { file: "/etc/hosts" }), "outside_root");285  assert.equal(await errCode("compile", { file: "clean/main.tex", build_dir: "../../elsewhere" }), "outside_root");286  assert.equal(await errCode("compile", { file: "clean/main.tex", build_dir: "." }), "bad_build_dir");287  assert.equal(await errCode("compile", { file: "clean/refs.bib" }), "bad_file");288  // A directory made by someone else is neither used nor removed.289  mkdirSync(join(work, "clean/figures")); writeFileSync(join(work, "clean/figures/keep.txt"), "user file");290  assert.equal(await errCode("compile", { file: "clean/main.tex", build_dir: "figures" }), "build_dir_in_use");291  assert.equal(await errCode("clean_build", { file: "clean/main.tex", build_dir: "figures", confirm: true }), "not_ours");292  assert.equal(readFileSync(join(work, "clean/figures/keep.txt"), "utf8"), "user file");293  // Confirmation is required.294  assert.equal(await errCode("clean_build", { file: "clean/main.tex" }), "confirm_required");295  assert.ok(existsSync(join(work, "clean/.latex-build/main.pdf")));296  const r = await good("clean_build", { file: "clean/main.tex", confirm: true });297  assert.equal(r.removed, true);298  assert.ok(r.files > 3);299  assert.equal(existsSync(join(work, "clean/.latex-build")), false);300  assert.ok(existsSync(join(work, "clean/main.tex")) && existsSync(join(work, "clean/refs.bib")));301  assert.equal((await good("clean_build", { file: "clean/main.tex", confirm: true })).removed, false);302});303
Files main
Menu

Theme