import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ApplicationRequestError, createApplicationService } from "../src/index.js"; let temporaryDirectory: string | undefined; afterEach(async () => { if (temporaryDirectory) { await rm(temporaryDirectory, { recursive: true, force: true }); } temporaryDirectory = undefined; }); async function createThemeFixture() { temporaryDirectory = await mkdtemp( join(tmpdir(), "md-to-pdf-application-") ); const bundledRoot = join(temporaryDirectory, "bundled"); const localRoot = join(temporaryDirectory, "local"); const themeRoot = join(bundledRoot, "test-theme"); const sharedFontRoot = join( bundledRoot, "_shared", "official-fonts" ); await mkdir(join(themeRoot, "fonts"), { recursive: true }); await mkdir(sharedFontRoot, { recursive: true }); await mkdir(localRoot, { recursive: true }); await writeFile( join(themeRoot, "theme.json"), JSON.stringify({ manifestVersion: 1, id: "test-theme", name: "测试主题", version: "1.0.0", description: "共享应用服务测试主题", author: "test", license: "MIT", entry: "theme.css", domPreset: "typora", defaultFontSize: "16px", supportedFeatures: ["code", "table"], pageDefaults: { margins: { top: "37mm", right: "26mm", bottom: "35mm", left: "28mm" } }, bundled: true }), "utf8" ); await writeFile( join(themeRoot, "theme.css"), [ "@font-face {", " font-family: Test;", " src: url('./fonts/test.woff2') format('woff2');", "}", "@font-face {", " font-family: SharedTest;", " src: url('theme-shared:official-fonts/shared.woff2') format('woff2');", "}", "#write { font-family: Test; }" ].join("\n"), "utf8" ); await writeFile( join(themeRoot, "fonts", "test.woff2"), Buffer.from([0, 1, 2, 3]) ); await writeFile( join(sharedFontRoot, "shared.woff2"), Buffer.from([4, 5, 6, 7]) ); return { bundledRoot, localRoot }; } describe("共享应用服务", () => { it("渲染安全 Markdown 文档", async () => { const roots = await createThemeFixture(); const service = createApplicationService(roots); const document = await service.render({ markdown: "# 文档\n\n", language: "zh-CN" }); expect(document.articleHtml).toContain('id="write"'); expect(document.articleHtml).not.toContain(" { const roots = await createThemeFixture(); const service = createApplicationService(roots); await expect(service.render({ markdown: 42 })).rejects.toEqual( expect.objectContaining({ statusCode: 400, code: "INVALID_MARKDOWN" }) ); }); it("解析上传素材与桌面文档目录中的相对图片", async () => { const roots = await createThemeFixture(); const service = createApplicationService(roots); const image = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]); const assetRoot = join(temporaryDirectory!, "document"); await mkdir(join(assetRoot, "文档.assets"), { recursive: true }); await writeFile(join(assetRoot, "文档.assets", "本地.png"), image); const local = await service.render( { markdown: "![本地](./%E6%96%87%E6%A1%A3.assets/%E6%9C%AC%E5%9C%B0.png)" }, { localRoot: assetRoot } ); const uploaded = await service.render({ markdown: "![网页](文档.assets/网页.png)", resources: [ { path: "文档.assets/网页.png", data: image.toString("base64") } ] }); expect(local.articleHtml).toContain("data:image/png;base64,"); expect(uploaded.articleHtml).toContain("data:image/png;base64,"); expect(local.articleHtml).toContain("md-document-image"); expect(uploaded.warnings).toEqual([]); }); it("列出主题并使用调用方提供的资源 URL", async () => { const roots = await createThemeFixture(); const service = createApplicationService({ ...roots, createAssetUrl: (themeId, assetPath) => `mdpdf://theme/${themeId}/${assetPath}` }); await expect(service.listThemes()).resolves.toEqual({ themes: [ expect.objectContaining({ id: "test-theme", category: "general", compatibleProfiles: [], pageDefaults: { margins: { top: "37mm", right: "26mm", bottom: "35mm", left: "28mm" } }, bundled: true, source: "bundled" }) ] }); await expect( service.getThemeCss("test-theme") ).resolves.toContain( "mdpdf://theme/test-theme/fonts/test.woff2" ); await expect( service.getThemeCss("test-theme") ).resolves.toContain( "mdpdf://theme/_shared/official-fonts/shared.woff2" ); }); it("安全读取主题二进制资源", async () => { const roots = await createThemeFixture(); const service = createApplicationService(roots); await expect( service.getThemeAsset("test-theme", "fonts/test.woff2") ).resolves.toEqual({ contentType: "font/woff2", content: Buffer.from([0, 1, 2, 3]) }); await expect( service.getThemeAsset("test-theme", "../test.woff2") ).rejects.toThrow("主题资源路径不安全"); await expect( service.getThemeAsset( "_shared", "official-fonts/shared.woff2" ) ).resolves.toEqual({ contentType: "font/woff2", content: Buffer.from([4, 5, 6, 7]) }); await expect( service.getThemeAsset("_shared", "../shared.woff2") ).rejects.toThrow("主题资源路径不安全"); }); it("内置主题优先于同 ID 的旧本地副本", async () => { const roots = await createThemeFixture(); const localThemeRoot = join(roots.localRoot, "test-theme"); await mkdir(localThemeRoot, { recursive: true }); await writeFile( join(localThemeRoot, "theme.json"), JSON.stringify({ manifestVersion: 1, id: "test-theme", name: "旧本地主题", version: "local", description: "迁移前的本地副本", author: "test", license: "local-only", entry: "theme.css", domPreset: "typora", defaultFontSize: "16px", supportedFeatures: ["code", "table"], bundled: false }), "utf8" ); await writeFile( join(localThemeRoot, "theme.css"), "#write { color: red; }", "utf8" ); const onWarning = vi.fn(); const service = createApplicationService({ ...roots, onWarning }); await expect(service.listThemes()).resolves.toEqual({ themes: [ expect.objectContaining({ id: "test-theme", name: "测试主题", bundled: true }) ] }); expect(onWarning).toHaveBeenCalledWith( "已忽略与内置主题同 ID 的本地主题 test-theme" ); }); });