Repository Public Research preview
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