fix(image-pipeline): preserve worker errors and skip aborted histograms
This commit is contained in:
87
tests/preview-renderer.test.ts
Normal file
87
tests/preview-renderer.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { emptyHistogram } from "@/lib/image-pipeline/histogram";
|
||||
|
||||
const histogramMocks = vi.hoisted(() => ({
|
||||
computeHistogram: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderCoreMocks = vi.hoisted(() => ({
|
||||
applyPipelineStep: vi.fn(),
|
||||
}));
|
||||
|
||||
const sourceLoaderMocks = vi.hoisted(() => ({
|
||||
loadSourceBitmap: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/image-pipeline/histogram", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/histogram")>(
|
||||
"@/lib/image-pipeline/histogram",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
computeHistogram: histogramMocks.computeHistogram,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/image-pipeline/render-core", () => ({
|
||||
applyPipelineStep: renderCoreMocks.applyPipelineStep,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/image-pipeline/source-loader", () => ({
|
||||
loadSourceBitmap: sourceLoaderMocks.loadSourceBitmap,
|
||||
}));
|
||||
|
||||
describe("preview-renderer cancellation", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
histogramMocks.computeHistogram.mockReset();
|
||||
renderCoreMocks.applyPipelineStep.mockReset();
|
||||
sourceLoaderMocks.loadSourceBitmap.mockReset();
|
||||
histogramMocks.computeHistogram.mockReturnValue(emptyHistogram());
|
||||
sourceLoaderMocks.loadSourceBitmap.mockResolvedValue({ width: 1, height: 1 });
|
||||
renderCoreMocks.applyPipelineStep.mockImplementation(() => {});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, 255]) })),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
vi.stubGlobal("requestAnimationFrame", ((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}) as typeof requestAnimationFrame);
|
||||
});
|
||||
|
||||
it("skips histogram work when cancellation lands after step application", async () => {
|
||||
const { renderPreview } = await import("@/lib/image-pipeline/preview-renderer");
|
||||
|
||||
let abortedReads = 0;
|
||||
const signal = {
|
||||
get aborted() {
|
||||
abortedReads += 1;
|
||||
return abortedReads >= 3;
|
||||
},
|
||||
} as AbortSignal;
|
||||
|
||||
await expect(
|
||||
renderPreview({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: "light-1",
|
||||
type: "light-adjust",
|
||||
params: { exposure: 0.1 },
|
||||
},
|
||||
],
|
||||
previewWidth: 1,
|
||||
includeHistogram: true,
|
||||
signal,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
|
||||
expect(histogramMocks.computeHistogram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
188
tests/worker-client.test.ts
Normal file
188
tests/worker-client.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { emptyHistogram } from "@/lib/image-pipeline/histogram";
|
||||
import type { RenderFullResult } from "@/lib/image-pipeline/render-types";
|
||||
|
||||
const previewRendererMocks = vi.hoisted(() => ({
|
||||
renderPreview: vi.fn(),
|
||||
}));
|
||||
|
||||
const bridgeMocks = vi.hoisted(() => ({
|
||||
renderFull: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/image-pipeline/preview-renderer", () => ({
|
||||
renderPreview: previewRendererMocks.renderPreview,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/image-pipeline/bridge", () => ({
|
||||
renderFull: bridgeMocks.renderFull,
|
||||
}));
|
||||
|
||||
function createFullResult(): RenderFullResult {
|
||||
return {
|
||||
blob: new Blob(["rendered"]),
|
||||
width: 32,
|
||||
height: 32,
|
||||
mimeType: "image/png",
|
||||
format: "png",
|
||||
quality: null,
|
||||
sizeBytes: 8,
|
||||
sourceWidth: 32,
|
||||
sourceHeight: 32,
|
||||
wasSizeClamped: false,
|
||||
};
|
||||
}
|
||||
|
||||
type WorkerMessage =
|
||||
| {
|
||||
kind: "preview" | "full";
|
||||
requestId: number;
|
||||
}
|
||||
| {
|
||||
kind: "cancel";
|
||||
requestId: number;
|
||||
};
|
||||
|
||||
type FakeWorkerBehavior = (worker: FakeWorker, message: WorkerMessage) => void;
|
||||
|
||||
class FakeWorker {
|
||||
static behavior: FakeWorkerBehavior = () => {};
|
||||
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onmessageerror: (() => void) | null = null;
|
||||
terminated = false;
|
||||
|
||||
postMessage(message: WorkerMessage): void {
|
||||
FakeWorker.behavior(this, message);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe("worker-client fallbacks", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
previewRendererMocks.renderPreview.mockReset();
|
||||
bridgeMocks.renderFull.mockReset();
|
||||
previewRendererMocks.renderPreview.mockResolvedValue({
|
||||
width: 16,
|
||||
height: 16,
|
||||
imageData: { data: new Uint8ClampedArray(16 * 16 * 4) },
|
||||
histogram: emptyHistogram(),
|
||||
});
|
||||
bridgeMocks.renderFull.mockResolvedValue(createFullResult());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not fall back to main-thread preview rendering for deterministic worker errors", async () => {
|
||||
FakeWorker.behavior = (worker, message) => {
|
||||
if (message.kind === "cancel") {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
worker.onmessage?.({
|
||||
data: {
|
||||
kind: "error",
|
||||
requestId: message.requestId,
|
||||
payload: {
|
||||
name: "RenderPipelineError",
|
||||
message: "Deterministic worker preview failure",
|
||||
},
|
||||
},
|
||||
} as MessageEvent);
|
||||
});
|
||||
};
|
||||
vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker);
|
||||
|
||||
const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
|
||||
|
||||
await expect(
|
||||
renderPreviewWithWorkerFallback({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [],
|
||||
previewWidth: 128,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "RenderPipelineError",
|
||||
message: "Deterministic worker preview failure",
|
||||
});
|
||||
|
||||
expect(previewRendererMocks.renderPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to main-thread full rendering for deterministic worker errors", async () => {
|
||||
FakeWorker.behavior = (worker, message) => {
|
||||
if (message.kind === "cancel") {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
worker.onmessage?.({
|
||||
data: {
|
||||
kind: "error",
|
||||
requestId: message.requestId,
|
||||
payload: {
|
||||
name: "RenderPipelineError",
|
||||
message: "Deterministic worker full render failure",
|
||||
},
|
||||
},
|
||||
} as MessageEvent);
|
||||
});
|
||||
};
|
||||
vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker);
|
||||
|
||||
const { renderFullWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
|
||||
|
||||
await expect(
|
||||
renderFullWithWorkerFallback({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [],
|
||||
render: {
|
||||
resolution: "original",
|
||||
format: "png",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "RenderPipelineError",
|
||||
message: "Deterministic worker full render failure",
|
||||
});
|
||||
|
||||
expect(bridgeMocks.renderFull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still falls back to the main thread when the Worker API is unavailable", async () => {
|
||||
vi.stubGlobal("Worker", undefined);
|
||||
|
||||
const workerClient = await import("@/lib/image-pipeline/worker-client");
|
||||
|
||||
const previewResult = await workerClient.renderPreviewWithWorkerFallback({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [],
|
||||
previewWidth: 128,
|
||||
});
|
||||
const fullResult = await workerClient.renderFullWithWorkerFallback({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [],
|
||||
render: {
|
||||
resolution: "original",
|
||||
format: "png",
|
||||
},
|
||||
});
|
||||
|
||||
expect(previewRendererMocks.renderPreview).toHaveBeenCalledTimes(1);
|
||||
expect(bridgeMocks.renderFull).toHaveBeenCalledTimes(1);
|
||||
expect(previewResult.width).toBe(16);
|
||||
expect(fullResult.format).toBe("png");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user