fix(image-pipeline): preserve worker errors and skip aborted histograms

This commit is contained in:
Matthias
2026-04-04 11:56:38 +02:00
parent b650485e81
commit d73db3a612
7 changed files with 421 additions and 131 deletions

View 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
View 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");
});
});