// @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; featureFlags?: { forceCpu: boolean; webglEnabled: boolean; wasmEnabled: 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() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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("passes backend feature flags to worker preview 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); ( globalThis as typeof globalThis & { __LEMONSPACE_FEATURE_FLAGS__?: Record; } ).__LEMONSPACE_FEATURE_FLAGS__ = { "imagePipeline.backend.forceCpu": false, "imagePipeline.backend.webgl.enabled": true, "imagePipeline.backend.wasm.enabled": true, }; const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client"); await renderPreviewWithWorkerFallback({ sourceUrl: "https://cdn.example.com/source.png", steps: [], previewWidth: 128, includeHistogram: true, }); expect(workerMessages).toContainEqual( expect.objectContaining({ kind: "preview", payload: expect.objectContaining({ featureFlags: { forceCpu: false, webglEnabled: true, wasmEnabled: true, }, }), }), ); }); it("removes aborted subscribers without canceling surviving identical preview consumers", async () => { const workerMessages: WorkerMessage[] = []; const previewStarted = createDeferred(); 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>>(); 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); }); });