fix: 修复桌面 DOCX 媒体捕获超时
This commit is contained in:
@@ -31,6 +31,18 @@ export type DesktopDocxExportOutcome =
|
|||||||
|
|
||||||
export type DesktopDocxCapability = DocxCapability;
|
export type DesktopDocxCapability = DocxCapability;
|
||||||
|
|
||||||
|
function readSafeDesktopMediaDetail(error: DocxExportServiceError) {
|
||||||
|
const cause = error.cause;
|
||||||
|
if (!(cause instanceof Error)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return /^桌面 DOCX (?:媒体布局超过 \d+ms|第 \d+\/\d+ 个媒体((?:image|mermaid|echarts))PNG 捕获超过 \d+ms)$/u.test(
|
||||||
|
cause.message
|
||||||
|
)
|
||||||
|
? cause.message
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function toDesktopDocxExportFailure(
|
export function toDesktopDocxExportFailure(
|
||||||
error: unknown
|
error: unknown
|
||||||
): DesktopDocxExportFailure {
|
): DesktopDocxExportFailure {
|
||||||
@@ -54,10 +66,11 @@ export function toDesktopDocxExportFailure(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (error instanceof DocxExportServiceError) {
|
if (error instanceof DocxExportServiceError) {
|
||||||
|
const detail = readSafeDesktopMediaDetail(error);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error.code,
|
error: error.code,
|
||||||
message: error.message,
|
message: detail ? `${error.message}:${detail}` : error.message,
|
||||||
retryable: error.retryable
|
retryable: error.retryable
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,51 @@ interface CaptureScreenshotResult {
|
|||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElectronDocxMediaCaptureTimeouts {
|
||||||
|
renderMs: number;
|
||||||
|
screenshotMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CAPTURE_TIMEOUTS: ElectronDocxMediaCaptureTimeouts = {
|
||||||
|
renderMs: 30_000,
|
||||||
|
screenshotMs: 20_000
|
||||||
|
};
|
||||||
|
|
||||||
|
function withTimeout<T>(
|
||||||
|
operation: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
message: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
const abort = () =>
|
||||||
|
finish(() => reject(signal?.reason ?? new Error("操作已取消")));
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => finish(() => reject(new Error(message))),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
if (signal?.aborted) {
|
||||||
|
abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
operation.then(
|
||||||
|
(value) => finish(() => resolve(value)),
|
||||||
|
(error) => finish(() => reject(error))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createRenderScript(request: DocxMediaCaptureRequest) {
|
function createRenderScript(request: DocxMediaCaptureRequest) {
|
||||||
const serialized = JSON.stringify(request);
|
const serialized = JSON.stringify(request);
|
||||||
return `(async () => {
|
return `(async () => {
|
||||||
@@ -24,12 +69,15 @@ function createRenderScript(request: DocxMediaCaptureRequest) {
|
|||||||
export async function captureDocxMediaWithElectronWebContents(
|
export async function captureDocxMediaWithElectronWebContents(
|
||||||
webContents: WebContents,
|
webContents: WebContents,
|
||||||
request: DocxMediaCaptureRequest,
|
request: DocxMediaCaptureRequest,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
|
||||||
) {
|
) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const plan = (await webContents.executeJavaScript(
|
const plan = (await withTimeout(
|
||||||
createRenderScript(request),
|
webContents.executeJavaScript(createRenderScript(request), true),
|
||||||
true
|
timeouts.renderMs,
|
||||||
|
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
|
||||||
|
signal
|
||||||
)) as DocxMediaCapturePlan;
|
)) as DocxMediaCapturePlan;
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const ownsDebugger = !webContents.debugger.isAttached();
|
const ownsDebugger = !webContents.debugger.isAttached();
|
||||||
@@ -41,7 +89,8 @@ export async function captureDocxMediaWithElectronWebContents(
|
|||||||
const captures = [];
|
const captures = [];
|
||||||
for (const target of plan.targets) {
|
for (const target of plan.targets) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const result = (await webContents.debugger.sendCommand(
|
const result = (await withTimeout(
|
||||||
|
webContents.debugger.sendCommand(
|
||||||
"Page.captureScreenshot",
|
"Page.captureScreenshot",
|
||||||
{
|
{
|
||||||
format: "png",
|
format: "png",
|
||||||
@@ -55,6 +104,10 @@ export async function captureDocxMediaWithElectronWebContents(
|
|||||||
scale: target.rasterScale
|
scale: target.rasterScale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
timeouts.screenshotMs,
|
||||||
|
`桌面 DOCX 第 ${target.ordinal}/${plan.targets.length} 个媒体(${target.kind})PNG 捕获超过 ${timeouts.screenshotMs}ms`,
|
||||||
|
signal
|
||||||
)) as CaptureScreenshotResult;
|
)) as CaptureScreenshotResult;
|
||||||
captures.push({
|
captures.push({
|
||||||
id: target.id,
|
id: target.id,
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ export interface ElectronDocxMediaEngineOptions {
|
|||||||
renderUrl: string;
|
renderUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function withActiveDocxCaptureSurface<T>(
|
||||||
|
window: Pick<
|
||||||
|
BrowserWindow,
|
||||||
|
"showInactive" | "hide" | "isDestroyed"
|
||||||
|
>,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
settleMs = 50
|
||||||
|
) {
|
||||||
|
window.showInactive();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ElectronDocxMediaEngine
|
export class ElectronDocxMediaEngine
|
||||||
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
|
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
|
||||||
{
|
{
|
||||||
@@ -98,10 +117,14 @@ export class ElectronDocxMediaEngine
|
|||||||
};
|
};
|
||||||
signal?.addEventListener("abort", abort, { once: true });
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
try {
|
try {
|
||||||
return await captureDocxMediaWithElectronWebContents(
|
return await withActiveDocxCaptureSurface(
|
||||||
|
window,
|
||||||
|
() =>
|
||||||
|
captureDocxMediaWithElectronWebContents(
|
||||||
window.webContents,
|
window.webContents,
|
||||||
request,
|
request,
|
||||||
signal
|
signal
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
signal?.removeEventListener("abort", abort);
|
signal?.removeEventListener("abort", abort);
|
||||||
|
|||||||
@@ -41,6 +41,40 @@ describe("Desktop DOCX IPC 契约", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("仅透传格式受控的桌面媒体超时详情", () => {
|
||||||
|
expect(
|
||||||
|
toDesktopDocxExportFailure(
|
||||||
|
new DocxExportServiceError(
|
||||||
|
500,
|
||||||
|
"DOCX_GENERATION_FAILED",
|
||||||
|
"DOCX 导出失败",
|
||||||
|
false,
|
||||||
|
{ cause: new Error("桌面 DOCX 媒体布局超过 30000ms") }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toMatchObject({
|
||||||
|
message: "DOCX 导出失败:桌面 DOCX 媒体布局超过 30000ms"
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
toDesktopDocxExportFailure(
|
||||||
|
new DocxExportServiceError(
|
||||||
|
500,
|
||||||
|
"DOCX_GENERATION_FAILED",
|
||||||
|
"DOCX 导出失败",
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
cause: new Error(
|
||||||
|
"桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toMatchObject({
|
||||||
|
message:
|
||||||
|
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("保留 Front Matter 解析错误", () => {
|
it("保留 Front Matter 解析错误", () => {
|
||||||
expect(
|
expect(
|
||||||
toDesktopDocxExportFailure(
|
toDesktopDocxExportFailure(
|
||||||
|
|||||||
@@ -104,4 +104,52 @@ describe("Electron DOCX 媒体捕获", () => {
|
|||||||
);
|
);
|
||||||
expect(detach).toHaveBeenCalledOnce();
|
expect(detach).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("媒体布局永久不返回时报告局部超时", async () => {
|
||||||
|
const webContents = {
|
||||||
|
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
|
||||||
|
debugger: {
|
||||||
|
isAttached: vi.fn(() => false)
|
||||||
|
}
|
||||||
|
} as unknown as WebContents;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
captureDocxMediaWithElectronWebContents(
|
||||||
|
webContents,
|
||||||
|
request,
|
||||||
|
undefined,
|
||||||
|
{ renderMs: 10, screenshotMs: 10 }
|
||||||
|
)
|
||||||
|
).rejects.toThrow("桌面 DOCX 媒体布局超过 10ms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("单个 PNG 永久不返回时报告媒体编号和类型", async () => {
|
||||||
|
let attached = false;
|
||||||
|
const detach = vi.fn(() => {
|
||||||
|
attached = false;
|
||||||
|
});
|
||||||
|
const webContents = {
|
||||||
|
executeJavaScript: vi.fn(async () => plan),
|
||||||
|
debugger: {
|
||||||
|
isAttached: vi.fn(() => attached),
|
||||||
|
attach: vi.fn(() => {
|
||||||
|
attached = true;
|
||||||
|
}),
|
||||||
|
detach,
|
||||||
|
sendCommand: vi.fn(() => new Promise(() => undefined))
|
||||||
|
}
|
||||||
|
} as unknown as WebContents;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
captureDocxMediaWithElectronWebContents(
|
||||||
|
webContents,
|
||||||
|
request,
|
||||||
|
undefined,
|
||||||
|
{ renderMs: 10, screenshotMs: 10 }
|
||||||
|
)
|
||||||
|
).rejects.toThrow(
|
||||||
|
"桌面 DOCX 第 1/1 个媒体(image)PNG 捕获超过 10ms"
|
||||||
|
);
|
||||||
|
expect(detach).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { withActiveDocxCaptureSurface } from "../src/electron-docx-media-engine.js";
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
let destroyed = false;
|
||||||
|
return {
|
||||||
|
showInactive: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
isDestroyed: vi.fn(() => destroyed),
|
||||||
|
destroy: () => {
|
||||||
|
destroyed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Electron DOCX 捕获合成表面", () => {
|
||||||
|
it("截图期间无焦点显示窗口并在完成后隐藏", async () => {
|
||||||
|
const window = createWindow();
|
||||||
|
const operation = vi.fn(async () => "captured");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withActiveDocxCaptureSurface(window, operation, 0)
|
||||||
|
).resolves.toBe("captured");
|
||||||
|
|
||||||
|
expect(window.showInactive).toHaveBeenCalledOnce();
|
||||||
|
expect(operation).toHaveBeenCalledOnce();
|
||||||
|
expect(window.hide).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("窗口在失败期间销毁时不再隐藏", async () => {
|
||||||
|
const window = createWindow();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withActiveDocxCaptureSurface(
|
||||||
|
window,
|
||||||
|
async () => {
|
||||||
|
window.destroy();
|
||||||
|
throw new Error("capture failed");
|
||||||
|
},
|
||||||
|
0
|
||||||
|
)
|
||||||
|
).rejects.toThrow("capture failed");
|
||||||
|
|
||||||
|
expect(window.showInactive).toHaveBeenCalledOnce();
|
||||||
|
expect(window.hide).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export async function withHiddenWindowAnimationFrames<T>(
|
||||||
|
windowRef: Window,
|
||||||
|
operation: () => Promise<T>
|
||||||
|
) {
|
||||||
|
const requestAnimationFrame = windowRef.requestAnimationFrame;
|
||||||
|
windowRef.requestAnimationFrame = (callback) =>
|
||||||
|
windowRef.setTimeout(
|
||||||
|
() => callback(windowRef.performance.now()),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
windowRef.requestAnimationFrame = requestAnimationFrame;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
beginPreviewFrameRender,
|
beginPreviewFrameRender,
|
||||||
completePreviewFrameRender
|
completePreviewFrameRender
|
||||||
} from "./preview-frame-visibility";
|
} from "./preview-frame-visibility";
|
||||||
|
import { withHiddenWindowAnimationFrames } from "./hidden-window-animation-frame";
|
||||||
|
|
||||||
function getPreviewRoot() {
|
function getPreviewRoot() {
|
||||||
const element = document.querySelector<HTMLElement>("#preview-root");
|
const element = document.querySelector<HTMLElement>("#preview-root");
|
||||||
@@ -139,22 +140,24 @@ window.__mdToPdfRender = async (
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
|
window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
|
||||||
|
withHiddenWindowAnimationFrames(window, () =>
|
||||||
renderDocxMediaCapturePlan(
|
renderDocxMediaCapturePlan(
|
||||||
runtime,
|
runtime,
|
||||||
previewRoot,
|
previewRoot,
|
||||||
payload,
|
payload,
|
||||||
dimensions
|
dimensions
|
||||||
|
)
|
||||||
);
|
);
|
||||||
document.documentElement.dataset.runtimeReady = "true";
|
document.documentElement.dataset.runtimeReady = "true";
|
||||||
|
|
||||||
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
||||||
if (desktopPdfBridge) {
|
if (desktopPdfBridge) {
|
||||||
desktopPdfBridge.onRender(async (requestId, payload) => {
|
desktopPdfBridge.onRender(async (requestId, payload) => {
|
||||||
const requestAnimationFrame = window.requestAnimationFrame;
|
|
||||||
window.requestAnimationFrame = (callback) =>
|
|
||||||
window.setTimeout(() => callback(performance.now()), 0);
|
|
||||||
try {
|
try {
|
||||||
const result = await window.__mdToPdfRender?.(payload, "pdf");
|
const result = await withHiddenWindowAnimationFrames(
|
||||||
|
window,
|
||||||
|
async () => window.__mdToPdfRender?.(payload, "pdf")
|
||||||
|
);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("PDF 分页运行时不可用");
|
throw new Error("PDF 分页运行时不可用");
|
||||||
}
|
}
|
||||||
@@ -164,8 +167,6 @@ if (desktopPdfBridge) {
|
|||||||
requestId,
|
requestId,
|
||||||
reason instanceof Error ? reason.message : "PDF 分页失败"
|
reason instanceof Error ? reason.message : "PDF 分页失败"
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
window.requestAnimationFrame = requestAnimationFrame;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
desktopPdfBridge.ready();
|
desktopPdfBridge.ready();
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { withHiddenWindowAnimationFrames } from "../src/hidden-window-animation-frame";
|
||||||
|
|
||||||
|
describe("隐藏窗口动画帧调度", () => {
|
||||||
|
it("使用定时器驱动动画帧并在成功后恢复", async () => {
|
||||||
|
const original = window.requestAnimationFrame;
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
await withHiddenWindowAnimationFrames(window, async () => {
|
||||||
|
expect(window.requestAnimationFrame).not.toBe(original);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
window.requestAnimationFrame((timestamp) => {
|
||||||
|
callback(timestamp);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
expect(window.requestAnimationFrame).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("操作失败后仍恢复原动画帧函数", async () => {
|
||||||
|
const original = window.requestAnimationFrame;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withHiddenWindowAnimationFrames(window, async () => {
|
||||||
|
throw new Error("render failed");
|
||||||
|
})
|
||||||
|
).rejects.toThrow("render failed");
|
||||||
|
|
||||||
|
expect(window.requestAnimationFrame).toBe(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,6 +101,14 @@ WOFF2 可用,生产 DOCX 含 5 个字体部件。当前镜像大小为 711,082
|
|||||||
字节;Desktop 主安装器为 140,279,447 字节,独立字体安装器为
|
字节;Desktop 主安装器为 140,279,447 字节,独立字体安装器为
|
||||||
24,488,770 字节,字体 ZIP 为 29,186,293 字节。
|
24,488,770 字节,字体 ZIP 为 29,186,293 字节。
|
||||||
|
|
||||||
|
`v0.6.0` 发布前 Desktop DOCX 媒体捕获超时修复已完成。共享图片等待
|
||||||
|
逻辑消除了 `decode()` 与加载事件之间的竞态,并增加有界超时;隐藏窗口
|
||||||
|
中的媒体渲染使用定时器驱动帧调度,Electron 捕获阶段临时激活合成表面,
|
||||||
|
同时为布局与单媒体截图提供可诊断的独立超时。Windows 目录版使用默认
|
||||||
|
10 页示例完成真实导出,12 个媒体全部生成 PNG,最终 DOCX 为 8,937,824
|
||||||
|
字节、包含 12 个媒体部件和 5 个字体部件;单元测试、类型检查、生产构建
|
||||||
|
及 `git diff --check` 均通过。
|
||||||
|
|
||||||
`v0.6.0` 阶段 3 已完成 DOCX 共享协议:`packages/core` 统一定义请求
|
`v0.6.0` 阶段 3 已完成 DOCX 共享协议:`packages/core` 统一定义请求
|
||||||
Schema、资源、Pandoc capability、错误码、结果、诊断、耗时、MIME 和
|
Schema、资源、Pandoc capability、错误码、结果、诊断、耗时、MIME 和
|
||||||
安全文件名;`packages/application` 新增 `prepareDocxExport()`,复用
|
安全文件名;`packages/application` 新增 `prepareDocxExport()`,复用
|
||||||
|
|||||||
@@ -74,24 +74,55 @@ export interface PreviewEngineStyles {
|
|||||||
echartsCss: string;
|
echartsCss: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForImages(container: ParentNode) {
|
export const DEFAULT_IMAGE_READY_TIMEOUT_MS = 10_000;
|
||||||
const images = Array.from(container.querySelectorAll("img"));
|
|
||||||
return Promise.all(
|
function waitForImage(
|
||||||
images.map(async (image) => {
|
image: HTMLImageElement,
|
||||||
|
timeoutMs: number
|
||||||
|
) {
|
||||||
if (image.complete) {
|
if (image.complete) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
image.removeEventListener("load", finish);
|
||||||
|
image.removeEventListener("error", finish);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timeout = window.setTimeout(finish, timeoutMs);
|
||||||
|
|
||||||
|
// 先监听事件,再检查 complete 和调用 decode,避免 Electron 中图片
|
||||||
|
// 已经失败后才注册 error 监听器而永久等待。
|
||||||
|
image.addEventListener("load", finish, { once: true });
|
||||||
|
image.addEventListener("error", finish, { once: true });
|
||||||
|
if (image.complete) {
|
||||||
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
void image.decode().then(finish, () => {
|
||||||
await image.decode();
|
// decode 失败时仍允许尚未到达的 load/error 事件完成等待;若事件
|
||||||
} catch {
|
// 已经发生,complete 会立即结束。最终由有界超时兜底。
|
||||||
await new Promise<void>((resolve) => {
|
if (image.complete) {
|
||||||
image.addEventListener("load", () => resolve(), { once: true });
|
finish();
|
||||||
image.addEventListener("error", () => resolve(), { once: true });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForImages(
|
||||||
|
container: ParentNode,
|
||||||
|
timeoutMs = DEFAULT_IMAGE_READY_TIMEOUT_MS
|
||||||
|
) {
|
||||||
|
const images = Array.from(container.querySelectorAll("img"));
|
||||||
|
return Promise.all(images.map((image) => waitForImage(image, timeoutMs)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function stylesheet(
|
function stylesheet(
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { waitForImages } from "../src/index.js";
|
||||||
|
|
||||||
|
function createPendingImage() {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
Object.defineProperty(image, "complete", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => false
|
||||||
|
});
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("文档图片就绪等待", () => {
|
||||||
|
it("图片正常加载后立即完成", async () => {
|
||||||
|
const image = createPendingImage();
|
||||||
|
image.decode = vi.fn(() => new Promise(() => undefined));
|
||||||
|
document.body.replaceChildren(image);
|
||||||
|
|
||||||
|
const waiting = waitForImages(document, 1_000);
|
||||||
|
image.dispatchEvent(new Event("load"));
|
||||||
|
|
||||||
|
await expect(waiting).resolves.toEqual([undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decode 拒绝且图片已经失败时不会错过 error 状态", async () => {
|
||||||
|
let complete = false;
|
||||||
|
const image = document.createElement("img");
|
||||||
|
Object.defineProperty(image, "complete", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => complete
|
||||||
|
});
|
||||||
|
image.decode = vi.fn(async () => {
|
||||||
|
complete = true;
|
||||||
|
throw new Error("decode failed");
|
||||||
|
});
|
||||||
|
document.body.replaceChildren(image);
|
||||||
|
|
||||||
|
await expect(waitForImages(document, 1_000)).resolves.toEqual([
|
||||||
|
undefined
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("图片永不响应时由局部超时结束等待", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const image = createPendingImage();
|
||||||
|
image.decode = vi.fn(() => new Promise(() => undefined));
|
||||||
|
document.body.replaceChildren(image);
|
||||||
|
|
||||||
|
const waiting = waitForImages(document, 25);
|
||||||
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
|
||||||
|
await expect(waiting).resolves.toEqual([undefined]);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user