Repository Public Research preview
1// Offline tests: no TeX needed. Parsers run on logs recorded from real builds of the fixtures.2import assert from "node:assert/strict";3import { mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";4import { tmpdir } from "node:os";5import { dirname, join } from "node:path";6import { test } from "node:test";7import { fileURLToPath } from "node:url";8import { Client } from "@modelcontextprotocol/sdk/client/index.js";9import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";10import { Latex, braceArg, explainError, parseBibLog, parseLog } from "../lib/engine.mjs";1112const root = dirname(dirname(fileURLToPath(import.meta.url)));13const log = (n) => readFileSync(join(root, "test/logs", n), "utf8");14const tmp = realpathSync(mkdtempSync(join(tmpdir(), "latex-mcp-off-")));15const code = (fn) => { try { fn(); return null; } catch (e) { return e.code; } };1617test("log of the defects article: every diagnostic with file and line", () => {18 const r = parseLog(log("defects.log"));19 assert.equal(r.engine, "pdfTeX");20 assert.equal(r.pages, 1);21 assert.deepEqual(r.errors, []);22 assert.deepEqual(r.undefined_references, [{ label: "fig:missing", page: 1, file: "main.tex", line: 6 }]);23 assert.deepEqual(r.undefined_citations, [{ key: "nobody2099", page: 1, file: "main.tex", line: 6 }]);24 assert.deepEqual(r.duplicate_labels, [{ label: "sec:first" }]);25 assert.equal(r.overfull.length, 1);26 assert.deepEqual(r.overfull[0], { box: "hbox", file: "sections/body.tex", line_start: 4, line_end: 5, page: 1, while_output: false, amount_pt: 364.17 });27});2829test("log of the deck: the overfull vbox belongs to page 3", () => {30 const r = parseLog(log("deck.log"));31 assert.equal(r.pages, 5);32 const v = r.overfull.filter((b) => b.box === "vbox");33 assert.equal(v.length, 1);34 assert.equal(v[0].page, 3);35 assert.equal(v[0].amount_pt, 42.49);36 assert.equal(v[0].line_start, 22);37 assert.equal(v[0].file, "deck.tex");38});3940test("log of a build stopped by a missing file", () => {41 const r = parseLog(log("broken.log"));42 assert.equal(r.errors.length, 1);43 assert.deepEqual({ file: r.errors[0].file, line: r.errors[0].line }, { file: "main.tex", line: 6 });44 assert.match(r.errors[0].message, /absent-image' not found/);45 assert.match(r.errors[0].context, /includegraphics/);46 assert.deepEqual(r.missing_files, [{ name: "figures/absent-image", file: "main.tex", line: 6 }]);47 assert.equal(r.pages, null);48});4950test("classic error form, wrapped logs, fonts, reruns, blocked shell commands", () => {51 const r = parseLog([52 "This is XeTeX, Version 3.14 (TeX Live 2026)", "(./paper.tex", "(./chapters/one.tex", "! Undefined control sequence.", "l.12 \\foo", " bar", "",53 "Package hyperref Warning: Token not allowed in a PDF string (Unicode):", "(hyperref) removing `math shift' on input line 30.", "",54 "LaTeX Font Warning: Font shape `T1/cmr/m/sc' undefined", "Missing character: There is no é (U+00E9) in font cmr10!",55 "runsystem(touch x)...disabled.", ")", "LaTeX Warning: Label(s) may have changed. Rerun to get cross-references right.", "[1] [2]",56 "Overfull \\hbox (12.5pt too wide) in paragraph at lines 40--41", "[]\\T1/cmr/m/n/10 some [3] text", "", ")",57 ].join("\n"));58 assert.equal(r.engine, "XeTeX");59 assert.deepEqual(r.errors, [{ file: "chapters/one.tex", line: 12, message: "Undefined control sequence.", context: "\\foo" }]);60 assert.equal(r.warnings[0].source, "hyperref");61 assert.equal(r.warnings[0].line, 30);62 assert.equal(r.warnings[0].file, "chapters/one.tex");63 assert.equal(r.missing_fonts[0].font, "T1/cmr/m/sc");64 assert.equal(r.missing_characters.length, 1);65 assert.deepEqual(r.shell_commands_blocked, ["touch x"]);66 assert.equal(r.rerun_needed, true);67 // The "[3]" inside the dump of the box is not a page; the box sits on page 3, after pages 1 and 2.68 assert.equal(r.overfull[0].page, 3);69 assert.equal(r.overfull[0].file, "paper.tex");70});7172test("BibTeX and Biber logs", () => {73 const b = parseBibLog(log("defects.blg"));74 assert.equal(b.backend, "bibtex");75 assert.deepEqual(b.missing_entries, ["nobody2099"]);76 const r = parseBibLog("[0] Config.pm:307> INFO - This is Biber 2.21\n[70] Biber.pm:4419> WARN - I didn't find a database entry for 'ghost2001' (section 0)\n[80] bibtex.pm:100> ERROR - Cannot find 'absent.bib'!\n");77 assert.equal(r.backend, "biber");78 assert.deepEqual(r.missing_entries, ["ghost2001"]);79 assert.deepEqual(r.missing_databases, ["absent.bib"]);80 const e = parseBibLog("This is BibTeX, Version 0.99d\nI couldn't open database file nope.bib\n---line 3 of file main.aux\nWarning--empty year in smith2020\n");81 assert.deepEqual(e.missing_databases, ["nope.bib"]);82 assert.equal(e.warnings[0].key, "smith2020");83});8485test("explain_error knows the costly ones and admits what it does not know", () => {86 assert.match(explainError("File `tikz.sty' not found.").fix, /tlmgr/);87 assert.match(explainError("Package biblatex Warning: Please (re)run Biber on the file").fix, /biber/);88 assert.match(explainError("Package fontspec Error: The font \"Garamond\" cannot be found.").fix, /XeLaTeX/);89 assert.match(explainError("Package pgfplots Warning: running in backwards compatibility mode").fix, /compat=/);90 assert.match(explainError("Package inputenc Error: Unicode character é (U+00E9)").cause, /character/);91 assert.equal(explainError("Something nobody has seen").matched, false);92});9394test("braceArg reads nested and escaped braces", () => {95 assert.deepEqual(braceArg("{a{b}c}d", 0), { text: "a{b}c", end: 7 });96 assert.equal(braceArg("{a\\}b}", 0).text, "a\\}b");97 assert.equal(braceArg("x{a}", 0), null);98});99100test("paths are confined to the root, symlinks included", () => {101 const rootDir = join(tmp, "root"), outside = join(tmp, "outside");102 mkdirSync(rootDir); mkdirSync(outside);103 writeFileSync(join(rootDir, "a.tex"), "x"); writeFileSync(join(outside, "secret.tex"), "x");104 symlinkSync(outside, join(rootDir, "link"));105 const L = new Latex({ LATEX_MCP_ROOT: rootDir, PATH: "" });106 assert.equal(L.inside("a.tex"), join(rootDir, "a.tex"));107 assert.equal(code(() => L.inside("../outside/secret.tex")), "outside_root");108 assert.equal(code(() => L.inside(join(outside, "secret.tex"))), "outside_root");109 assert.equal(code(() => L.inside("link/secret.tex")), "outside_root");110 assert.equal(code(() => L.inside("link/new.docx", { mustExist: false })), "outside_root");111 assert.equal(code(() => L.inside("/etc/hosts")), "outside_root");112 assert.equal(code(() => L.inside("missing.tex")), "not_found");113 assert.equal(code(() => L.job("a.tex", "../../elsewhere")), "outside_root");114 assert.equal(code(() => L.job("a.tex", ".")), "bad_build_dir");115 assert.equal(code(() => new Latex({ LATEX_MCP_ROOT: join(tmp, "nope") })), "bad_root");116});117118test("without TeX on the PATH the tools say what is missing", async () => {119 const dir = join(tmp, "notex"); mkdirSync(dir); writeFileSync(join(dir, "a.tex"), "\\documentclass{article}\\begin{document}x\\end{document}");120 const L = new Latex({ LATEX_MCP_ROOT: dir, PATH: "" });121 L.path = [join(tmp, "empty-bin")]; // no tool anywhere122 const st = await L.toolchainStatus();123 assert.equal(st.capabilities.compile, false);124 assert.equal(st.tools.pandoc.installed, false);125 await assert.rejects(() => L.compile({ file: "a.tex" }), (e) => e.code === "tool_missing" && /pdflatex/.test(e.message));126 await assert.rejects(() => L.lint({ file: "a.tex" }), (e) => e.code === "tool_missing");127 const wc = await L.wordCount({ file: "a.tex" });128 assert.equal(wc.method, "built-in approximation");129 assert.equal(wc.words_total, 1);130 assert.equal(code(() => L.checkOverflow({ file: "a.tex" })), "no_build");131});132133test("the server speaks MCP over stdio, also when started through a symlinked path", async () => {134 const link = join(tmp, "pkg"); symlinkSync(root, link);135 const c = new Client({ name: "offline-test", version: "0" });136 await c.connect(new StdioClientTransport({ command: process.execPath, args: [join(link, "server.mjs")], env: { PATH: process.env.PATH, LATEX_MCP_ROOT: tmp }, stderr: "pipe" }));137 const { tools } = await c.listTools();138 assert.deepEqual(tools.map((t) => t.name).sort(), ["audit_references", "beamer_report", "bib_audit", "check_overflow", "clean_build", "compile", "explain_error", "extract_numbering", "lint", "parse_log", "pdf_text", "to_docx", "toolchain_status", "word_count"]);139 for (const t of tools) { assert.ok(t.description.length > 40, t.name); assert.ok(t.annotations, t.name); assert.ok(t.outputSchema, t.name); }140 const by = Object.fromEntries(tools.map((t) => [t.name, t]));141 assert.equal(by.clean_build.annotations.destructiveHint, true);142 for (const n of ["parse_log", "check_overflow", "audit_references", "explain_error"]) assert.equal(by[n].annotations.readOnlyHint, true, n);143 const r = await c.callTool({ name: "explain_error", arguments: { message: "! Undefined control sequence." } });144 assert.equal(r.structuredContent.matched, true);145 const bad = await c.callTool({ name: "parse_log", arguments: { log_path: "/etc/hosts" } });146 assert.equal(bad.isError, true);147 assert.equal(bad.structuredContent.error.code, "outside_root");148 await c.close();149});150