test(image-pipeline): harden backend flag fallback coverage

This commit is contained in:
Matthias
2026-04-04 21:41:10 +02:00
parent fd4f8f4f3b
commit b57062091a
2 changed files with 294 additions and 22 deletions

View File

@@ -87,7 +87,11 @@ describe("backend router fallback reasons", () => {
} }
it("emits unsupported_api when backend is unavailable at runtime", () => { it("emits unsupported_api when backend is unavailable at runtime", () => {
const reasons: string[] = []; const events: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const cpuPreview = vi.fn(); const cpuPreview = vi.fn();
const router = createBackendRouter({ const router = createBackendRouter({
backends: createTestBackends({ backends: createTestBackends({
@@ -101,7 +105,11 @@ describe("backend router fallback reasons", () => {
}, },
}, },
onFallback: (event) => { onFallback: (event) => {
reasons.push(event.reason); events.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
}, },
}); });
@@ -114,11 +122,21 @@ describe("backend router fallback reasons", () => {
}); });
expect(cpuPreview).toHaveBeenCalledTimes(1); expect(cpuPreview).toHaveBeenCalledTimes(1);
expect(reasons).toEqual(["unsupported_api"]); expect(events).toEqual([
{
reason: "unsupported_api",
requestedBackend: "webgl",
fallbackBackend: "cpu",
},
]);
}); });
it("emits flag_disabled when backend is disabled by flags", () => { it("emits flag_disabled when backend is disabled by flags", () => {
const reasons: string[] = []; const events: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const cpuPreview = vi.fn(); const cpuPreview = vi.fn();
const router = createBackendRouter({ const router = createBackendRouter({
backends: createTestBackends({ backends: createTestBackends({
@@ -132,7 +150,11 @@ describe("backend router fallback reasons", () => {
}, },
}, },
onFallback: (event) => { onFallback: (event) => {
reasons.push(event.reason); events.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
}, },
}); });
@@ -145,11 +167,21 @@ describe("backend router fallback reasons", () => {
}); });
expect(cpuPreview).toHaveBeenCalledTimes(1); expect(cpuPreview).toHaveBeenCalledTimes(1);
expect(reasons).toEqual(["flag_disabled"]); expect(events).toEqual([
{
reason: "flag_disabled",
requestedBackend: "webgl",
fallbackBackend: "cpu",
},
]);
}); });
it("emits runtime_error when backend execution throws", () => { it("emits runtime_error when backend execution throws", () => {
const reasons: string[] = []; const events: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const cpuPreview = vi.fn(); const cpuPreview = vi.fn();
const router = createBackendRouter({ const router = createBackendRouter({
backends: createTestBackends({ backends: createTestBackends({
@@ -165,7 +197,11 @@ describe("backend router fallback reasons", () => {
}, },
}, },
onFallback: (event) => { onFallback: (event) => {
reasons.push(event.reason); events.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
}, },
}); });
@@ -178,7 +214,13 @@ describe("backend router fallback reasons", () => {
}); });
expect(cpuPreview).toHaveBeenCalledTimes(1); expect(cpuPreview).toHaveBeenCalledTimes(1);
expect(reasons).toEqual(["runtime_error"]); expect(events).toEqual([
{
reason: "runtime_error",
requestedBackend: "webgl",
fallbackBackend: "cpu",
},
]);
}); });
}); });

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ImagePipelineBackend } from "@/lib/image-pipeline/backend/backend-types"; import type { ImagePipelineBackend } from "@/lib/image-pipeline/backend/backend-types";
import { createBackendRouter } from "@/lib/image-pipeline/backend/backend-router"; import { createBackendRouter } from "@/lib/image-pipeline/backend/backend-router";
@@ -70,9 +70,177 @@ function createRouterFlags(overrides: Partial<BackendFeatureFlags>): BackendFeat
}; };
} }
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", () => { 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", () => { it("forceCpu overrides all backend choices", () => {
const reasons: string[] = []; const fallbackEvents: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const backend = createBackends(); const backend = createBackends();
const router = createBackendRouter({ const router = createBackendRouter({
backends: backend.backends, backends: backend.backends,
@@ -92,7 +260,11 @@ describe("backend feature flags", () => {
wasmEnabled: true, wasmEnabled: true,
}), }),
onFallback: (event) => { onFallback: (event) => {
reasons.push(event.reason); fallbackEvents.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
}, },
}); });
@@ -118,11 +290,26 @@ describe("backend feature flags", () => {
expect(backend.webglFull).not.toHaveBeenCalled(); expect(backend.webglFull).not.toHaveBeenCalled();
expect(backend.wasmPreview).not.toHaveBeenCalled(); expect(backend.wasmPreview).not.toHaveBeenCalled();
expect(backend.wasmFull).not.toHaveBeenCalled(); expect(backend.wasmFull).not.toHaveBeenCalled();
expect(reasons).toEqual(["flag_disabled", "flag_disabled"]); 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", () => { it("webgl and wasm can be independently enabled or disabled", () => {
const reasonA: string[] = []; const reasonA: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const backendA = createBackends(); const backendA = createBackends();
const routerA = createBackendRouter({ const routerA = createBackendRouter({
backends: backendA.backends, backends: backendA.backends,
@@ -142,7 +329,11 @@ describe("backend feature flags", () => {
wasmEnabled: false, wasmEnabled: false,
}), }),
onFallback: (event) => { onFallback: (event) => {
reasonA.push(event.reason); reasonA.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
}, },
}); });
@@ -164,9 +355,19 @@ describe("backend feature flags", () => {
expect(backendA.webglPreview).toHaveBeenCalledTimes(1); expect(backendA.webglPreview).toHaveBeenCalledTimes(1);
expect(backendA.wasmPreview).not.toHaveBeenCalled(); expect(backendA.wasmPreview).not.toHaveBeenCalled();
expect(backendA.cpuPreview).toHaveBeenCalledTimes(1); expect(backendA.cpuPreview).toHaveBeenCalledTimes(1);
expect(reasonA).toEqual(["flag_disabled"]); expect(reasonA).toEqual([
{
reason: "flag_disabled",
requestedBackend: "wasm",
fallbackBackend: "cpu",
},
]);
const reasonB: string[] = []; const reasonB: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const backendB = createBackends(); const backendB = createBackends();
const routerB = createBackendRouter({ const routerB = createBackendRouter({
backends: backendB.backends, backends: backendB.backends,
@@ -186,7 +387,11 @@ describe("backend feature flags", () => {
wasmEnabled: true, wasmEnabled: true,
}), }),
onFallback: (event) => { onFallback: (event) => {
reasonB.push(event.reason); reasonB.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
}, },
}); });
@@ -208,11 +413,21 @@ describe("backend feature flags", () => {
expect(backendB.webglPreview).not.toHaveBeenCalled(); expect(backendB.webglPreview).not.toHaveBeenCalled();
expect(backendB.wasmPreview).toHaveBeenCalledTimes(1); expect(backendB.wasmPreview).toHaveBeenCalledTimes(1);
expect(backendB.cpuPreview).toHaveBeenCalledTimes(1); expect(backendB.cpuPreview).toHaveBeenCalledTimes(1);
expect(reasonB).toEqual(["flag_disabled"]); expect(reasonB).toEqual([
{
reason: "flag_disabled",
requestedBackend: "webgl",
fallbackBackend: "cpu",
},
]);
}); });
it("defaults preserve cpu behavior when no explicit flags are set", () => { it("defaults preserve cpu behavior when no explicit flags are set", () => {
const reasons: string[] = []; const fallbackEvents: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const backend = createBackends(); const backend = createBackends();
const router = createBackendRouter({ const router = createBackendRouter({
backends: backend.backends, backends: backend.backends,
@@ -228,7 +443,11 @@ describe("backend feature flags", () => {
}, },
featureFlags: getBackendFeatureFlags(), featureFlags: getBackendFeatureFlags(),
onFallback: (event) => { onFallback: (event) => {
reasons.push(event.reason); fallbackEvents.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
}, },
}); });
@@ -258,6 +477,17 @@ describe("backend feature flags", () => {
expect(backend.cpuPreview).toHaveBeenCalledTimes(3); expect(backend.cpuPreview).toHaveBeenCalledTimes(3);
expect(backend.webglPreview).not.toHaveBeenCalled(); expect(backend.webglPreview).not.toHaveBeenCalled();
expect(backend.wasmPreview).not.toHaveBeenCalled(); expect(backend.wasmPreview).not.toHaveBeenCalled();
expect(reasons).toEqual(["flag_disabled", "flag_disabled"]); expect(fallbackEvents).toEqual([
{
reason: "flag_disabled",
requestedBackend: "webgl",
fallbackBackend: "cpu",
},
{
reason: "flag_disabled",
requestedBackend: "wasm",
fallbackBackend: "cpu",
},
]);
}); });
}); });