test: 建立 DOCX 自动化验收门禁
This commit is contained in:
@@ -52,6 +52,7 @@ import {
|
||||
toDesktopDocxExportFailure,
|
||||
type DesktopDocxExportOutcome
|
||||
} from "./docx-contract.js";
|
||||
import { saveDesktopDocx } from "./desktop-docx-save.js";
|
||||
import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js";
|
||||
import {
|
||||
parseMarkdownRenderRequest,
|
||||
@@ -987,16 +988,14 @@ export class DesktopApplicationController {
|
||||
timings: generated.timings
|
||||
};
|
||||
}
|
||||
const targetPath = selection.filePath
|
||||
.toLowerCase()
|
||||
.endsWith(".docx")
|
||||
? selection.filePath
|
||||
: `${selection.filePath}.docx`;
|
||||
await writeFile(targetPath, generated.docx);
|
||||
const saved = await saveDesktopDocx(
|
||||
selection.filePath,
|
||||
generated.docx
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
saved: true,
|
||||
fileName: path.basename(targetPath),
|
||||
fileName: saved.fileName,
|
||||
diagnostics: generated.diagnostics,
|
||||
timings: generated.timings
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
export function resolveDesktopDocxTargetPath(selectedPath: string) {
|
||||
return selectedPath.toLowerCase().endsWith(".docx")
|
||||
? selectedPath
|
||||
: `${selectedPath}.docx`;
|
||||
}
|
||||
|
||||
export async function saveDesktopDocx(
|
||||
selectedPath: string,
|
||||
content: Uint8Array
|
||||
) {
|
||||
if (!selectedPath || content.byteLength === 0) {
|
||||
throw new Error("DOCX 保存参数无效");
|
||||
}
|
||||
const targetPath = resolveDesktopDocxTargetPath(selectedPath);
|
||||
await writeFile(targetPath, content);
|
||||
return {
|
||||
targetPath,
|
||||
fileName: path.basename(targetPath)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDesktopDocxTargetPath,
|
||||
saveDesktopDocx
|
||||
} from "../src/desktop-docx-save.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
async function createTemporaryDirectory() {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "md-to-pdf-desktop-docx-save-")
|
||||
);
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
describe("Desktop DOCX 原生保存", () => {
|
||||
it("在用户未填写扩展名时补充 .docx 并原样落盘", async () => {
|
||||
const directory = await createTemporaryDirectory();
|
||||
const content = Uint8Array.of(80, 75, 3, 4, 1, 2, 3);
|
||||
const selectedPath = path.join(directory, "桌面验收");
|
||||
|
||||
const saved = await saveDesktopDocx(selectedPath, content);
|
||||
|
||||
expect(saved).toEqual({
|
||||
targetPath: `${selectedPath}.docx`,
|
||||
fileName: "桌面验收.docx"
|
||||
});
|
||||
expect(await readFile(saved.targetPath)).toEqual(Buffer.from(content));
|
||||
});
|
||||
|
||||
it("大小写不敏感地保留已有 DOCX 扩展名", () => {
|
||||
expect(resolveDesktopDocxTargetPath("报告.DOCX")).toBe(
|
||||
"报告.DOCX"
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝空路径或空内容", async () => {
|
||||
await expect(
|
||||
saveDesktopDocx("", Uint8Array.of(80, 75))
|
||||
).rejects.toThrow("DOCX 保存参数无效");
|
||||
await expect(
|
||||
saveDesktopDocx("报告.docx", new Uint8Array())
|
||||
).rejects.toThrow("DOCX 保存参数无效");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||
"verify:docx-http": "node scripts/verify-docx-http.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
DOCX_MIME_TYPE,
|
||||
defaultExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import { inspectDocxAcceptance } from "@md-to-pdf/docx-engine";
|
||||
import { buildApp } from "../dist/app.js";
|
||||
|
||||
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryDirectory = path.resolve(directory, "../../..");
|
||||
const outputDirectory = path.join(
|
||||
repositoryDirectory,
|
||||
"output",
|
||||
"docx-acceptance"
|
||||
);
|
||||
const temporaryPrefix = "md-to-pdf-docx-";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function temporaryDirectories() {
|
||||
return new Set(
|
||||
fs
|
||||
.readdirSync(os.tmpdir(), { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() && entry.name.startsWith(temporaryPrefix)
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
);
|
||||
}
|
||||
|
||||
const markdown = `---
|
||||
title: Server HTTP DOCX 验收
|
||||
author: Markdown PDF 导出器研发组
|
||||
---
|
||||
|
||||
# HTTP 导出保持可编辑
|
||||
|
||||
这是一段由真实 Fastify 路由和 Pandoc 生成的可编辑中文正文,包含
|
||||
[验收链接](https://example.invalid/server-docx)。
|
||||
|
||||
1. 原生编号第一项
|
||||
2. 原生编号第二项
|
||||
|
||||
| HTTP 门禁 | 预期 |
|
||||
| --- | --- |
|
||||
| MIME | DOCX |
|
||||
| 内容 | 可编辑 |
|
||||
|
||||
脚注必须保留。[^server-footnote]
|
||||
|
||||
公式 $a^2+b^2=c^2$ 必须使用 OMML。
|
||||
|
||||
[^server-footnote]: Server HTTP 验收脚注。
|
||||
`;
|
||||
const exportConfig = {
|
||||
...defaultExportConfig,
|
||||
themeId: "typora-like",
|
||||
pageDecorationsMode: "custom",
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
marginMode: "custom",
|
||||
margins: {
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
bottom: "16mm",
|
||||
left: "16mm"
|
||||
}
|
||||
}
|
||||
};
|
||||
const mediaAdapter = {
|
||||
async capture() {
|
||||
return {
|
||||
plan: {
|
||||
targets: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
},
|
||||
captures: []
|
||||
};
|
||||
},
|
||||
async close() {}
|
||||
};
|
||||
const pdfGenerator = {
|
||||
async generate() {
|
||||
throw new Error("DOCX HTTP 验收不得调用 PDF");
|
||||
},
|
||||
async close() {}
|
||||
};
|
||||
const beforeTemporaryDirectories = temporaryDirectories();
|
||||
const app = buildApp({
|
||||
logger: false,
|
||||
pdfGenerator,
|
||||
docxMediaAdapter: mediaAdapter
|
||||
});
|
||||
|
||||
let response;
|
||||
try {
|
||||
const capabilityResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/docx/capability"
|
||||
});
|
||||
assert(capabilityResponse.statusCode === 200, "DOCX capability 请求失败");
|
||||
assert(
|
||||
capabilityResponse.json().status === "available",
|
||||
`DOCX capability 不可用:${capabilityResponse.body}`
|
||||
);
|
||||
|
||||
response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload: {
|
||||
markdown,
|
||||
fileName: "目录/Server HTTP 验收.md",
|
||||
language: "zh-CN",
|
||||
resources: [],
|
||||
exportConfig
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
assert(response.statusCode === 200, `DOCX HTTP 返回 ${response.statusCode}`);
|
||||
assert(
|
||||
response.headers["content-type"]?.includes(DOCX_MIME_TYPE),
|
||||
`DOCX MIME 无效:${response.headers["content-type"]}`
|
||||
);
|
||||
assert(
|
||||
response.headers["content-disposition"]?.includes(
|
||||
"Server%20HTTP%20%E9%AA%8C%E6%94%B6.docx"
|
||||
),
|
||||
`DOCX 文件名无效:${response.headers["content-disposition"]}`
|
||||
);
|
||||
assert(
|
||||
response.headers["cache-control"] === "no-store",
|
||||
"DOCX HTTP 响应必须禁用缓存"
|
||||
);
|
||||
for (const timing of [
|
||||
"runtime-probe",
|
||||
"prepare",
|
||||
"media",
|
||||
"reference",
|
||||
"pandoc",
|
||||
"validation",
|
||||
"total"
|
||||
]) {
|
||||
assert(
|
||||
response.headers["server-timing"]?.includes(`${timing};dur=`),
|
||||
`Server-Timing 缺少 ${timing}`
|
||||
);
|
||||
}
|
||||
assert(
|
||||
response.headers["x-docx-warning-count"] === "0",
|
||||
"无媒体 HTTP 验收不应产生 DOCX 警告"
|
||||
);
|
||||
|
||||
const report = inspectDocxAcceptance(response.rawPayload, {
|
||||
id: "server-http",
|
||||
page: {
|
||||
widthTwips: 11906,
|
||||
heightTwips: 16838,
|
||||
orientation: "portrait",
|
||||
marginsTwips: {
|
||||
top: 907,
|
||||
right: 907,
|
||||
bottom: 907,
|
||||
left: 907
|
||||
}
|
||||
},
|
||||
requiredText: [
|
||||
"真实 Fastify 路由",
|
||||
"原生编号第一项",
|
||||
"HTTP 门禁"
|
||||
],
|
||||
requiredFootnoteText: ["Server HTTP 验收脚注"],
|
||||
minimumParagraphs: 8,
|
||||
minimumTextRuns: 12,
|
||||
minimumTables: 1,
|
||||
minimumNumberedParagraphs: 2,
|
||||
minimumHyperlinks: 1,
|
||||
minimumFootnoteReferences: 1,
|
||||
minimumMathObjects: 1,
|
||||
requiredStyleIds: [
|
||||
"Normal",
|
||||
"Heading1",
|
||||
"SourceCode",
|
||||
"Table",
|
||||
"Caption"
|
||||
],
|
||||
requirePageField: true
|
||||
});
|
||||
|
||||
const afterTemporaryDirectories = temporaryDirectories();
|
||||
const leakedTemporaryDirectories = [...afterTemporaryDirectories].filter(
|
||||
(name) => !beforeTemporaryDirectories.has(name)
|
||||
);
|
||||
assert(
|
||||
leakedTemporaryDirectories.length === 0,
|
||||
`DOCX HTTP 遗留临时目录:${leakedTemporaryDirectories.join(", ")}`
|
||||
);
|
||||
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
const outputPath = path.join(outputDirectory, "server-http.docx");
|
||||
const reportPath = path.join(
|
||||
outputDirectory,
|
||||
"server-http-report.json"
|
||||
);
|
||||
fs.writeFileSync(outputPath, response.rawPayload);
|
||||
const result = {
|
||||
outputFile: path.relative(repositoryDirectory, outputPath),
|
||||
bytes: response.rawPayload.byteLength,
|
||||
headers: {
|
||||
contentType: response.headers["content-type"],
|
||||
contentDisposition: response.headers["content-disposition"],
|
||||
cacheControl: response.headers["cache-control"],
|
||||
serverTiming: response.headers["server-timing"],
|
||||
warningCount: response.headers["x-docx-warning-count"],
|
||||
echartsErrorCount: response.headers["x-echarts-error-count"],
|
||||
mermaidErrorCount: response.headers["x-mermaid-error-count"]
|
||||
},
|
||||
leakedTemporaryDirectories,
|
||||
acceptance: report
|
||||
};
|
||||
fs.writeFileSync(
|
||||
reportPath,
|
||||
`${JSON.stringify(result, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
Reference in New Issue
Block a user