SPB Git

Repository Public Research preview

skill.test.mjs 9.2 KB 153 lines javascript

1// Tests of the skill: specification compliance, engine identity with the MCP package, and the2// command-line tool on real compiles of the fixtures.3// Run: node --test skill-tests/skill.test.mjs4import assert from "node:assert/strict";5import { execFile } from "node:child_process";6import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";7import { tmpdir } from "node:os";8import { join } from "node:path";9import { after, test } from "node:test";10import { fileURLToPath } from "node:url";1112const skill = fileURLToPath(new URL("../skill/latex-manuscript-qa/", import.meta.url));13const work = realpathSync(mkdtempSync(join(tmpdir(), "latex-skill-")));14cpSync(fileURLToPath(new URL("../mcp/test/fixtures/", import.meta.url)), work, { recursive: true });15const cli = (...args) => new Promise((res) => execFile(process.execPath, [join(skill, "scripts/latex.mjs"), ...args], { cwd: work, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, env: { PATH: process.env.PATH, HOME: process.env.HOME } }, (err, stdout, stderr) => {16  let json = null; try { json = JSON.parse(stdout); } catch { /* usage text */ }17  res({ status: err ? (typeof err.code === "number" ? err.code : 1) : 0, json, stdout, stderr });18}));19after(() => rmSync(work, { recursive: true, force: true }));2021test("SKILL.md follows the Agent Skills specification and every referenced file exists", () => {22  const md = readFileSync(join(skill, "SKILL.md"), "utf8");23  const fm = /^---\n([\s\S]*?)\n---\n/.exec(md)[1];24  const name = /^name: (.+)$/m.exec(fm)[1];25  assert.equal(name, "latex-manuscript-qa");26  assert.match(name, /^[a-z0-9]+(-[a-z0-9]+)*$/);27  const d = /^description: (.+)$/m.exec(fm)[1];28  assert.ok(d.length > 50 && d.length <= 1024, String(d.length));29  assert.ok(/^compatibility: (.+)$/m.exec(fm)[1].length <= 500);30  assert.ok(md.split("\n").length < 500);31  const docs = [md, ...readdirSync(join(skill, "references")).map((f) => readFileSync(join(skill, "references", f), "utf8")), readFileSync(join(skill, "README.md"), "utf8")];32  for (const text of docs) for (const ref of text.matchAll(/`((?:references|scripts)\/[A-Za-z0-9_./-]+)`/g)) assert.ok(existsSync(join(skill, ref[1])), `${ref[1]} is referenced but missing`);33  for (const f of readdirSync(join(skill, "references"))) assert.ok(md.includes(`references/${f}`), `${f} is not mentioned in SKILL.md`);34  const manifest = JSON.parse(readFileSync(join(skill, "agent-registry.json"), "utf8"));35  assert.equal(manifest.name, "@spboucher/latex-manuscript-qa");36  assert.equal(manifest.version, /version: "([^"]+)"/.exec(fm)[1]);37  assert.ok(existsSync(join(skill, manifest.entrypoint)));38});3940test("the skill engine is byte-identical to the MCP engine and has no dependencies", () => {41  for (const f of ["engine.mjs", "logparse.mjs"]) assert.equal(readFileSync(join(skill, "scripts/lib", f), "utf8"), readFileSync(new URL(`../mcp/lib/${f}`, import.meta.url), "utf8"), `${f}: run node build-skill.mjs`);42  assert.equal(readFileSync(join(skill, "LICENSE"), "utf8"), readFileSync(new URL("../mcp/LICENSE", import.meta.url), "utf8"));43  for (const f of ["scripts/latex.mjs", "scripts/lib/engine.mjs", "scripts/lib/logparse.mjs"]) for (const m of readFileSync(join(skill, f), "utf8").matchAll(/^import .* from "([^"]+)";$/gm)) assert.ok(m[1].startsWith("node:") || m[1].startsWith("./"), `${f} imports ${m[1]}`);44});4546test("every command of the usage text is documented in SKILL.md", async () => {47  const help = await cli("help");48  assert.equal(help.status, 0);49  const md = readFileSync(join(skill, "SKILL.md"), "utf8");50  const cmds = [...help.stdout.matchAll(/^  ([a-z]+) /gm)].map((m) => m[1]);51  assert.deepEqual(cmds, ["status", "qa", "compile", "log", "overflow", "beamer", "refs", "bib", "count", "lint", "numbering", "docx", "text", "explain", "clean"]);52  for (const c of cmds) assert.ok(md.includes(`latex.mjs ${c}`), `${c} is not shown in SKILL.md`);53});5455test("usage errors exit 2; explain works without TeX", async () => {56  assert.equal((await cli()).status, 2);57  assert.equal((await cli("frobnicate")).status, 2);58  const noFile = await cli("compile");59  assert.equal(noFile.status, 2);60  assert.equal(noFile.json.error.code, "usage");61  const outside = await cli("refs", "/etc/hosts");62  assert.equal(outside.status, 2);63  assert.equal(outside.json.error.code, "outside_root");64  const noBuild = await cli("overflow", "loop/main.tex");65  assert.equal(noBuild.status, 2);66  assert.equal(noBuild.json.error.code, "no_build");67  const e = await cli("explain", "Package biblatex Warning: Please (re)run Biber on the file");68  assert.equal(e.status, 0);69  assert.match(e.json.fix, /biber/);70});7172test("status lists the toolchain", async () => {73  const s = await cli("status");74  assert.equal(s.status, 0);75  assert.equal(s.json.root, work);76  assert.equal(s.json.tools.pdflatex.installed, true, "pdflatex is required for these tests");77});7879test("qa passes on the clean article", async () => {80  const r = await cli("qa", "clean/main.tex");81  assert.equal(r.status, 0, r.stdout);82  assert.equal(r.json.passed, true);83  assert.equal(r.json.pages, 2);84  assert.ok(r.json.checks.length >= 7);85  assert.ok(r.json.checks.every((c) => c.passed));86  assert.ok(existsSync(join(work, "clean/.latex-build/main.pdf")));87});8889test("qa fails on the defects article and names each defect with its location", async () => {90  const r = await cli("qa", "defects/main.tex");91  assert.equal(r.status, 1);92  assert.equal(r.json.passed, false);93  const by = Object.fromEntries(r.json.checks.map((c) => [c.check, c]));94  assert.equal(by["clean build from scratch"].passed, true);95  assert.deepEqual(by["no undefined reference or citation in the log"].detail, ["ref fig:missing (main.tex:6)", "cite nobody2099 (main.tex:6)"]);96  assert.deepEqual(by["no multiply defined label"].detail, ["sec:first"]);97  assert.match(by["no overfull box above 1 pt"].detail[0], /^hbox 3\d\d(\.\d+)? pt at sections\/body\.tex:4$/);98  assert.deepEqual(by["every float is referenced"].detail, ["fig:orphan (main.tex:16)"]);99  assert.deepEqual(by["no duplicate label in the sources"].detail, ["sec:first: main.tex:5, main.tex:11"]);100  assert.deepEqual(by["bibliography (bibtex) ran without missing entry"].detail, ["nobody2099"]);101  assert.deepEqual(r.json.advisory.unused_bib_entries, ["munk1966"]);102});103104test("compile, log, refs, bib, numbering, count on single commands", async () => {105  const broken = await cli("compile", "broken/main.tex");106  assert.equal(broken.status, 1);107  assert.deepEqual({ file: broken.json.errors[0].file, line: broken.json.errors[0].line }, { file: "main.tex", line: 6 });108  assert.equal((await cli("log", "broken/main.tex")).status, 1);109  assert.equal((await cli("log", "clean/main.tex")).status, 0);110  assert.equal((await cli("refs", "clean/main.tex")).status, 0);111  assert.equal((await cli("refs", "defects/main.tex")).status, 1);112  assert.equal((await cli("bib", "defects/main.tex")).status, 1);113  const n = await cli("numbering", "clean/main.tex");114  assert.equal(n.json.labels.find((l) => l.label === "sec:results").number, "3");115  const w = await cli("count", "clean/main.tex");116  assert.ok(w.json.words_total > 40 && w.json.words_total < 80, String(w.json.words_total));117  const loose = await cli("overflow", "defects/main.tex", "--hbox", "500");118  assert.equal(loose.status, 0);119});120121test("deck: overflow and beamer name frame 3", async () => {122  assert.equal((await cli("compile", "deck/deck.tex")).status, 0);123  const o = await cli("overflow", "deck/deck.tex");124  assert.equal(o.status, 1);125  assert.deepEqual(o.json.beamer.overflowing_frames.map((f) => [f.number, f.title, f.line]), [[3, "An overflowing frame", 15]]);126  const b = await cli("beamer", "deck/deck.tex");127  assert.equal(b.status, 1);128  assert.equal(b.json.frames_total, 5);129  const q = await cli("qa", "deck/deck.tex");130  assert.equal(q.status, 1);131  assert.deepEqual(q.json.checks.find((c) => c.check === "no Beamer frame overflows").detail.map((d) => d.replace(/ \+[\d.]+ pt$/, "")), ['frame 3 "An overflowing frame" (deck.tex:15)']);132});133134test("docx keeps the numbers; clean needs --yes and removes only the build directory", async (t) => {135  const st = (await cli("status")).json;136  if (st.capabilities.to_docx) {137    const d = await cli("docx", "clean/main.tex", "--out", "out/paper.docx");138    assert.equal(d.status, 0, d.stdout);139    assert.equal(d.json.docx, "out/paper.docx");140    assert.deepEqual(d.json.resolved, { references: 5, equations: 1, captions: 2 });141    assert.equal(readFileSync(join(work, "out/paper.docx")).toString("latin1", 0, 2), "PK");142    assert.match(readFileSync(join(work, "clean/.latex-build/main.pandoc.tex"), "utf8"), /Section~3 the results/);143    assert.equal((await cli("docx", "clean/main.tex", "--out", "out/paper.docx")).json.error.code, "exists");144    assert.equal((await cli("docx", "defects/main.tex")).status, 1); // unresolved label145  } else t.diagnostic("pandoc not installed: docx not tested");146  assert.equal((await cli("clean", "clean/main.tex")).json.error.code, "confirm_required");147  assert.ok(existsSync(join(work, "clean/.latex-build")));148  const c = await cli("clean", "clean/main.tex", "--yes");149  assert.equal(c.json.removed, true);150  assert.equal(existsSync(join(work, "clean/.latex-build")), false);151  assert.ok(existsSync(join(work, "clean/main.tex")));152});153
Files main
Menu

Theme