feat: 实现 Web 与 Desktop DOCX 导出交互
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0",
|
||||
"fastify": "^5.6.2",
|
||||
"playwright": "1.62.0"
|
||||
|
||||
+180
-13
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
|
||||
import Fastify from "fastify";
|
||||
import {
|
||||
EXPORT_CONFIG_VERSION,
|
||||
DOCX_MIME_TYPE,
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
exportConfigSchema,
|
||||
@@ -11,10 +12,21 @@ import {
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportService,
|
||||
DocxExportServiceError,
|
||||
createApplicationService,
|
||||
readDocxExportRuntimeLimits,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
PandocDocxConverter,
|
||||
PandocRuntime
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
||||
import {
|
||||
createDocxMediaEngine,
|
||||
type ServerDocxMediaCaptureAdapter
|
||||
} from "./docx-media-engine.js";
|
||||
import {
|
||||
createPdfGenerator,
|
||||
PdfEngineClosedError,
|
||||
@@ -68,7 +80,13 @@ export interface BuildAppOptions {
|
||||
logger?: boolean;
|
||||
pdfGenerator?: PdfGenerator;
|
||||
prewarmPdfBrowser?: boolean;
|
||||
prewarmDocxRuntime?: boolean;
|
||||
applicationService?: ApplicationService;
|
||||
docxExportService?: Pick<
|
||||
DocxExportService,
|
||||
"getCapability" | "generate" | "close"
|
||||
>;
|
||||
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
|
||||
}
|
||||
|
||||
function milliseconds(value: number) {
|
||||
@@ -106,6 +124,30 @@ function createPdfServerTiming(
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
function createDocxServerTiming(
|
||||
timings: Awaited<
|
||||
ReturnType<DocxExportService["generate"]>
|
||||
>["timings"]
|
||||
) {
|
||||
return [
|
||||
`queue;dur=${milliseconds(timings.queueMs)}`,
|
||||
`runtime-probe;dur=${milliseconds(timings.probeMs)}`,
|
||||
`prepare;dur=${milliseconds(timings.prepareMs)}`,
|
||||
`media;dur=${milliseconds(timings.mediaMs)}`,
|
||||
`reference;dur=${milliseconds(timings.referenceMs)}`,
|
||||
`pandoc;dur=${milliseconds(timings.pandocMs)}`,
|
||||
`validation;dur=${milliseconds(timings.validationMs)}`,
|
||||
`total;dur=${milliseconds(timings.totalMs)}`
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
export function createDocxContentDisposition(fileName: string) {
|
||||
return (
|
||||
`attachment; filename="document.docx"; ` +
|
||||
`filename*=UTF-8''${encodeRfc5987(fileName)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function buildApp(options: BuildAppOptions = {}) {
|
||||
const app = Fastify({
|
||||
logger: options.logger ?? true,
|
||||
@@ -121,6 +163,21 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
});
|
||||
const pdfGenerator =
|
||||
options.pdfGenerator ?? createPdfGenerator();
|
||||
const pandocRuntime = options.docxExportService
|
||||
? undefined
|
||||
: new PandocRuntime();
|
||||
const docxExportService =
|
||||
options.docxExportService ??
|
||||
new DocxExportService({
|
||||
application: applicationService,
|
||||
runtime: pandocRuntime!,
|
||||
converter: new PandocDocxConverter({
|
||||
runtime: pandocRuntime!
|
||||
}),
|
||||
limits: readDocxExportRuntimeLimits()
|
||||
});
|
||||
const docxMediaAdapter =
|
||||
options.docxMediaAdapter ?? createDocxMediaEngine();
|
||||
|
||||
if (options.prewarmPdfBrowser) {
|
||||
app.addHook("onReady", async () => {
|
||||
@@ -132,8 +189,27 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
if (options.prewarmDocxRuntime) {
|
||||
app.addHook("onReady", async () => {
|
||||
try {
|
||||
const capability =
|
||||
await docxExportService.getCapability();
|
||||
if (capability.status !== "available") {
|
||||
app.log.warn(
|
||||
{ capability },
|
||||
"DOCX runtime unavailable"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
app.log.warn({ error }, "DOCX runtime probe failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
await pdfGenerator.close();
|
||||
await docxExportService.close();
|
||||
await docxMediaAdapter.close();
|
||||
});
|
||||
|
||||
app.get("/api/health", async () => ({
|
||||
@@ -143,19 +219,28 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
rendererVersion: RENDERER_VERSION
|
||||
}));
|
||||
|
||||
app.get("/api/capabilities", async () => ({
|
||||
defaultExportConfig,
|
||||
supportedPaperFormats,
|
||||
implemented: [
|
||||
"project-skeleton",
|
||||
"export-config",
|
||||
"theme-manifest",
|
||||
"markdown-render",
|
||||
"html-preview",
|
||||
"pdf-export"
|
||||
],
|
||||
planned: []
|
||||
}));
|
||||
app.get("/api/capabilities", async () => {
|
||||
const docx = await docxExportService.getCapability();
|
||||
return {
|
||||
defaultExportConfig,
|
||||
supportedPaperFormats,
|
||||
docx,
|
||||
implemented: [
|
||||
"project-skeleton",
|
||||
"export-config",
|
||||
"theme-manifest",
|
||||
"markdown-render",
|
||||
"html-preview",
|
||||
"pdf-export",
|
||||
"docx-export"
|
||||
],
|
||||
planned: []
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/api/docx/capability", async () =>
|
||||
docxExportService.getCapability()
|
||||
);
|
||||
|
||||
app.get("/api/themes", async () =>
|
||||
applicationService.listThemes()
|
||||
@@ -364,5 +449,87 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: unknown }>("/api/docx", async (request, reply) => {
|
||||
const abortController = new AbortController();
|
||||
const abortRequest = () =>
|
||||
abortController.abort(new Error("HTTP 请求已中断"));
|
||||
request.raw.once("aborted", abortRequest);
|
||||
|
||||
try {
|
||||
const generated = await docxExportService.generate(
|
||||
request.body ?? {},
|
||||
{
|
||||
mediaAdapter: docxMediaAdapter,
|
||||
signal: abortController.signal
|
||||
}
|
||||
);
|
||||
request.log.info(
|
||||
{
|
||||
fileName: generated.fileName,
|
||||
bytes: generated.docx.byteLength,
|
||||
diagnostics: generated.diagnostics,
|
||||
timings: generated.timings
|
||||
},
|
||||
"DOCX generated"
|
||||
);
|
||||
return reply
|
||||
.header("content-type", DOCX_MIME_TYPE)
|
||||
.header(
|
||||
"content-disposition",
|
||||
createDocxContentDisposition(generated.fileName)
|
||||
)
|
||||
.header("cache-control", "no-store")
|
||||
.header(
|
||||
"server-timing",
|
||||
createDocxServerTiming(generated.timings)
|
||||
)
|
||||
.header(
|
||||
"x-docx-warning-count",
|
||||
String(generated.diagnostics.warnings.length)
|
||||
)
|
||||
.header(
|
||||
"x-echarts-error-count",
|
||||
String(generated.diagnostics.echartsErrors.length)
|
||||
)
|
||||
.header(
|
||||
"x-mermaid-error-count",
|
||||
String(generated.diagnostics.mermaidErrors.length)
|
||||
)
|
||||
.send(
|
||||
Buffer.from(
|
||||
generated.docx.buffer,
|
||||
generated.docx.byteOffset,
|
||||
generated.docx.byteLength
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
retryable: false
|
||||
});
|
||||
}
|
||||
if (error instanceof DocxExportServiceError) {
|
||||
if (error.retryable) {
|
||||
reply.header("retry-after", "5");
|
||||
}
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
retryable: error.retryable
|
||||
});
|
||||
}
|
||||
request.log.error({ error }, "DOCX generation failed");
|
||||
return reply.code(500).send({
|
||||
error: "DOCX_GENERATION_FAILED",
|
||||
message: "DOCX 生成失败",
|
||||
retryable: false
|
||||
});
|
||||
} finally {
|
||||
request.raw.removeListener("aborted", abortRequest);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { chromium, type Browser, type BrowserContext } from "playwright";
|
||||
import type {
|
||||
DocxMediaCaptureAdapter,
|
||||
DocxMediaCaptureRequest
|
||||
} from "@md-to-pdf/application";
|
||||
import { captureDocxMediaWithPlaywrightPage } from "./playwright-docx-media-capture.js";
|
||||
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
|
||||
|
||||
export interface ServerDocxMediaCaptureAdapter
|
||||
extends DocxMediaCaptureAdapter {
|
||||
warmup?(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PlaywrightDocxMediaEngineOptions {
|
||||
renderOrigin?: string;
|
||||
launchBrowser?: () => Promise<Browser>;
|
||||
}
|
||||
|
||||
function normalizeRenderOrigin(value: string) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("DOCX 媒体渲染地址必须使用 HTTP 或 HTTPS");
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
async function closeContext(context: BrowserContext) {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export class PlaywrightDocxMediaEngine
|
||||
implements ServerDocxMediaCaptureAdapter
|
||||
{
|
||||
private readonly renderOrigin: string;
|
||||
private readonly renderUrl: string;
|
||||
private readonly launchBrowser: () => Promise<Browser>;
|
||||
private browserPromise: Promise<Browser> | undefined;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: PlaywrightDocxMediaEngineOptions = {}) {
|
||||
this.renderOrigin = normalizeRenderOrigin(
|
||||
options.renderOrigin ??
|
||||
process.env.PDF_RENDER_ORIGIN ??
|
||||
"http://localhost:5173"
|
||||
);
|
||||
this.renderUrl = new URL(
|
||||
"/preview-frame.html?target=continuous",
|
||||
this.renderOrigin
|
||||
).href;
|
||||
this.launchBrowser =
|
||||
options.launchBrowser ??
|
||||
(() =>
|
||||
chromium.launch({
|
||||
headless: true
|
||||
}));
|
||||
}
|
||||
|
||||
async warmup() {
|
||||
if (this.closed) {
|
||||
throw new Error("DOCX 媒体服务已关闭");
|
||||
}
|
||||
await this.getBrowser();
|
||||
}
|
||||
|
||||
async capture(
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (this.closed) {
|
||||
throw new Error("DOCX 媒体服务已关闭");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const browser = await this.getBrowser();
|
||||
signal?.throwIfAborted();
|
||||
const context = await browser.newContext({
|
||||
locale: request.payload.metadata.language || "zh-CN",
|
||||
serviceWorkers: "block"
|
||||
});
|
||||
const abort = () => {
|
||||
void closeContext(context);
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
|
||||
try {
|
||||
await context.route("**/*", async (route) => {
|
||||
if (
|
||||
isAllowedPdfRequestUrl(
|
||||
route.request().url(),
|
||||
this.renderOrigin
|
||||
)
|
||||
) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.abort("blockedbyclient");
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(this.renderUrl, {
|
||||
waitUntil: "domcontentloaded"
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.documentElement.dataset.runtimeReady === "true"
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return await captureDocxMediaWithPlaywrightPage(
|
||||
page,
|
||||
request,
|
||||
signal
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
await closeContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
const browserPromise = this.browserPromise;
|
||||
this.browserPromise = undefined;
|
||||
if (!browserPromise) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const browser = await browserPromise;
|
||||
await browser.close();
|
||||
} catch {
|
||||
// 浏览器启动失败时没有可关闭的实例。
|
||||
}
|
||||
}
|
||||
|
||||
private getBrowser() {
|
||||
if (!this.browserPromise) {
|
||||
const browserPromise = this.launchBrowser();
|
||||
this.browserPromise = browserPromise;
|
||||
void browserPromise
|
||||
.then((browser) => {
|
||||
browser.on("disconnected", () => {
|
||||
if (this.browserPromise === browserPromise) {
|
||||
this.browserPromise = undefined;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.browserPromise === browserPromise) {
|
||||
this.browserPromise = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.browserPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDocxMediaEngine(
|
||||
options: PlaywrightDocxMediaEngineOptions = {}
|
||||
) {
|
||||
return new PlaywrightDocxMediaEngine(options);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { buildApp } from "./app.js";
|
||||
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const app = buildApp({
|
||||
prewarmPdfBrowser: true
|
||||
prewarmPdfBrowser: true,
|
||||
prewarmDocxRuntime: true
|
||||
});
|
||||
|
||||
async function start() {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportServiceError,
|
||||
type DocxMediaCaptureAdapter
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
DOCX_MIME_TYPE,
|
||||
defaultExportConfig,
|
||||
type DocxCapability,
|
||||
type DocxExportResult
|
||||
} from "@md-to-pdf/core";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import type { PdfGenerator } from "../src/pdf-engine.js";
|
||||
import type { ServerDocxMediaCaptureAdapter } from "../src/docx-media-engine.js";
|
||||
|
||||
let app: FastifyInstance | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close();
|
||||
app = undefined;
|
||||
});
|
||||
|
||||
const availableCapability: DocxCapability = {
|
||||
format: "docx",
|
||||
status: "available",
|
||||
expectedVersion: "3.9.0.2",
|
||||
detectedVersion: "3.9.0.2"
|
||||
};
|
||||
|
||||
function generatedDocx(): DocxExportResult {
|
||||
return {
|
||||
docx: new Uint8Array([80, 75, 3, 4]),
|
||||
fileName: "测试报告.docx",
|
||||
diagnostics: {
|
||||
warnings: ["字体可能被替换"],
|
||||
echartsErrors: ["图表 1"],
|
||||
mermaidErrors: []
|
||||
},
|
||||
timings: {
|
||||
queueMs: 1,
|
||||
probeMs: 2,
|
||||
prepareMs: 3,
|
||||
mediaMs: 4,
|
||||
referenceMs: 5,
|
||||
pandocMs: 6,
|
||||
validationMs: 7,
|
||||
totalMs: 28
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createDocxService(
|
||||
overrides: {
|
||||
capability?: DocxCapability;
|
||||
generate?: (request: unknown) => Promise<DocxExportResult>;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
getCapability: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
overrides.capability ?? availableCapability
|
||||
),
|
||||
generate: vi.fn(
|
||||
overrides.generate ?? (async () => generatedDocx())
|
||||
),
|
||||
close: vi.fn(async () => undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function createMediaAdapter() {
|
||||
return {
|
||||
capture: vi.fn<
|
||||
DocxMediaCaptureAdapter["capture"]
|
||||
>(async () => ({
|
||||
plan: {
|
||||
targets: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
},
|
||||
captures: []
|
||||
})),
|
||||
close: vi.fn(async () => undefined)
|
||||
} satisfies ServerDocxMediaCaptureAdapter;
|
||||
}
|
||||
|
||||
function createPdfGenerator() {
|
||||
return {
|
||||
generate: vi.fn(),
|
||||
close: vi.fn(async () => undefined)
|
||||
} as unknown as PdfGenerator;
|
||||
}
|
||||
|
||||
function createTestApp(
|
||||
service = createDocxService(),
|
||||
mediaAdapter = createMediaAdapter()
|
||||
) {
|
||||
app = buildApp({
|
||||
logger: false,
|
||||
pdfGenerator: createPdfGenerator(),
|
||||
docxExportService: service,
|
||||
docxMediaAdapter: mediaAdapter
|
||||
});
|
||||
return { app, service, mediaAdapter };
|
||||
}
|
||||
|
||||
describe("DOCX HTTP API", () => {
|
||||
it("在统一 capability 中报告 DOCX 运行时状态", async () => {
|
||||
const { app: instance, service } = createTestApp();
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "GET",
|
||||
url: "/api/capabilities"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().docx).toEqual(availableCapability);
|
||||
expect(response.json().implemented).toContain("docx-export");
|
||||
expect(service.getCapability).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("通过独立端点返回不可用 capability", async () => {
|
||||
const capability: DocxCapability = {
|
||||
format: "docx",
|
||||
status: "not-found",
|
||||
expectedVersion: "3.9.0.2",
|
||||
message: "未找到 Pandoc 运行时"
|
||||
};
|
||||
const { app: instance } = createTestApp(
|
||||
createDocxService({ capability })
|
||||
);
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "GET",
|
||||
url: "/api/docx/capability"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual(capability);
|
||||
});
|
||||
|
||||
it("生成 DOCX 并返回安全文件名、诊断和耗时", async () => {
|
||||
const { app: instance, service, mediaAdapter } =
|
||||
createTestApp();
|
||||
const payload = {
|
||||
markdown: "# 测试报告",
|
||||
fileName: "目录/测试报告.md",
|
||||
language: "zh-CN",
|
||||
resources: [],
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain(
|
||||
DOCX_MIME_TYPE
|
||||
);
|
||||
expect(response.headers["content-disposition"]).toContain(
|
||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%8A%A5%E5%91%8A.docx"
|
||||
);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.headers["x-docx-warning-count"]).toBe("1");
|
||||
expect(response.headers["x-echarts-error-count"]).toBe("1");
|
||||
expect(response.headers["x-mermaid-error-count"]).toBe("0");
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"runtime-probe;dur=2.0"
|
||||
);
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"pandoc;dur=6.0"
|
||||
);
|
||||
expect(response.rawPayload).toEqual(
|
||||
Buffer.from([80, 75, 3, 4])
|
||||
);
|
||||
expect(service.generate).toHaveBeenCalledWith(payload, {
|
||||
mediaAdapter,
|
||||
signal: expect.any(AbortSignal)
|
||||
});
|
||||
});
|
||||
|
||||
it("映射应用请求错误", async () => {
|
||||
const service = createDocxService({
|
||||
generate: async () => {
|
||||
throw new ApplicationRequestError(
|
||||
400,
|
||||
"INVALID_EXPORT_CONFIG",
|
||||
"导出配置无效"
|
||||
);
|
||||
}
|
||||
});
|
||||
const { app: instance } = createTestApp(service);
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload: {}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({
|
||||
error: "INVALID_EXPORT_CONFIG",
|
||||
message: "导出配置无效",
|
||||
retryable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("映射可重试的队列错误", async () => {
|
||||
const service = createDocxService({
|
||||
generate: async () => {
|
||||
throw new DocxExportServiceError(
|
||||
429,
|
||||
"DOCX_QUEUE_FULL",
|
||||
"DOCX 生成队列已满",
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
const { app: instance } = createTestApp(service);
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload: {}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(429);
|
||||
expect(response.headers["retry-after"]).toBe("5");
|
||||
expect(response.json()).toEqual({
|
||||
error: "DOCX_QUEUE_FULL",
|
||||
message: "DOCX 生成队列已满",
|
||||
retryable: true
|
||||
});
|
||||
});
|
||||
|
||||
it("关闭应用时按顺序释放共享服务与媒体浏览器", async () => {
|
||||
const service = createDocxService();
|
||||
const mediaAdapter = createMediaAdapter();
|
||||
const { app: instance } = createTestApp(
|
||||
service,
|
||||
mediaAdapter
|
||||
);
|
||||
|
||||
await instance.close();
|
||||
|
||||
expect(service.close).toHaveBeenCalledTimes(1);
|
||||
expect(mediaAdapter.close).toHaveBeenCalledTimes(1);
|
||||
app = undefined;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Browser } from "playwright";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
type DocxMediaCapturePlan
|
||||
} from "@md-to-pdf/core";
|
||||
import { PlaywrightDocxMediaEngine } from "../src/docx-media-engine.js";
|
||||
|
||||
const emptyPlan: DocxMediaCapturePlan = {
|
||||
targets: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
};
|
||||
|
||||
const request = {
|
||||
payload: createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write"></article>',
|
||||
bodyHtml: "",
|
||||
metadata: {
|
||||
title: "",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "test.md",
|
||||
themeCss: "",
|
||||
exportConfig: defaultExportConfig
|
||||
}),
|
||||
dimensions: {
|
||||
contentWidthPx: 600,
|
||||
contentHeightPx: 900
|
||||
}
|
||||
};
|
||||
|
||||
describe("Server DOCX 媒体浏览器", () => {
|
||||
it("使用请求级上下文并阻止非同源网络访问", async () => {
|
||||
const continueRoute = vi.fn(async () => undefined);
|
||||
const abortRoute = vi.fn(async () => undefined);
|
||||
const detach = vi.fn(async () => undefined);
|
||||
const page = {
|
||||
goto: vi.fn(async () => undefined),
|
||||
waitForFunction: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => emptyPlan),
|
||||
context: () => context
|
||||
};
|
||||
const context = {
|
||||
route: vi.fn(
|
||||
async (
|
||||
_pattern: string,
|
||||
handler: (route: {
|
||||
request(): { url(): string };
|
||||
continue(): Promise<void>;
|
||||
abort(reason: string): Promise<void>;
|
||||
}) => Promise<void>
|
||||
) => {
|
||||
await handler({
|
||||
request: () => ({
|
||||
url: () => "http://renderer.test/assets/runtime.js"
|
||||
}),
|
||||
continue: continueRoute,
|
||||
abort: abortRoute
|
||||
});
|
||||
await handler({
|
||||
request: () => ({
|
||||
url: () => "https://external.test/tracker.js"
|
||||
}),
|
||||
continue: continueRoute,
|
||||
abort: abortRoute
|
||||
});
|
||||
}
|
||||
),
|
||||
newPage: vi.fn(async () => page),
|
||||
newCDPSession: vi.fn(async () => ({
|
||||
send: vi.fn(),
|
||||
detach
|
||||
})),
|
||||
close: vi.fn(async () => undefined)
|
||||
};
|
||||
const browser = {
|
||||
newContext: vi.fn(async () => context),
|
||||
on: vi.fn(),
|
||||
close: vi.fn(async () => undefined)
|
||||
} as unknown as Browser;
|
||||
const engine = new PlaywrightDocxMediaEngine({
|
||||
renderOrigin: "http://renderer.test",
|
||||
launchBrowser: vi.fn(async () => browser)
|
||||
});
|
||||
|
||||
const result = await engine.capture(request);
|
||||
|
||||
expect(result.plan).toEqual(emptyPlan);
|
||||
expect(browser.newContext).toHaveBeenCalledWith({
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block"
|
||||
});
|
||||
expect(page.goto).toHaveBeenCalledWith(
|
||||
"http://renderer.test/preview-frame.html?target=continuous",
|
||||
{ waitUntil: "domcontentloaded" }
|
||||
);
|
||||
expect(continueRoute).toHaveBeenCalledTimes(1);
|
||||
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
|
||||
expect(detach).toHaveBeenCalledOnce();
|
||||
expect(context.close).toHaveBeenCalledOnce();
|
||||
|
||||
await engine.close();
|
||||
expect(browser.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("关闭后拒绝预热和媒体捕获", async () => {
|
||||
const engine = new PlaywrightDocxMediaEngine({
|
||||
renderOrigin: "http://renderer.test",
|
||||
launchBrowser: vi.fn()
|
||||
});
|
||||
|
||||
await engine.close();
|
||||
|
||||
await expect(engine.warmup()).rejects.toThrow(
|
||||
"DOCX 媒体服务已关闭"
|
||||
);
|
||||
await expect(engine.capture(request)).rejects.toThrow(
|
||||
"DOCX 媒体服务已关闭"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user