Files
lemonspace_app/tests/image-pipeline/backend-feature-flags.test.ts

494 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ImagePipelineBackend } from "@/lib/image-pipeline/backend/backend-types";
import { createBackendRouter } from "@/lib/image-pipeline/backend/backend-router";
import {
type BackendFeatureFlags,
getBackendFeatureFlags,
} from "@/lib/image-pipeline/backend/feature-flags";
function createStep() {
return {
nodeId: "n1",
type: "color-adjust",
params: {
hsl: {
hue: 0,
saturation: 0,
luminance: 0,
},
temperature: 0,
tint: 0,
vibrance: 0,
},
} as const;
}
function createBackends() {
const cpuPreview = vi.fn();
const cpuFull = vi.fn();
const webglPreview = vi.fn();
const webglFull = vi.fn();
const wasmPreview = vi.fn();
const wasmFull = vi.fn();
const backends: readonly ImagePipelineBackend[] = [
{
id: "cpu",
runPreviewStep: cpuPreview,
runFullPipeline: cpuFull,
},
{
id: "webgl",
runPreviewStep: webglPreview,
runFullPipeline: webglFull,
},
{
id: "wasm",
runPreviewStep: wasmPreview,
runFullPipeline: wasmFull,
},
];
return {
backends,
cpuPreview,
cpuFull,
webglPreview,
webglFull,
wasmPreview,
wasmFull,
};
}
function createRouterFlags(overrides: Partial<BackendFeatureFlags>): BackendFeatureFlags {
return {
...getBackendFeatureFlags(),
...overrides,
};
}
function createLocalStorageStub(): Storage {
const values = new Map<string, string>();
return {
getItem(key: string): string | null {
return values.has(key) ? values.get(key)! : null;
},
setItem(key: string, value: string): void {
values.set(key, String(value));
},
removeItem(key: string): void {
values.delete(key);
},
clear(): void {
values.clear();
},
key(index: number): string | null {
return Array.from(values.keys())[index] ?? null;
},
get length(): number {
return values.size;
},
};
}
beforeEach(() => {
vi.unstubAllGlobals();
vi.stubGlobal("localStorage", createLocalStorageStub());
globalThis.__LEMONSPACE_FEATURE_FLAGS__ = undefined;
window.__LEMONSPACE_FEATURE_FLAGS__ = undefined;
localStorage.clear();
});
afterEach(() => {
globalThis.__LEMONSPACE_FEATURE_FLAGS__ = undefined;
window.__LEMONSPACE_FEATURE_FLAGS__ = undefined;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("backend feature flags", () => {
it("parses accepted truthy and falsy string and numeric variants", () => {
const cases: Array<{ value: unknown; expected: boolean }> = [
{ value: true, expected: true },
{ value: false, expected: false },
{ value: 1, expected: true },
{ value: 0, expected: false },
{ value: "true", expected: true },
{ value: "false", expected: false },
{ value: "1", expected: true },
{ value: "0", expected: false },
{ value: "on", expected: true },
{ value: "off", expected: false },
{ value: " TrUe ", expected: true },
{ value: " Off ", expected: false },
];
for (const testCase of cases) {
expect(
getBackendFeatureFlags((key) => {
if (key === "imagePipeline.backend.forceCpu") {
return testCase.value;
}
return false;
}).forceCpu,
).toBe(testCase.expected);
expect(
getBackendFeatureFlags((key) => {
if (key === "imagePipeline.backend.webgl.enabled") {
return testCase.value;
}
return false;
}).webglEnabled,
).toBe(testCase.expected);
expect(
getBackendFeatureFlags((key) => {
if (key === "imagePipeline.backend.wasm.enabled") {
return testCase.value;
}
return false;
}).wasmEnabled,
).toBe(testCase.expected);
}
});
it("falls back to defaults for invalid values", () => {
const invalidValues: unknown[] = [
2,
-1,
Number.NaN,
"yes",
"no",
"enabled",
"disabled",
"",
" ",
null,
undefined,
{},
[],
];
for (const value of invalidValues) {
expect(
getBackendFeatureFlags(() => value),
).toEqual({
forceCpu: false,
webglEnabled: false,
wasmEnabled: false,
});
}
});
it("prefers runtime store values over localStorage", () => {
globalThis.__LEMONSPACE_FEATURE_FLAGS__ = {
"imagePipeline.backend.forceCpu": true,
"imagePipeline.backend.webgl.enabled": "1",
"imagePipeline.backend.wasm.enabled": "off",
};
localStorage.setItem("imagePipeline.backend.forceCpu", "false");
localStorage.setItem("imagePipeline.backend.webgl.enabled", "0");
localStorage.setItem("imagePipeline.backend.wasm.enabled", "on");
expect(getBackendFeatureFlags()).toEqual({
forceCpu: true,
webglEnabled: true,
wasmEnabled: false,
});
});
it("returns defaults when localStorage access throws", () => {
const failingStorage = {
getItem() {
throw new Error("blocked");
},
setItem() {
// no-op
},
removeItem() {
// no-op
},
clear() {
// no-op
},
key() {
return null;
},
get length() {
return 0;
},
} as Storage;
globalThis.__LEMONSPACE_FEATURE_FLAGS__ = undefined;
vi.stubGlobal("localStorage", failingStorage);
expect(getBackendFeatureFlags()).toEqual({
forceCpu: false,
webglEnabled: false,
wasmEnabled: false,
});
});
it("forceCpu overrides all backend choices", () => {
const fallbackEvents: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const backend = createBackends();
const router = createBackendRouter({
backends: backend.backends,
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: createRouterFlags({
forceCpu: true,
webglEnabled: true,
wasmEnabled: true,
}),
onFallback: (event) => {
fallbackEvents.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
},
});
router.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "webgl",
});
router.runFullPipeline({
pixels: new Uint8ClampedArray(4),
steps: [createStep()],
width: 1,
height: 1,
backendHint: "wasm",
});
expect(backend.cpuPreview).toHaveBeenCalledTimes(1);
expect(backend.cpuFull).toHaveBeenCalledTimes(1);
expect(backend.webglPreview).not.toHaveBeenCalled();
expect(backend.webglFull).not.toHaveBeenCalled();
expect(backend.wasmPreview).not.toHaveBeenCalled();
expect(backend.wasmFull).not.toHaveBeenCalled();
expect(fallbackEvents).toEqual([
{
reason: "flag_disabled",
requestedBackend: "webgl",
fallbackBackend: "cpu",
},
{
reason: "flag_disabled",
requestedBackend: "wasm",
fallbackBackend: "cpu",
},
]);
});
it("webgl and wasm can be independently enabled or disabled", () => {
const reasonA: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const backendA = createBackends();
const routerA = createBackendRouter({
backends: backendA.backends,
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: createRouterFlags({
forceCpu: false,
webglEnabled: true,
wasmEnabled: false,
}),
onFallback: (event) => {
reasonA.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
},
});
routerA.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "webgl",
});
routerA.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "wasm",
});
expect(backendA.webglPreview).toHaveBeenCalledTimes(1);
expect(backendA.wasmPreview).not.toHaveBeenCalled();
expect(backendA.cpuPreview).toHaveBeenCalledTimes(1);
expect(reasonA).toEqual([
{
reason: "flag_disabled",
requestedBackend: "wasm",
fallbackBackend: "cpu",
},
]);
const reasonB: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const backendB = createBackends();
const routerB = createBackendRouter({
backends: backendB.backends,
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: createRouterFlags({
forceCpu: false,
webglEnabled: false,
wasmEnabled: true,
}),
onFallback: (event) => {
reasonB.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
},
});
routerB.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "webgl",
});
routerB.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "wasm",
});
expect(backendB.webglPreview).not.toHaveBeenCalled();
expect(backendB.wasmPreview).toHaveBeenCalledTimes(1);
expect(backendB.cpuPreview).toHaveBeenCalledTimes(1);
expect(reasonB).toEqual([
{
reason: "flag_disabled",
requestedBackend: "webgl",
fallbackBackend: "cpu",
},
]);
});
it("defaults preserve cpu behavior when no explicit flags are set", () => {
const fallbackEvents: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const backend = createBackends();
const router = createBackendRouter({
backends: backend.backends,
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: getBackendFeatureFlags(),
onFallback: (event) => {
fallbackEvents.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
},
});
router.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "webgl",
});
router.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "wasm",
});
router.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
});
expect(backend.cpuPreview).toHaveBeenCalledTimes(3);
expect(backend.webglPreview).not.toHaveBeenCalled();
expect(backend.wasmPreview).not.toHaveBeenCalled();
expect(fallbackEvents).toEqual([
{
reason: "flag_disabled",
requestedBackend: "webgl",
fallbackBackend: "cpu",
},
{
reason: "flag_disabled",
requestedBackend: "wasm",
fallbackBackend: "cpu",
},
]);
});
});