import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { defaultExportConfig } from "@md-to-pdf/core"; import { strFromU8, unzipSync } from "fflate"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const repositoryRoot = path.resolve(scriptDirectory, ".."); const composeFile = path.join(scriptDirectory, "compose.yaml"); const image = process.env.MD_TO_PDF_IMAGE?.trim() || "md-to-pdf:local"; const validFontPackRoot = path.resolve( process.env.MD_TO_PDF_FONT_PACK_DIR?.trim() || path.join(repositoryRoot, "output", "font-packs", "root") ); function assert(condition, message) { if (!condition) { throw new Error(message); } } function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: repositoryRoot, encoding: "utf8", stdio: options.capture ? "pipe" : "inherit", env: options.env ?? process.env }); if (result.error) { throw result.error; } if (result.status !== 0) { throw new Error( `${command} ${args.join(" ")} 失败(退出码 ${result.status})${ options.capture ? `\n${result.stderr || result.stdout}` : "" }` ); } return options.capture ? result.stdout.trim() : ""; } async function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : undefined; server.close((error) => error || !port ? reject(error ?? new Error("无法分配端口")) : resolve(port) ); }); }); } async function waitForHealth(origin) { let lastError; for (let attempt = 0; attempt < 45; attempt += 1) { try { const response = await fetch(`${origin}/api/health`); if (response.ok && (await response.json()).status === "ok") { return; } } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, 1000)); } throw new Error( `容器健康检查未就绪${lastError ? `:${String(lastError)}` : ""}` ); } function inspectFontPackMount(containerId, expectedRoot) { const inspection = JSON.parse( run("docker", ["inspect", containerId], { capture: true }) )[0]; const mount = inspection.Mounts.find( (item) => item.Destination === "/app/.local/font-packs" ); assert(mount, "容器缺少字体包挂载"); assert(mount.RW === false, "字体包挂载不是只读"); assert( path.resolve(mount.Source) === path.resolve(expectedRoot), `字体包挂载源不匹配:${mount.Source}` ); } async function exportDocx(origin) { const response = await fetch(`${origin}/api/docx`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ markdown: "---\ntitle: Docker 字体包验收\n---\n\n# Docker 字体包验收\n\n这是一段可编辑中文正文。", fileName: "Docker 字体包验收.md", language: "zh-CN", resources: [], exportConfig: { ...defaultExportConfig, themeId: "gov-red-standard" } }) }); if (!response.ok) { throw new Error( `DOCX 导出失败(${response.status}):${await response.text()}` ); } return new Uint8Array(await response.arrayBuffer()); } async function runScenario({ name, root, expected }) { const port = await freePort(); const projectName = `mdtp-fp3c-${name}-${randomUUID().slice(0, 8)}`; const environment = { ...process.env, MD_TO_PDF_IMAGE: image, MD_TO_PDF_PORT: String(port), MD_TO_PDF_FONT_PACK_DIR: root, MD_TO_PDF_THEME_DIR: path.join(path.dirname(root), "empty-themes"), MD_TO_PDF_APP_VERSION: "0.6.0" }; await mkdir(environment.MD_TO_PDF_THEME_DIR, { recursive: true }); const compose = [ "compose", "--project-name", projectName, "--file", composeFile ]; try { run( "docker", [...compose, "up", "--detach", "--no-build", "--pull", "never"], { env: environment } ); const containerId = run( "docker", [...compose, "ps", "--quiet", "app"], { capture: true, env: environment } ); assert(containerId, `${name} 场景未创建容器`); inspectFontPackMount(containerId, root); const origin = `http://127.0.0.1:${port}`; await waitForHealth(origin); const capabilityResponse = await fetch( `${origin}/api/docx/capability` ); const capability = await capabilityResponse.json(); assert( capabilityResponse.ok && capability.status === "available" && capability.detectedVersion === "3.9.0.2", `${name} 场景 DOCX capability 无效:${JSON.stringify(capability)}` ); const cssResponse = await fetch( `${origin}/api/themes/gov-red-standard/css` ); const css = await cssResponse.text(); assert(cssResponse.ok, `${name} 场景主题 CSS 请求失败`); const marker = "md-to-pdf-font-packs:mdtp-serif-sc@1.0.0"; assert( css.includes(marker) === expected.fontPackAvailable, `${name} 场景字体包标记状态不符合预期` ); if (expected.fontPackAvailable) { const assetPath = css.match( /(\/api\/font-packs\/[^"'()\s]+\/web\?v=[a-f0-9]+)/u )?.[1]; assert(assetPath, "有效字体包 CSS 缺少受控 WOFF2 地址"); const assetResponse = await fetch(`${origin}${assetPath}`); assert(assetResponse.ok, "有效字体包 WOFF2 请求失败"); assert( assetResponse.headers.get("content-type")?.includes("font/woff2"), "有效字体包 WOFF2 MIME 无效" ); assert( assetResponse.headers.get("cache-control")?.includes("immutable"), "有效字体包 WOFF2 缓存策略无效" ); assert( (await assetResponse.arrayBuffer()).byteLength > 1024 * 1024, "有效字体包 WOFF2 内容异常" ); } if (expected.exportDocx) { const docx = await exportDocx(origin); const entries = unzipSync(docx); const fontParts = Object.keys(entries).filter((entry) => entry.startsWith("word/fonts/") ); assert( fontParts.length >= expected.minimumFontParts, `${name} 场景 DOCX 字体部件不足:${fontParts.length}` ); if (expected.fontPackAvailable) { const fontTable = strFromU8(entries["word/fontTable.xml"]); assert( fontTable.includes("MdTP Serif SC"), "有效字体包未进入 DOCX 字体表" ); } } return { name, fontPackAvailable: expected.fontPackAvailable, docxExported: expected.exportDocx, readOnlyMount: true }; } finally { run( "docker", [...compose, "down", "--remove-orphans", "--timeout", "5"], { env: environment } ); } } const validRootStatus = await stat(validFontPackRoot).catch( () => undefined ); assert( validRootStatus?.isDirectory(), `未找到有效字体包根目录:${validFontPackRoot}。请先运行 npm run build:font-pack。` ); run("docker", ["image", "inspect", image], { capture: true }); run("docker", [ "run", "--rm", "--entrypoint", "sh", image, "-c", "test ! -e /app/.local/font-packs" ]); const temporaryRoot = await mkdtemp( path.join(os.tmpdir(), "mdtp-docker-font-pack-") ); try { const emptyRoot = path.join(temporaryRoot, "empty-font-packs"); const damagedRoot = path.join(temporaryRoot, "damaged-font-packs"); await mkdir(emptyRoot, { recursive: true }); await cp(validFontPackRoot, damagedRoot, { recursive: true }); const manifestPath = path.join( damagedRoot, "mdtp-serif-sc", "1.0.0", "font-pack.json" ); const manifest = JSON.parse(await readFile(manifestPath, "utf8")); const damagedAssetPath = path.join( path.dirname(manifestPath), manifest.faces[0].web.path ); const damagedAsset = await readFile(damagedAssetPath); damagedAsset[0] ^= 0xff; await writeFile(damagedAssetPath, damagedAsset); const results = []; results.push( await runScenario({ name: "empty", root: emptyRoot, expected: { fontPackAvailable: false, exportDocx: false, minimumFontParts: 0 } }) ); results.push( await runScenario({ name: "valid", root: validFontPackRoot, expected: { fontPackAvailable: true, exportDocx: true, minimumFontParts: 2 } }) ); results.push( await runScenario({ name: "damaged", root: damagedRoot, expected: { fontPackAvailable: false, exportDocx: true, minimumFontParts: 0 } }) ); process.stdout.write(`${JSON.stringify({ image, results }, null, 2)}\n`); } finally { await rm(temporaryRoot, { recursive: true, force: true }); }