fix(image-pipeline): diagnose and stabilize webgl preview path
This commit is contained in:
@@ -3,7 +3,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ImagePipelineBackend } from "@/lib/image-pipeline/backend/backend-types";
|
||||
import { detectBackendCapabilities } from "@/lib/image-pipeline/backend/capabilities";
|
||||
import {
|
||||
detectBackendCapabilities,
|
||||
resetBackendCapabilitiesCache,
|
||||
} from "@/lib/image-pipeline/backend/capabilities";
|
||||
import { createBackendRouter } from "@/lib/image-pipeline/backend/backend-router";
|
||||
|
||||
const previewRendererMocks = vi.hoisted(() => ({
|
||||
@@ -23,6 +26,10 @@ vi.mock("@/lib/image-pipeline/bridge", () => ({
|
||||
}));
|
||||
|
||||
describe("detectBackendCapabilities", () => {
|
||||
beforeEach(() => {
|
||||
resetBackendCapabilitiesCache();
|
||||
});
|
||||
|
||||
it("reports webgl, wasmSimd and offscreenCanvas independently", () => {
|
||||
expect(
|
||||
detectBackendCapabilities({
|
||||
@@ -48,6 +55,39 @@ describe("detectBackendCapabilities", () => {
|
||||
offscreenCanvas: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("caches default WebGL capability detection and releases the probe context", () => {
|
||||
const loseContext = vi.fn();
|
||||
const getContext = vi.fn(() => ({
|
||||
getExtension: vi.fn((name: string) => {
|
||||
if (name === "WEBGL_lose_context") {
|
||||
return { loseContext };
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
}));
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = vi.spyOn(document, "createElement").mockImplementation((tagName) => {
|
||||
if (tagName === "canvas") {
|
||||
return {
|
||||
getContext,
|
||||
} as unknown as HTMLCanvasElement;
|
||||
}
|
||||
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
const first = detectBackendCapabilities();
|
||||
const second = detectBackendCapabilities();
|
||||
|
||||
expect(first.webgl).toBe(true);
|
||||
expect(second.webgl).toBe(true);
|
||||
expect(getContext).toHaveBeenCalledTimes(1);
|
||||
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||
|
||||
createElementSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("backend router fallback reasons", () => {
|
||||
|
||||
@@ -8,12 +8,14 @@ import { emptyHistogram } from "@/lib/image-pipeline/histogram";
|
||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
|
||||
const workerClientMocks = vi.hoisted(() => ({
|
||||
getLastBackendDiagnostics: vi.fn(() => null),
|
||||
renderPreviewWithWorkerFallback: vi.fn(),
|
||||
}));
|
||||
|
||||
const PREVIEW_SETTLE_MS = 80;
|
||||
|
||||
vi.mock("@/lib/image-pipeline/worker-client", () => ({
|
||||
getLastBackendDiagnostics: workerClientMocks.getLastBackendDiagnostics,
|
||||
isPipelineAbortError: () => false,
|
||||
renderPreviewWithWorkerFallback: workerClientMocks.renderPreviewWithWorkerFallback,
|
||||
}));
|
||||
@@ -96,6 +98,8 @@ describe("usePipelinePreview", () => {
|
||||
previewHarnessState.latestHistogram = emptyHistogram();
|
||||
previewHarnessState.latestError = null;
|
||||
previewHarnessState.latestIsRendering = false;
|
||||
workerClientMocks.getLastBackendDiagnostics.mockReset();
|
||||
workerClientMocks.getLastBackendDiagnostics.mockReturnValue(null);
|
||||
workerClientMocks.renderPreviewWithWorkerFallback.mockReset();
|
||||
workerClientMocks.renderPreviewWithWorkerFallback.mockResolvedValue({
|
||||
width: 120,
|
||||
|
||||
@@ -43,6 +43,11 @@ type WorkerMessage =
|
||||
payload?: {
|
||||
previewWidth?: number;
|
||||
includeHistogram?: boolean;
|
||||
featureFlags?: {
|
||||
forceCpu: boolean;
|
||||
webglEnabled: boolean;
|
||||
wasmEnabled: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -315,6 +320,63 @@ describe("worker-client fallbacks", () => {
|
||||
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<string, unknown>;
|
||||
}
|
||||
).__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<void>();
|
||||
|
||||
Reference in New Issue
Block a user