409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
// @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;
|
|
payload?: {
|
|
previewWidth?: number;
|
|
includeHistogram?: boolean;
|
|
};
|
|
}
|
|
| {
|
|
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;
|
|
}
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((innerResolve, innerReject) => {
|
|
resolve = innerResolve;
|
|
reject = innerReject;
|
|
});
|
|
|
|
return {
|
|
promise,
|
|
resolve,
|
|
reject,
|
|
};
|
|
}
|
|
|
|
describe("worker-client fallbacks", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.unstubAllGlobals();
|
|
vi.stubGlobal(
|
|
"ImageData",
|
|
class ImageData {
|
|
data: Uint8ClampedArray;
|
|
width: number;
|
|
height: number;
|
|
|
|
constructor(data: Uint8ClampedArray, width: number, height: number) {
|
|
this.data = data;
|
|
this.width = width;
|
|
this.height = height;
|
|
}
|
|
},
|
|
);
|
|
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");
|
|
});
|
|
|
|
it("shares one worker preview execution across identical requests", async () => {
|
|
const workerMessages: WorkerMessage[] = [];
|
|
FakeWorker.behavior = (worker, message) => {
|
|
workerMessages.push(message);
|
|
if (message.kind !== "preview") {
|
|
return;
|
|
}
|
|
|
|
queueMicrotask(() => {
|
|
worker.onmessage?.({
|
|
data: {
|
|
kind: "preview-result",
|
|
requestId: message.requestId,
|
|
payload: {
|
|
width: 8,
|
|
height: 4,
|
|
histogram: emptyHistogram(),
|
|
pixels: new Uint8ClampedArray(8 * 4 * 4).buffer,
|
|
},
|
|
},
|
|
} as MessageEvent);
|
|
});
|
|
};
|
|
vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker);
|
|
|
|
const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
|
|
|
|
const request = {
|
|
sourceUrl: "https://cdn.example.com/source.png",
|
|
steps: [],
|
|
previewWidth: 128,
|
|
includeHistogram: true,
|
|
} as const;
|
|
|
|
const [first, second] = await Promise.all([
|
|
renderPreviewWithWorkerFallback(request),
|
|
renderPreviewWithWorkerFallback(request),
|
|
]);
|
|
|
|
expect(workerMessages.filter((message) => message.kind === "preview")).toHaveLength(1);
|
|
expect(previewRendererMocks.renderPreview).not.toHaveBeenCalled();
|
|
expect(first.width).toBe(8);
|
|
expect(second.width).toBe(8);
|
|
});
|
|
|
|
it("creates separate preview executions when width or histogram settings differ", async () => {
|
|
const workerMessages: WorkerMessage[] = [];
|
|
FakeWorker.behavior = (worker, message) => {
|
|
workerMessages.push(message);
|
|
if (message.kind !== "preview") {
|
|
return;
|
|
}
|
|
|
|
queueMicrotask(() => {
|
|
worker.onmessage?.({
|
|
data: {
|
|
kind: "preview-result",
|
|
requestId: message.requestId,
|
|
payload: {
|
|
width: message.payload?.previewWidth ?? 1,
|
|
height: 4,
|
|
histogram: emptyHistogram(),
|
|
pixels: new Uint8ClampedArray((message.payload?.previewWidth ?? 1) * 4 * 4).buffer,
|
|
},
|
|
},
|
|
} as MessageEvent);
|
|
});
|
|
};
|
|
vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker);
|
|
|
|
const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
|
|
|
|
await Promise.all([
|
|
renderPreviewWithWorkerFallback({
|
|
sourceUrl: "https://cdn.example.com/source.png",
|
|
steps: [],
|
|
previewWidth: 128,
|
|
includeHistogram: false,
|
|
}),
|
|
renderPreviewWithWorkerFallback({
|
|
sourceUrl: "https://cdn.example.com/source.png",
|
|
steps: [],
|
|
previewWidth: 256,
|
|
includeHistogram: false,
|
|
}),
|
|
renderPreviewWithWorkerFallback({
|
|
sourceUrl: "https://cdn.example.com/source.png",
|
|
steps: [],
|
|
previewWidth: 128,
|
|
includeHistogram: true,
|
|
}),
|
|
]);
|
|
|
|
expect(workerMessages.filter((message) => message.kind === "preview")).toHaveLength(3);
|
|
});
|
|
|
|
it("removes aborted subscribers without canceling surviving identical preview consumers", async () => {
|
|
const workerMessages: WorkerMessage[] = [];
|
|
const previewStarted = createDeferred<void>();
|
|
|
|
FakeWorker.behavior = (worker, message) => {
|
|
workerMessages.push(message);
|
|
if (message.kind !== "preview") {
|
|
return;
|
|
}
|
|
|
|
previewStarted.resolve();
|
|
queueMicrotask(() => {
|
|
worker.onmessage?.({
|
|
data: {
|
|
kind: "preview-result",
|
|
requestId: message.requestId,
|
|
payload: {
|
|
width: 8,
|
|
height: 4,
|
|
histogram: emptyHistogram(),
|
|
pixels: new Uint8ClampedArray(8 * 4 * 4).buffer,
|
|
},
|
|
},
|
|
} as MessageEvent);
|
|
});
|
|
};
|
|
vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker);
|
|
|
|
const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
|
|
|
|
const firstController = new AbortController();
|
|
const secondController = new AbortController();
|
|
const request = {
|
|
sourceUrl: "https://cdn.example.com/source.png",
|
|
steps: [],
|
|
previewWidth: 128,
|
|
includeHistogram: true,
|
|
} as const;
|
|
|
|
const firstPromise = renderPreviewWithWorkerFallback({
|
|
...request,
|
|
signal: firstController.signal,
|
|
});
|
|
const secondPromise = renderPreviewWithWorkerFallback({
|
|
...request,
|
|
signal: secondController.signal,
|
|
});
|
|
|
|
await previewStarted.promise;
|
|
firstController.abort();
|
|
|
|
await expect(firstPromise).rejects.toMatchObject({ name: "AbortError" });
|
|
await expect(secondPromise).resolves.toMatchObject({ width: 8, height: 4 });
|
|
expect(workerMessages.filter((message) => message.kind === "preview")).toHaveLength(1);
|
|
expect(workerMessages.filter((message) => message.kind === "cancel")).toHaveLength(0);
|
|
});
|
|
|
|
it("shares one fallback preview execution across identical requests", async () => {
|
|
vi.stubGlobal("Worker", undefined);
|
|
const deferred = createDeferred<Awaited<ReturnType<typeof previewRendererMocks.renderPreview>>>();
|
|
previewRendererMocks.renderPreview.mockReturnValueOnce(deferred.promise);
|
|
|
|
const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client");
|
|
|
|
const firstPromise = renderPreviewWithWorkerFallback({
|
|
sourceUrl: "https://cdn.example.com/source.png",
|
|
steps: [],
|
|
previewWidth: 128,
|
|
includeHistogram: false,
|
|
});
|
|
const secondPromise = renderPreviewWithWorkerFallback({
|
|
sourceUrl: "https://cdn.example.com/source.png",
|
|
steps: [],
|
|
previewWidth: 128,
|
|
includeHistogram: false,
|
|
});
|
|
|
|
expect(previewRendererMocks.renderPreview).toHaveBeenCalledTimes(1);
|
|
|
|
deferred.resolve({
|
|
width: 16,
|
|
height: 16,
|
|
imageData: { data: new Uint8ClampedArray(16 * 16 * 4) },
|
|
histogram: emptyHistogram(),
|
|
});
|
|
|
|
const [first, second] = await Promise.all([firstPromise, secondPromise]);
|
|
expect(first.width).toBe(16);
|
|
expect(second.width).toBe(16);
|
|
});
|
|
});
|