import { access, constants } from "node:fs"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { defaultMarkdownToPdfOutputPath, MarkdownToPdfError, renderMarkdownToPdf, } from "../../src/agent/markdownToPdf.js"; import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js"; const accessAsync = promisify(access); const roots: string[] = []; const CHROME_CANDIDATES = [ process.env["PUPPETEER_EXECUTABLE_PATH"], process.env["CHROME_PATH"], process.env["GOOGLE_CHROME_BIN"], "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", ].filter((v): v is string => typeof v === "string" && v.trim() !== ""); afterEach(async () => { while (roots.length > 0) { const root = roots.pop(); if (root !== undefined) await rm(root, { recursive: true, force: true }); } }); describe("role tool defaults", () => { it("includes WebFetch, WebSearch, and markdown_to_pdf in the unrestricted surface", () => { const config = claudeSdkToolConfigForRole(undefined); expect(config.tools).toEqual([ "Read", "Write", "Bash", "Glob", "Grep", "WebFetch", "WebSearch", ]); expect(config.allowedTools).toContain("WebFetch"); expect(config.allowedTools).toContain("WebSearch"); expect(config.allowedTools).toContain("mcp__cph_hub__markdown_to_pdf"); expect(cphHubMcpToolsForRole(undefined)).toContain("markdown_to_pdf"); }); it("maps explicit markdown_to_pdf and web tools", () => { expect(claudeSdkToolConfigForRole(["web_fetch", "web_search", "markdown_to_pdf"])).toEqual({ tools: ["WebFetch", "WebSearch"], allowedTools: ["WebFetch", "WebSearch", "mcp__cph_hub__markdown_to_pdf"], }); expect(cphHubMcpToolsForRole(["markdown_to_pdf"])).toEqual(["markdown_to_pdf"]); }); }); describe("renderMarkdownToPdf", () => { it("defaults the output next to build/.pdf", () => { expect(defaultMarkdownToPdfOutputPath("notes/handout.md")).toBe(join("build", "handout.pdf")); expect(defaultMarkdownToPdfOutputPath("README")).toBe(join("build", "README.pdf")); }); it("renders markdown through md-to-pdf with a system Chrome", async () => { let chromePath: string | undefined; for (const candidate of CHROME_CANDIDATES) { try { await accessAsync(candidate, constants.X_OK); chromePath = candidate; break; } catch { // try next } } if (chromePath === undefined) { // No browser on this host — mapping tests above still cover the contract. return; } const root = await mkdtemp(join(tmpdir(), "cph-md2pdf-test-")); roots.push(root); const workspace = join(root, "project"); await mkdir(workspace, { recursive: true }); await writeFile( join(workspace, "lesson.md"), [ "# 测试讲义", "", "段落里有行内数学 $E=mc^2$。", "", "$$", "\\int_0^1 x\\,dx = \\frac{1}{2}", "$$", "", "- item a", "- item b", "", "![remote](https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/128px-React-icon.svg.png)", "", ].join("\n"), "utf8", ); const result = await renderMarkdownToPdf({ workspaceRoot: root, workspaceDir: workspace, markdownPath: "lesson.md", outputPath: "build/lesson.pdf", chromePath, }); expect(result.outputPath).toBe("build/lesson.pdf"); expect(result.bytes).toBeGreaterThan(500); const pdf = await readFile(join(workspace, "build", "lesson.pdf")); expect(pdf.subarray(0, 4).toString("utf8")).toBe("%PDF"); }, 120_000); it("refuses output outside the workspace", async () => { const root = await mkdtemp(join(tmpdir(), "cph-md2pdf-boundary-")); roots.push(root); const workspace = join(root, "project"); await mkdir(workspace, { recursive: true }); await writeFile(join(workspace, "a.md"), "# a\n", "utf8"); await expect( renderMarkdownToPdf({ workspaceRoot: root, workspaceDir: workspace, markdownPath: "a.md", outputPath: "../escape.pdf", }), ).rejects.toMatchObject({ reason: "boundary" } satisfies Partial); }); it("ignores front-matter that would override basedir/dest", async () => { let chromePath: string | undefined; for (const candidate of CHROME_CANDIDATES) { try { await accessAsync(candidate, constants.X_OK); chromePath = candidate; break; } catch { // try next } } if (chromePath === undefined) return; const root = await mkdtemp(join(tmpdir(), "cph-md2pdf-fm-")); roots.push(root); const workspace = join(root, "project"); await mkdir(workspace, { recursive: true }); await writeFile( join(workspace, "evil.md"), [ "---", "basedir: /", "dest: /tmp/should-not-write.pdf", "---", "", "# safe body", "", ].join("\n"), "utf8", ); const result = await renderMarkdownToPdf({ workspaceRoot: root, workspaceDir: workspace, markdownPath: "evil.md", outputPath: "build/safe.pdf", chromePath, }); expect(result.outputPath).toBe("build/safe.pdf"); const pdf = await readFile(join(workspace, "build", "safe.pdf")); expect(pdf.subarray(0, 4).toString("utf8")).toBe("%PDF"); }, 120_000); });