SPB Git

Repository Public Research preview

offline.test.mjs 9.1 KB 150 lines javascript

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
Files main
Menu

Theme