Files
workinf_Blender_Wasm/web/tests/e2e/smoke.spec.ts
2026-08-12 15:23:35 -04:00

2494 lines
152 KiB
TypeScript

import { expect, test } from "@playwright/test";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
const animationBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/animation_scene.blend");
const riggedBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/rigged_shape_scene.blend");
const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend");
const greasePencilBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend");
const localTexturePng = path.resolve(import.meta.dirname, "../../../tests/golden/W-010/desktop-1440x900.png");
const deformationGolden = path.resolve(import.meta.dirname, "../../../tests/golden/W-079/blender-deformation.json");
interface DepsgraphE2EReport {
status: string;
objectCount: number;
meshObjectCount: number;
meshes: Array<{
sourceMeshId: string;
vertexCount: number;
triangleCount: number;
positions: number[];
indices: number[];
modifierCount: number;
modifiers: Array<{
uuid: string;
index: number;
type: string;
status: "EVALUATED" | "DISABLED" | "BLOCKED";
showViewport: boolean;
targetObjectIds?: string[];
errorCode?: string;
suggestion?: string;
dependsOn?: string[];
}>;
}>;
}
test("opens the Blender-style editor and initializes workers", async ({ page }) => {
const externalRequests: string[] = [];
let appOrigin = "";
page.on("request", (request) => {
if (appOrigin && new URL(request.url()).origin !== appOrigin) externalRequests.push(request.url());
});
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
appOrigin = new URL(page.url()).origin;
await expect(page.getByText("Blender Web", { exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Layout" })).toHaveClass(/active/);
await expect(page.locator(".status-bar")).toContainText("Manifest: verified r1");
await expect(page.locator(".status-bar")).toContainText("Engine: ready, open a .blend file", { timeout: 20_000 });
await expect(page.locator(".status-bar")).toContainText(/Storage: (IndexedDB \+ OPFS|IndexedDB)/, { timeout: 20_000 });
await expect(page.locator("canvas.viewport-canvas")).toBeVisible();
await page.reload();
await expect(page.locator(".status-bar")).toContainText(/Storage: (IndexedDB \+ OPFS|IndexedDB)/, { timeout: 20_000 });
expect(externalRequests).toEqual([]);
});
test("migrates IndexedDB metadata and creates a validated OPFS project layout", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ schemaVersion: number; stores: string[]; projectPath?: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" });
const infoId = "storage-info-e2e";
const projectId = "layout-e2e";
let info: { schemaVersion: number; stores: string[] } | undefined;
worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { schemaVersion: number; stores: string[]; scenePath?: string }; error?: string }>) => {
if (event.data.requestId === infoId && event.data.ok) {
info = event.data.result;
worker.postMessage({ requestId: "storage-project-e2e", command: { type: "ensureProject", projectId } });
return;
}
if (event.data.requestId !== "storage-project-e2e") return;
worker.terminate();
if (!event.data.ok || !event.data.result) {
reject(new Error(event.data.error ?? "storage project layout failed"));
return;
}
if (!info) {
reject(new Error("storage info response missing"));
return;
}
resolve({ schemaVersion: info.schemaVersion, stores: info.stores, projectPath: event.data.result.scenePath });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ requestId: infoId, command: { type: "info" } });
}));
expect(result.schemaVersion).toBe(6);
expect(result.stores).toEqual(expect.arrayContaining(["project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"]));
expect(result.projectPath).toBe("projects/layout-e2e/scene.blend");
});
test("atomically persists a verified blend before updating project metadata", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ backend: string; bytes: number; revision: number; persisted: boolean; sha256: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" });
const buffer = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44]).buffer;
const requestId = "storage-save-e2e";
worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { backend: string; bytes: number; revision: number; persisted: boolean; sha256: string }; error?: string }>) => {
if (event.data.requestId !== requestId) return;
worker.terminate();
if (!event.data.ok || !event.data.result) {
reject(new Error(event.data.error ?? "storage save failed"));
return;
}
resolve(event.data.result);
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ requestId, command: { type: "saveProject", projectId: "save-e2e", revision: 7, buffer } }, [buffer]);
}));
expect(result.backend).toBe("opfs");
expect(result.bytes).toBe(5);
expect(result.revision).toBe(7);
expect(result.persisted).toBe(true);
expect(result.sha256).toMatch(/^[a-f0-9]{64}$/);
});
test("recovers verified atomic saves after worker failures at both commit boundaries", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const cases = ["after-stage", "after-scene-commit"] as const;
const recovered = [];
for (const [index, faultAt] of cases.entries()) {
const projectId = `recovery-e2e-${Date.now()}-${index}`;
const expected = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, index, 10, 20, 30]);
const firstClient = new StorageClient();
let fault = "";
try {
await firstClient.saveProject(projectId, 40 + index, expected.slice().buffer, faultAt);
}
catch (error) {
fault = error instanceof Error ? error.message : String(error);
}
firstClient.terminate();
const restartedClient = new StorageClient();
const restored = await restartedClient.readProject(projectId);
const clean = await restartedClient.recoverProject(projectId);
restartedClient.terminate();
recovered.push({
faultAt,
fault,
revision: restored.revision,
bytes: Array.from(new Uint8Array(restored.buffer)),
sha256: restored.sha256,
recovered: restored.recovered,
cleanStatus: clean.status,
cleanRecovered: clean.recovered,
});
}
return recovered;
});
expect(result).toHaveLength(2);
for (const [index, item] of result.entries()) {
expect(item.fault).toContain(`PROJECT_SAVE_FAULT_INJECTED: ${item.faultAt}`);
expect(item.revision).toBe(40 + index);
expect(item.bytes).toEqual([0x42, 0x4c, 0x45, 0x4e, 0x44, index, 10, 20, 30]);
expect(item.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(item.recovered).toBe(true);
expect(item.cleanStatus).toBe("clean");
expect(item.cleanRecovered).toBe(false);
}
});
test("serializes concurrent saves for one project without journal races", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const client = new StorageClient();
const projectId = `save-order-e2e-${Date.now()}`;
await Promise.all([
client.saveProject(projectId, 8, Uint8Array.from([8, 8, 8]).buffer),
client.saveProject(projectId, 9, Uint8Array.from([9, 9, 9, 9]).buffer),
]);
const restored = await client.readProject(projectId);
client.terminate();
return {
revision: restored.revision,
bytes: Array.from(new Uint8Array(restored.buffer)),
recovered: restored.recovered,
};
});
expect(result).toEqual({ revision: 9, bytes: [9, 9, 9, 9], recovered: false });
});
test("content-addresses assets, deduplicates them, and rediscovers them after worker restart", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const firstClient = new StorageClient();
const concurrent = await Promise.all([firstClient.info(), firstClient.ensureProject("asset-e2e")]);
const firstBytes = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]).buffer;
const first = await firstClient.putAsset("asset-e2e", firstBytes, "image/png", "//textures/checker.png");
const second = await firstClient.putAsset("asset-e2e", Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]).buffer, "image/png", "textures/checker.png");
firstClient.terminate();
const secondClient = new StorageClient();
const listed = await secondClient.listAssets("asset-e2e");
const restored = await secondClient.readAsset("asset-e2e", first.sha256);
let traversalError = "";
try {
await secondClient.putAsset("asset-e2e", Uint8Array.from([1]).buffer, "image/png", "../escape.png");
}
catch (error) {
traversalError = error instanceof Error ? error.message : String(error);
}
secondClient.terminate();
return {
concurrent: concurrent.length,
sha256: first.sha256,
firstDeduplicated: first.deduplicated,
secondDeduplicated: second.deduplicated,
path: first.path,
sourcePath: first.sourcePath,
listed: listed.assets.length,
listedHash: listed.assets[0]?.sha256,
restored: Array.from(new Uint8Array(restored.data)),
traversalError,
};
});
expect(result.concurrent).toBe(2);
expect(result.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(result.firstDeduplicated).toBe(false);
expect(result.secondDeduplicated).toBe(true);
expect(result.path).toMatch(/projects\/asset-e2e\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
expect(result.sourcePath).toBe("textures/checker.png");
expect(result.listed).toBe(1);
expect(result.listedHash).toBe(result.sha256);
expect(result.restored).toEqual([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]);
expect(result.traversalError).toContain("ASSET_PATH_OUTSIDE_PROJECT");
});
test("viewport accepts orbit input without rebuilding the shell", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toBeVisible();
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (!box) return;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2 + 20);
await page.mouse.up();
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 3 · Vertices 8 · Faces 6");
});
test("opens a local .blend into SceneIR and Three.js", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await expect(page.getByText("BasicCamera", { exact: true })).toBeVisible();
await expect(page.getByText("BasicArea", { exact: true })).toBeVisible();
await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r1 (3 objects)");
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 3 · Vertices 8 · Faces 6");
const renderedPixels = await page.locator("canvas.viewport-canvas").evaluate((canvas) => {
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (!gl) return 0;
const pixels = new Uint8Array(4 * 16 * 16);
gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let changed = 0;
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) changed++;
}
return changed;
});
expect(renderedPixels).toBeGreaterThan(0);
});
test("reads bounded non-mesh data blocks and previews supported geometry", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", nonMeshBlend);
await expect(page.getByText("WebCurveObject", { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(page.getByText("WebMetaballObject", { exact: true })).toBeVisible();
await expect(page.getByText("WebVolumeObject", { exact: true })).toBeVisible();
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-non-mesh-count", "6");
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2");
const renderedPixels = await canvas.evaluate((element) => {
const gl = element.getContext("webgl2") ?? element.getContext("webgl");
if (!gl) return 0;
const pixels = new Uint8Array(4 * 16 * 16);
gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let changed = 0;
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) changed++;
}
return changed;
});
expect(renderedPixels).toBeGreaterThan(0);
await page.screenshot({ path: testInfo.outputPath("nonmesh-desktop-1440x900.png"), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(canvas).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
const bounds = await canvas.boundingBox();
expect(bounds?.width ?? 0).toBeGreaterThan(100);
expect(bounds?.height ?? 0).toBeGreaterThan(100);
const mobilePixels = await canvas.evaluate((element) => {
const gl = element.getContext("webgl2") ?? element.getContext("webgl");
if (!gl) return 0;
const pixels = new Uint8Array(4 * 16 * 16);
gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let changed = 0;
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) changed++;
}
return changed;
});
expect(mobilePixels).toBeGreaterThan(0);
await page.screenshot({ path: testInfo.outputPath("nonmesh-mobile-390x844.png"), fullPage: true });
});
test("reports structured N-015 gates for previewable and resource-backed data", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(nonMeshBlend));
const report = await page.evaluate((input) => new Promise<{ gates: Array<{ id: string; taskId: string; status: string; code?: string }>; metaball: { position: number[]; scale: number[] }; binaryDataIds: string[]; hairType: string }>((resolve, reject) => {
type Response = { kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { nonMeshData?: Array<{ id: string; type?: string; elements?: Array<{ position: number[]; scale: number[] }> }> }; nonMeshGeometryBuffers?: Array<{ dataId: string; pointCount: number }>; capabilityGate?: { taskId: string; status: string; issues: Array<{ code: string }> } }; error?: { message?: string } };
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const ids = ["curve:WebCurveData", "volume:WebVolumeData"];
const results: Array<{ id: string; taskId: string; status: string; code?: string }> = [];
let metaball: { position: number[]; scale: number[] } | undefined;
let binaryDataIds: string[] = [];
let hairType = "";
worker.onmessage = (event: MessageEvent<Response>) => {
if (event.data.kind !== "result") return;
if (event.data.requestId === "nonmesh-open") {
if (!event.data.ok) {
worker.terminate();
reject(new Error(event.data.error?.message ?? "non-mesh fixture open failed"));
return;
}
const data = event.data.result?.snapshot?.nonMeshData?.find((candidate) => candidate.id === "metaball:WebMetaballData");
const element = data?.elements?.[0];
if (!element) {
worker.terminate();
reject(new Error("metaball metadata missing"));
return;
}
metaball = element;
binaryDataIds = [...new Set((event.data.result?.nonMeshGeometryBuffers ?? []).map((chunk) => chunk.dataId))].sort();
hairType = event.data.result?.snapshot?.nonMeshData?.find((candidate) => candidate.id === "curves:WebHairData")?.type ?? "";
ids.forEach((dataId, index) => worker.postMessage({ requestId: `nonmesh-gate-${index}`, command: { type: "queryNonMeshCapability", dataId } }));
return;
}
const index = Number(event.data.requestId.replace("nonmesh-gate-", ""));
const gate = event.data.result?.capabilityGate;
if (!event.data.ok || !gate || !Number.isSafeInteger(index)) return;
results.push({ id: ids[index], taskId: gate.taskId, status: gate.status, code: gate.issues[0]?.code });
if (results.length === ids.length) {
worker.terminate();
if (!metaball) {
reject(new Error("metaball metadata missing after gate queries"));
return;
}
resolve({ gates: results.sort((left, right) => left.id.localeCompare(right.id)), metaball, binaryDataIds, hairType });
}
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: "nonmesh-open", command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(report.gates).toEqual([
{ id: "curve:WebCurveData", taskId: "N-015", status: "READY", code: undefined },
{ id: "volume:WebVolumeData", taskId: "N-015", status: "BLOCKED", code: "NON_MESH_RESOURCE_MISSING" },
]);
expect(report.binaryDataIds).toEqual(["curve:WebCurveData", "curve:WebSurfaceData", "curves:WebCurvesData", "curves:WebHairData", "pointcloud:WebPointCloudData"]);
expect(report.hairType).toBe("HAIR");
expect(report.metaball.position[0]).toBeCloseTo(-0.65, 5);
expect(report.metaball.position[1]).toBeCloseTo(0, 5);
expect(report.metaball.position[2]).toBeCloseTo(0.4, 5);
expect(report.metaball.scale).toEqual([1, 1, 1]);
});
test("enforces the N-015 one-million-point binary transfer gate", async ({ page }) => {
await page.goto("/");
const gate = await page.evaluate(() => new Promise<{ status: string; metrics: { pointCount: number; chunkCount: number; transferredBytes: number; elapsedMs: number; peakBytes: number } }>((resolve, reject) => {
const worker = new Worker("/src/workers/nonmesh-binary-test.worker.ts", { type: "module" });
worker.onmessage = (event) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ pointCount: 1_000_000 });
}));
expect(gate.status).toBe("READY");
expect(gate.metrics.pointCount).toBe(1_000_000);
expect(gate.metrics.chunkCount).toBe(16);
expect(gate.metrics.transferredBytes).toBeGreaterThan(16 * 65_536 * 12);
expect(gate.metrics.peakBytes).toBeLessThan(512 * 1024 * 1024);
});
test("rejects malformed N-015 binary chunks, integer overflow and budget overflow", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ hash: string; length: string; integerOverflow: string; invalidAttributes: string; attributeOverflow: string; negativeOffset: string; missingAttribute: string; duplicateId: string; hundredThousandBudget: string; pointBudget: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/nonmesh-binary-malformed-test.worker.ts", { type: "module" });
worker.onmessage = (event) => { worker.terminate(); if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.hash).toContain("NON_MESH_BINARY_INVALID");
expect(result.length).toContain("NON_MESH_BINARY_INVALID");
expect(result.integerOverflow).toContain("NON_MESH_BINARY_INVALID");
expect(result.invalidAttributes).toContain("NON_MESH_BINARY_INVALID");
expect(result.attributeOverflow).toContain("NON_MESH_BINARY_INVALID");
expect(result.missingAttribute).toContain("NON_MESH_BINARY_INVALID");
expect(result.negativeOffset).toContain("NON_MESH_BINARY_INVALID");
expect(result.duplicateId).toContain("NON_MESH_BINARY_INVALID");
expect(result.hundredThousandBudget).toContain("NON_MESH_BINARY_INVALID");
expect(result.pointBudget).toContain("NON_MESH_BINARY_INVALID");
});
test("enforces the N-016 Grease Pencil layer/frame/drawing/stroke/point schema budget", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ validStatus: string; malformed: string; budget: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/grease-pencil-schema-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ validStatus: string; malformed: string; budget: string }>) => {
worker.terminate();
resolve(event.data);
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.validStatus).toBe("available");
expect(result.malformed).toContain("GREASE_PENCIL_SCHEMA_INVALID");
expect(result.budget).toBe("accepted-blocked-budget");
});
test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { createGreasePencilObject } = await import("/src/three-adapter/grease-pencil.ts");
const point = (x: number) => ({ position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] });
const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ cyclic: false, pointCount: 2, materialIndex: 0, points: [point(x), point(x + 1)] }] });
const object = createGreasePencilObject({
id: "grease-pencil:onion",
name: "Onion",
geometryStatus: "available",
layerCount: 1,
frameCount: 3,
strokeCount: 3,
pointCount: 6,
layers: [{ id: "layer:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [
{ frame: 1, drawing: drawing("drawing:1", -2) },
{ frame: 5, drawing: drawing("drawing:5", 0) },
{ frame: 9, drawing: drawing("drawing:9", 2) },
] }],
}, 5);
if (!object) return null;
return {
onion: object.userData.greasePencilOnionStrokeCount,
current: object.userData.greasePencilCurrentStrokeCount,
kinds: object.children.map((child) => child.userData.greasePencilOnion),
opacities: object.children.map((child) => "material" in child && !Array.isArray(child.material) ? (child.material as { opacity?: number }).opacity : undefined),
};
});
expect(result).toEqual({ onion: 2, current: 1, kinds: ["PREVIOUS", "NONE", "NEXT"], opacities: [0.28, 1, 0.28] });
});
for (const offscreen of [false, true]) {
test(`renders the N-016 Grease Pencil current-frame strokes in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => {
await page.goto(offscreen ? "/?offscreen=1" : "/");
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
await expect(page.getByText("GreasePencilObject", { exact: true })).toBeVisible({ timeout: 20_000 });
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr");
await expect(canvas).toHaveAttribute("data-grease-pencil-count", "1", { timeout: 20_000 });
await expect(canvas).toHaveAttribute("data-grease-pencil-blocked-count", "0");
if (offscreen) {
await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
}
else {
const renderedPixels = await canvas.evaluate((element) => {
const gl = element.getContext("webgl2") ?? element.getContext("webgl");
if (!gl) return 0;
const pixels = new Uint8Array(4 * 16 * 16);
gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
return pixels.reduce((count, value) => count + (value > 0 ? 1 : 0), 0);
});
expect(renderedPixels).toBeGreaterThan(0);
}
expect(await canvas.getAttribute("data-renderer-error")).toBeNull();
});
}
test("enforces the N-017 paint stroke, PBVH/UV hit and brush budgets", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, string>>((resolve, reject) => {
const worker = new Worker("/src/workers/paint-schema-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, string>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toBe("VERTEX_COLOR");
expect(result.weight).toBe("Group");
expect(result.hit).toContain("PAINT_SCHEMA_INVALID");
expect(result.budget).toContain("PAINT_BUDGET_EXCEEDED");
});
test("derives an N-017 source-face, barycentric and UV paint hit from a real raycast", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const three = await import("/src/vendor/three/three.module.js");
const { paintHitFromIntersection } = await import("/src/three-adapter/paint-hit.ts");
const geometry = new three.BufferGeometry();
geometry.setAttribute("position", new three.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3));
geometry.setAttribute("uv", new three.Float32BufferAttribute([0, 0, 1, 0, 0, 1], 2));
geometry.setIndex([0, 1, 2]);
const mesh = new three.Mesh(geometry, new three.MeshBasicMaterial());
mesh.userData.blenderId = "object:paint";
mesh.userData.meshId = "mesh:paint";
mesh.userData.triangleFaceIndices = [17];
mesh.updateMatrixWorld(true);
const raycaster = new three.Raycaster(new three.Vector3(0.25, 0.25, 1), new three.Vector3(0, 0, -1));
const intersection = raycaster.intersectObject(mesh)[0];
return intersection ? paintHitFromIntersection(intersection, 0.75) : null;
});
expect(result?.objectId).toBe("object:paint");
expect(result?.dataId).toBe("mesh:paint");
expect(result?.faceIndex).toBe(17);
expect(result?.pressure).toBe(0.75);
expect(result?.barycentric).toEqual(expect.arrayContaining([0.5, 0.25, 0.25]));
expect(result?.uv).toEqual([0.25, 0.25]);
expect(result?.normal).toEqual([0, 0, 1]);
});
test("validates the N-018 physics capability and cache manifests without claiming solvers", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/physics-simulation-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toBe("CLOTH:2");
expect(result.familyCount).toBe(7);
expect(result.families).toEqual(["RIGID_BODY", "SOFT_BODY", "CLOTH", "FLUID", "DYNAMIC_PAINT", "PARTICLE", "HAIR"]);
expect(result.inventoryBlocked).toBe(true);
expect(result.cycle).toContain("PHYSICS_DEPENDENCY_CYCLE");
expect(result.budget).toContain("PHYSICS_BUDGET_EXCEEDED");
expect(result.missingFrame).toContain("PHYSICS_CACHE_FRAME_MISMATCH");
expect(result.solver).toBe("PHYSICS_SOLVER_UNAVAILABLE");
expect(result.manifest).toBe("READY");
});
test("maps N-019 Scene exposure and light shadow metadata without using legacy World exposure", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-view-transform", "AgX");
await expect(canvas).toHaveAttribute("data-view-look", "None");
await expect(canvas).toHaveAttribute("data-mist", "disabled");
const mapping = await page.evaluate(async () => {
const { blenderLightIntensity, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts");
const { Object3D } = await import("/src/vendor/three/three.module.js");
const definition = {
id: "light:test", name: "Test", lightType: 0, color: [1, 1, 1], energy: 100, exposure: 2,
radius: 0, spotAngle: 1, spotBlend: 0, areaShape: 0, areaSize: 1, areaSizeY: 1, sunAngle: 0,
castsShadow: false,
};
const light = createPBRLight(definition);
configurePBRLight(light, {
id: "object:test", name: "Test", type: "LIGHT", parentId: null, dataId: "light:test", visible: true,
selectable: true, localMatrix: new Array(16).fill(0), worldMatrix: new Array(16).fill(0),
transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 },
}, new Object3D());
return { intensity: blenderLightIntensity(definition), castShadow: light.castShadow, sourceShadow: light.userData.blenderCastsShadow };
});
expect(mapping).toEqual({ intensity: 40, castShadow: false, sourceShadow: false });
});
test("executes the bounded N-020 CPU compositor and preserves unsupported nodes as gates", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/compositor-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toEqual([0.25, 0, 0.5, 0.75]);
expect(result.viewer).toBe(true);
expect(result.gate).toBe("READY");
expect(result.cycle).toContain("COMPOSITOR_GRAPH_CYCLE");
expect(result.unsupported).toBe("COMPOSITOR_NODE_UNSUPPORTED");
expect(result.budget).toContain("COMPOSITOR_BUDGET_EXCEEDED");
expect(result.cancelled).toContain("COMPOSITOR_CANCELLED");
});
test("validates N-021 sequencer strips, deterministic edits, sandbox paths and codec gates", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/sequencer-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toBe(105);
expect(result.edit).toEqual([5, [["strip:Movie", 15, 20, 100, 105], ["strip:MovieRight", 20, 25, 105, 110]]]);
expect(result.revision).toContain("REVISION_CONFLICT");
expect(result.path).toContain("SEQUENCER_RESOURCE_OUTSIDE_PROJECT");
expect(result.cycle).toContain("SEQUENCER_DEPENDENCY_CYCLE");
expect(result.budget).toContain("SEQUENCER_BUDGET_EXCEEDED");
expect(result.codec).toBe("SEQUENCER_CODEC_UNSUPPORTED");
expect((result.runtime as { localEncoding: string }).localEncoding).toBe("BLOCKED");
});
test("validates N-022 tracking markers, masks, resource bindings and solve gates", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/tracking-mask-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.edit).toEqual([6, [1, 10], [0.4, 0.6]]);
expect(result.revision).toContain("REVISION_CONFLICT");
expect(result.path).toContain("TRACKING_RESOURCE_OUTSIDE_PROJECT");
expect(result.binding).toContain("TRACKING_BINDING_MISSING");
expect(result.budget).toContain("TRACKING_BUDGET_EXCEEDED");
expect(result.browserGate).toBe("TRACKING_SOLVE_UNAVAILABLE");
expect(result.verifiedGate).toBe("READY");
expect(result.solveGate).toBe("BLOCKED");
});
test("validates N-023 asset catalogs, library graphs, archive budgets and IO gates", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/asset-library-io-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toEqual(["CC0-1.0", ["library:b", "library:a"]]);
expect(result.hash).toContain("ASSET_SOURCE_HASH_MISMATCH");
expect(result.license).toContain("ASSET_LICENSE_MISSING");
expect(result.cycle).toContain("LIBRARY_DEPENDENCY_CYCLE");
expect(result.archive).toContain("IO_ARCHIVE_UNSAFE");
expect(result.uri).toContain("IO_EXTERNAL_URI_BLOCKED");
expect(result.glb).toBe("READY");
expect(result.obj).toBe("IO_FORMAT_UNSUPPORTED");
expect(result.library).toBe("BLOCKED");
expect((result.storage as { contentAddressedIndex: string }).contentAddressedIndex).toBe("LOCAL_BOUNDED");
});
test("validates N-024 editor context, selection sync, layout budgets and workflow gates", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/editor-workflow-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.edit).toEqual([2, "TIMELINE", ["object:2"]]);
expect(result.revision).toContain("REVISION_CONFLICT");
expect(result.overlap).toContain("EDITOR_LAYOUT_INVALID");
expect(result.budget).toContain("EDITOR_SELECTION_INVALID");
expect(result.view).toBe("READY");
expect(result.writer).toBe("EDITOR_WRITER_UNAVAILABLE");
expect(result.gizmo).toBe("EDITOR_GIZMO_UNAVAILABLE");
});
test("validates N-025 script policy, signatures, budgets and platform gates", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/scripting-platform-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toBe("scripts/clean.py");
expect(result.exec).toBe("SCRIPT_SANDBOX_UNAVAILABLE");
expect(result.server).toBe("SERVER_JOB_UNAVAILABLE");
expect(result.path).toContain("SCRIPT_MANIFEST_INVALID");
expect(result.policy).toContain("SCRIPT_POLICY_DENIED");
expect(result.budget).toContain("SCRIPT_BUDGET_EXCEEDED");
expect(result.signature).toContain("SCRIPT_SIGNATURE_INVALID");
expect((result.platform as { nativeWindow: string }).nativeWindow).toBe("BLOCKED");
});
test("keeps N-026 release manifest deterministic and blocks missing evidence", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/release-gate-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.valid).toEqual([2, true]);
expect(result.gate).toEqual(["BLOCKED", expect.arrayContaining(["RELEASE_EVIDENCE_MISSING", "RELEASE_TEST_CHANNEL_MISSING", "RELEASE_PERFORMANCE_MISSING", "RELEASE_FAULT_EVIDENCE_MISSING", "RELEASE_PROVENANCE_MISSING"])]);
expect(result.oldSchema).toContain("PROTOCOL_MISMATCH");
expect(result.cycle).toContain("RELEASE_DEPENDENCY_CYCLE");
expect(result.status).toContain("RELEASE_MANIFEST_INVALID");
expect(result.unbound).toContain("RELEASE_EVIDENCE_MISSING");
});
test("keeps N-015 selection history bounded and rejects stale raycast hits", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/selection-history-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.history).toEqual([3, 1, [
["curve:1", [1, 3], "HANDLE_LEFT"],
["curve:2", [2], "HANDLE_RIGHT"],
]]);
expect(result.revision).toContain("REVISION_CONFLICT");
expect(result.budget).toContain("SELECTION_HISTORY_BUDGET_EXCEEDED");
expect(result.raycast).toContain("RAYCAST_HIT_INVALID");
expect(result.handleHit).toEqual(["object:1", "HANDLE_RIGHT"]);
expect(result.rangePatch).toEqual([["curve:1", [1, 2, 4]]]);
expect(result.migrated).toEqual([2, "mesh:1"]);
expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]);
});
test("validates bounded OpenVDB metadata, SHA and cancellation", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ decodedByteLength: number; outsideProject: string; cancelled: boolean }>((resolve, reject) => {
const worker = new Worker("/src/workers/vdb-test.worker.ts", { type: "module" });
worker.onmessage = (event) => { worker.terminate(); if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.decodedByteLength).toBe(4);
expect(result.outsideProject).toContain("NON_MESH_RESOURCE_OUTSIDE_PROJECT");
expect(result.cancelled).toBe(true);
});
test("returns real Blender evaluations for legacy non-mesh geometry", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(nonMeshBlend));
const geometries = await page.evaluate((input) => new Promise<Array<{ sourceType: string; status: string; vertexCount: number; triangleCount: number; positions?: number[]; indices?: number[] }>>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
worker.onmessage = (event) => {
if (event.data.kind !== "result") return;
if (!event.data.ok) {
worker.terminate();
reject(new Error(event.data.error?.message ?? "depsgraph evaluation failed"));
}
else if (event.data.requestId === "evaluation-open") {
worker.postMessage({ requestId: "evaluation-run", command: { type: "evaluateDepsgraph" } });
}
else if (event.data.requestId === "evaluation-run") {
worker.terminate();
resolve(event.data.result?.depsgraph?.nonMeshGeometries ?? []);
}
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: "evaluation-open", command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(new Set(geometries.filter((geometry) => geometry.status === "EVALUATED").map((geometry) => geometry.sourceType))).toEqual(new Set(["CURVE", "SURFACE", "FONT", "METABALL"]));
for (const geometry of geometries) {
if (geometry.status !== "EVALUATED") continue;
expect(geometry.vertexCount).toBeGreaterThan(0);
expect(geometry.positions).toHaveLength(geometry.vertexCount * 3);
expect(geometry.indices).toHaveLength(geometry.triangleCount * 3);
}
});
test("keeps N-015 non-mesh previews in the OffscreenCanvas renderer", async ({ page }) => {
await page.goto("/?offscreen=1");
await page.setInputFiles("[data-testid=blend-file-input]", nonMeshBlend);
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-renderer-backend", "offscreen-worker");
await expect(canvas).toHaveAttribute("data-non-mesh-count", "6", { timeout: 20_000 });
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2");
await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
expect(await canvas.getAttribute("data-renderer-error")).toBeNull();
});
test("runs Main-authoritative object and edit-mode modeling workflows", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 3");
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 4");
await page.getByRole("button", { name: "复制对象", exact: true }).click();
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 5");
await page.getByRole("button", { name: "Object Mode" }).click();
await expect(page.getByRole("button", { name: "Edit Mode" })).toBeVisible();
await page.getByRole("button", { name: "移动工具" }).click();
await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r");
await page.getByRole("button", { name: "Edit Mode" }).click();
await page.getByRole("button", { name: "删除对象" }).click();
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 4");
await page.getByText("BasicCube", { exact: true }).click();
await page.getByText("BasicCamera", { exact: true }).click({ modifiers: ["Shift"] });
await expect(page.getByText("2 selected", { exact: true })).toBeVisible();
});
test("saves the opened blend through StorageWorker before download", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("blender-web.blend");
});
test("autosaves a dirty project without starting a download", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "已保存" }).click();
await expect(page.getByRole("button", { name: "已保存" })).toBeVisible({ timeout: 5_000 });
});
test("persists an operation log entry with an inverse payload", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ id: string; revision: number; persisted: boolean }>((resolve, reject) => {
const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" });
const requestId = "storage-operation-e2e";
worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { id: string; revision: number; persisted: boolean }; error?: string }>) => {
if (event.data.requestId !== requestId) return;
worker.terminate();
if (!event.data.ok || !event.data.result) {
reject(new Error(event.data.error ?? "operation log failed"));
return;
}
resolve(event.data.result);
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ requestId, command: { type: "appendOperation", id: "op-e2e", projectId: "save-e2e", revision: 8, payload: { type: "transform" }, inversePayload: { type: "restore" } } });
}));
expect(result).toEqual({ id: "op-e2e", projectId: "save-e2e", revision: 8, persisted: true });
});
test("retains bounded snapshots and returns a validated operation replay plan", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const projectId = `replay-e2e-${Date.now()}`;
const client = new StorageClient();
for (let revision = 1; revision <= 4; revision++) {
await client.saveSnapshot(projectId, revision, Uint8Array.from([revision, revision + 1]).buffer, 2, 16);
}
await client.appendOperation("op-r3", projectId, 3, { type: "setFrame", frame: 3 });
await client.appendOperation("op-r5", projectId, 5, { type: "setFrame", frame: 5 });
await client.appendOperation("op-r4", projectId, 4, { type: "setFrame", frame: 4 });
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("blender-web-metadata", 6);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const corrupt = db.transaction("operation_log", "readwrite");
corrupt.objectStore("operation_log").put({ id: "broken-op", projectId, revision: "bad", payload: null, createdAt: "never" });
await new Promise<void>((resolve, reject) => { corrupt.oncomplete = () => resolve(); corrupt.onerror = () => reject(corrupt.error); });
db.close();
const snapshots = await client.listSnapshots(projectId);
const latest = await client.readSnapshot(projectId, snapshots.snapshots[0].revision);
const replay = await client.listOperations(projectId, 3);
const pruned = await client.pruneOperations(projectId, 4);
client.terminate();
return {
snapshotRevisions: snapshots.snapshots.map((item) => item.revision),
latest: Array.from(new Uint8Array(latest.buffer)),
replayRevisions: replay.operations.map((item) => item.revision),
quarantined: replay.quarantined,
pruned: pruned.removed,
};
});
expect(result).toEqual({ snapshotRevisions: [4, 3], latest: [4, 5], replayRevisions: [4, 5], quarantined: 1, pruned: 2 });
});
test("reports quota exhaustion without replacing the committed project", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const client = new StorageClient();
const projectId = `quota-e2e-${Date.now()}`;
await client.saveProject(projectId, 1, Uint8Array.from([1, 2, 3]).buffer);
let error = "";
try { await client.saveProject(projectId, 2, Uint8Array.from([9, 9, 9]).buffer, "quota"); }
catch (caught) { error = caught instanceof Error ? caught.message : String(caught); }
const restored = await client.readProject(projectId);
client.terminate();
return { error, revision: restored.revision, bytes: Array.from(new Uint8Array(restored.buffer)) };
});
expect(result.error).toContain("QuotaExceededError");
expect(result.revision).toBe(1);
expect(result.bytes).toEqual([1, 2, 3]);
});
test("enforces a real OPFS quota without replacing the committed project", async ({ page }) => {
await page.goto("/");
const cdp = await page.context().newCDPSession(page);
await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 64 * 1024 });
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const projectId = `real-quota-e2e-${Date.now()}`;
const client = new StorageClient();
const initial = await client.saveProject(projectId, 1, Uint8Array.from({ length: 1024 }, (_, index) => index & 0xff).buffer);
let error = "";
try {
await client.saveProject(projectId, 2, Uint8Array.from({ length: 128 * 1024 }, (_, index) => (index * 7) & 0xff).buffer);
}
catch (caught) {
error = caught instanceof Error ? caught.message : String(caught);
}
const restored = await client.readProject(projectId);
client.terminate();
const restarted = new StorageClient();
const reopened = await restarted.readProject(projectId);
restarted.terminate();
return {
backend: initial.backend,
error,
revision: restored.revision,
reopenedRevision: reopened.revision,
bytes: Array.from(new Uint8Array(reopened.buffer.slice(0, 4))),
};
});
expect(result.backend).toBe("opfs");
expect(result.error).toMatch(/QuotaExceededError|quota/i);
expect(result.revision).toBe(1);
expect(result.reopenedRevision).toBe(1);
expect(result.bytes).toEqual([0, 1, 2, 3]);
});
test("persists and revalidates content-addressed Simulation caches across Worker restarts", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const digest = async (data: ArrayBuffer): Promise<string> => {
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
};
const projectId = `simulation-e2e-${Date.now()}`;
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44]).buffer;
const source = Uint8Array.from([11, 12, 13, 21, 22, 23, 24]);
const frameOne = source.buffer.slice(0, 3);
const frameTwo = source.buffer.slice(3);
const payload = source.buffer.slice(0);
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
const manifest = {
schemaVersion: 1 as const,
graphId: "geometry-node-tree:simulation-e2e",
graphHash: fixedHash,
sourceBlendSha256: await digest(sourceBlend),
inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer),
cacheSha256: await digest(payload),
blenderVersion: "5.2.0",
frameStart: 1,
frameEnd: 2,
byteLength: payload.byteLength,
frames: [
{ frame: 1, byteOffset: 0, byteLength: 3, sha256: await digest(frameOne) },
{ frame: 2, byteOffset: 3, byteLength: 4, sha256: await digest(frameTwo) },
],
};
const first = new StorageClient();
await first.saveProject(projectId, 1, sourceBlend.slice(0));
const stored = await first.putSimulationCache(projectId, manifest, payload);
first.terminate();
const restarted = new StorageClient();
const listed = await restarted.listSimulationCaches(projectId);
const read = await restarted.readSimulationCache(projectId, stored.cacheKey);
const frameRead = await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 2);
let missingFrameCode = "";
try {
await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 3);
}
catch (error) {
missingFrameCode = String((error as Error & { code?: string }).code ?? "");
}
let corruptCode = "";
try {
await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, source.buffer.slice(0));
}
catch (error) {
corruptCode = String((error as Error & { code?: string }).code ?? "");
}
restarted.terminate();
return {
cacheKey: stored.cacheKey,
path: stored.path,
listed: listed.caches.map((cache) => cache.cacheKey),
bytes: Array.from(new Uint8Array(read.data)),
frame: frameRead.frame,
frameOffset: frameRead.byteOffset,
frameBytes: Array.from(new Uint8Array(frameRead.data)),
missingFrameCode,
corruptCode,
};
});
expect(result.cacheKey).toMatch(/^[a-f0-9]{16}-[a-f0-9]{16}-[a-f0-9]{16}-1-2$/);
expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
expect(result.listed).toContain(result.cacheKey);
expect(result.bytes).toEqual([11, 12, 13, 21, 22, 23, 24]);
expect(result.frame).toBe(2);
expect(result.frameOffset).toBe(3);
expect(result.frameBytes).toEqual([21, 22, 23, 24]);
expect(result.missingFrameCode).toBe("SIMULATION_CACHE_MISSING");
expect(result.corruptCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
});
test("stores LOD cache bytes in OPFS and indexes its manifest", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { buildLODCacheKey } = await import("/src/three-adapter/lod.ts");
return new Promise<{ bytes: number; path: string; persisted: boolean; manifest?: { cacheKey: string } }>((resolve, reject) => {
const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" });
const cacheKey = buildLODCacheKey({ objectId: "object-e2e", meshRevision: 7, modifierStackHash: "stack-hash", profileHash: "profile-hash", poseOrRestState: "REST" });
const manifest = {
schemaVersion: 1,
meshId: "mesh-e2e",
sourceMeshRevision: 7,
cacheKey,
objectId: "object-e2e",
modifierStackHash: "stack-hash",
profileHash: "profile-hash",
poseOrRestState: "REST",
generatedAt: "2026-08-06T00:00:00.000Z",
levels: [
{ level: 0, sourceMeshRevision: 7, triangleBudget: 1200, meshId: "mesh-e2e-l0" },
{ level: 1, sourceMeshRevision: 7, triangleBudget: 400, meshId: "mesh-e2e-l1" },
],
};
const bytes = Uint8Array.from([0x4c, 0x4f, 0x44, 0x31]).buffer;
worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { bytes?: number; path?: string; persisted?: boolean; manifest?: { cacheKey: string }; manifests?: Array<{ cacheKey: string }> }; error?: string }>) => {
if (event.data.requestId === "lod-save-e2e" && event.data.ok) {
worker.postMessage({ requestId: "lod-put-e2e", command: { type: "putLODManifest", projectId: "lod-e2e", manifest } });
}
else if (event.data.requestId === "lod-put-e2e" && event.data.ok) {
worker.postMessage({ requestId: "lod-get-e2e", command: { type: "getLODManifest", projectId: "lod-e2e", cacheKey } });
}
else if (event.data.requestId === "lod-get-e2e") {
if (!event.data.ok || !event.data.result) reject(new Error(event.data.error ?? "LOD manifest read failed"));
else worker.postMessage({ requestId: "lod-list-e2e", command: { type: "listLODManifests", projectId: "lod-e2e" } });
}
else if (event.data.requestId === "lod-list-e2e") {
worker.terminate();
if (!event.data.ok || !event.data.result) reject(new Error(event.data.error ?? "LOD manifest list failed"));
else resolve({ bytes: 4, path: `projects/lod-e2e/cache/lod/${cacheKey}.mesh`, persisted: event.data.result.manifests?.some((item: { cacheKey: string }) => item.cacheKey === cacheKey) ?? false, manifest: { cacheKey } });
}
else if (!event.data.ok) {
worker.terminate();
reject(new Error(event.data.error ?? "LOD storage failed"));
}
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ requestId: "lod-save-e2e", command: { type: "saveLOD", projectId: "lod-e2e", cacheKey, data: bytes } }, [bytes]);
});
});
expect(result.bytes).toBe(4);
expect(result.path).toMatch(/^projects\/lod-e2e\/cache\/lod\/lod-[0-9a-f]+\.mesh$/);
expect(result.persisted).toBe(true);
expect(result.manifest?.cacheKey).toMatch(/^lod-[0-9a-f]+$/);
});
test("selects Three.js LOD levels with hysteresis and stable cache keys", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { buildLODCacheKey, selectLODLevel } = await import("/src/three-adapter/lod.ts");
const policy = { screenHeightThresholds: [512, 128, 32], hysteresis: 0.1 };
return {
near: selectLODLevel(600, policy).level,
boundary: selectLODLevel(490, policy, 0).level,
far: selectLODLevel(20, policy, 1).level,
keyA: buildLODCacheKey({ objectId: "cube", meshRevision: 4, modifierStackHash: "stack", profileHash: "profile", poseOrRestState: "REST" }),
keyB: buildLODCacheKey({ objectId: "cube", meshRevision: 4, modifierStackHash: "stack", profileHash: "profile", poseOrRestState: "REST" }),
};
});
expect(result.near).toBe(0);
expect(result.boundary).toBe(0);
expect(result.far).toBe(2);
expect(result.keyA).toBe(result.keyB);
});
test("reads and evicts LOD cache entries by byte budget", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { buildLODCacheKey } = await import("/src/three-adapter/lod.ts");
return new Promise<{ readBytes: number; removed: number; removedBytes: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" });
const cacheKey = buildLODCacheKey({ objectId: "evict-object", meshRevision: 3, modifierStackHash: "stack", profileHash: "profile", poseOrRestState: "REST" });
const manifest = {
schemaVersion: 1,
meshId: "evict-mesh",
sourceMeshRevision: 3,
cacheKey,
objectId: "evict-object",
modifierStackHash: "stack",
profileHash: "profile",
poseOrRestState: "REST",
generatedAt: "2026-08-06T00:00:00.000Z",
byteLength: 6,
levels: [{ level: 0, sourceMeshRevision: 3, triangleBudget: 100, meshId: "evict-l0" }],
};
const bytes = Uint8Array.from([1, 2, 3, 4, 5, 6]).buffer;
let readBytes = 0;
worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { data?: ArrayBuffer; bytes?: number; removed?: number; removedBytes?: number }; error?: string }>) => {
if (!event.data.ok) {
worker.terminate();
reject(new Error(event.data.error ?? "LOD cache operation failed"));
}
else if (event.data.requestId === "lod-read-e2e") {
readBytes = event.data.result?.bytes ?? 0;
worker.postMessage({ requestId: "lod-prune-e2e", command: { type: "pruneLOD", projectId: "lod-evict-e2e", maxBytes: 0 } });
}
else if (event.data.requestId === "lod-prune-e2e") {
worker.terminate();
resolve({ readBytes, removed: event.data.result?.removed ?? 0, removedBytes: event.data.result?.bytes ?? 0 });
}
else if (event.data.requestId === "lod-save-e2e") worker.postMessage({ requestId: "lod-put-e2e", command: { type: "putLODManifest", projectId: "lod-evict-e2e", manifest } });
else if (event.data.requestId === "lod-put-e2e") worker.postMessage({ requestId: "lod-read-e2e", command: { type: "readLOD", projectId: "lod-evict-e2e", cacheKey } });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ requestId: "lod-save-e2e", command: { type: "saveLOD", projectId: "lod-evict-e2e", cacheKey, data: bytes } }, [bytes]);
});
});
expect(result.readBytes).toBe(6);
expect(result.removed).toBe(1);
expect(result.removedBytes).toBe(6);
});
test("renders local UV/color/material-slot geometry", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend);
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 1 · Vertices 5 · Faces 2");
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-renderer-backend", "webgl-pbr");
await expect(canvas).toHaveAttribute("data-pbr-profile", "physical-v1");
await expect(canvas).toHaveAttribute("data-tone-mapping", "aces");
await expect(canvas).toHaveAttribute("data-shadow-map", "pcf-1024");
const renderedPixels = await canvas.evaluate((canvas) => {
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (!gl) return 0;
const pixels = new Uint8Array(4 * 16 * 16);
gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
return pixels.reduce((count, value, index) => count + (index % 4 !== 3 && value > 50 ? 1 : 0), 0);
});
expect(renderedPixels).toBeGreaterThan(0);
});
test("maps Blender Principled values to the bounded physical viewport material", async ({ page }) => {
await page.goto("/");
const mapped = await page.evaluate(async () => {
const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts");
const material = createPBRMaterial({
id: "material:pbr",
name: "PBR",
baseColor: [0.2, 0.4, 0.8, 0.9],
roughness: 0.21,
metallic: 0.62,
emissionColor: [0.05, 0.1, 0.2, 1],
alpha: 0.9,
ior: 1.52,
specularIORLevel: 0.35,
transmissionWeight: 0.27,
coatWeight: 0.64,
coatRoughness: 0.12,
emissionStrength: 3.5,
});
const result = {
type: material.type,
roughness: material.roughness,
metalness: material.metalness,
ior: material.ior,
specularIntensity: material.specularIntensity,
transmission: material.transmission,
clearcoat: material.clearcoat,
clearcoatRoughness: material.clearcoatRoughness,
emissiveIntensity: material.emissiveIntensity,
opacity: material.opacity,
transparent: material.transparent,
depthWrite: material.depthWrite,
profile: material.userData.pbrProfile,
};
material.dispose();
return result;
});
expect(mapped).toEqual({
type: "MeshPhysicalMaterial",
roughness: 0.21,
metalness: 0.62,
ior: 1.52,
specularIntensity: 0.7,
transmission: 0.27,
clearcoat: 0.64,
clearcoatRoughness: 0.12,
emissiveIntensity: 3.5,
opacity: 0.9,
transparent: true,
depthWrite: false,
profile: "physical-v1",
});
});
test("authors advanced Principled PBR controls through Blender Main", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend);
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
await page.getByRole("slider", { name: "材质 IOR" }).fill("1.52");
await page.getByRole("slider", { name: "材质镜面 IOR" }).fill("0.35");
await page.getByRole("slider", { name: "材质透射" }).fill("0.27");
await page.getByRole("slider", { name: "材质涂层", exact: true }).fill("0.64");
await page.getByRole("slider", { name: "材质涂层粗糙度" }).fill("0.12");
await page.getByRole("spinbutton", { name: "材质自发光强度" }).fill("3.5");
await page.getByRole("button", { name: "应用 Principled" }).click();
await expect(page.locator("[data-testid=engine-status]")).toContainText(/SceneIR r\d+/, { timeout: 15_000 });
});
test("captures nonblank PBR viewport framing at desktop and mobile sizes", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend);
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-pbr-profile", "physical-v1");
await page.screenshot({ path: testInfo.outputPath("pbr-desktop-1440x900.png"), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(canvas).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
const bounds = await canvas.boundingBox();
expect(bounds?.width ?? 0).toBeGreaterThan(100);
expect(bounds?.height ?? 0).toBeGreaterThan(100);
const sampleCanvas = (): Promise<{ visible: number; hash: number }> => canvas.evaluate((element) => {
const gl = element.getContext("webgl2") ?? element.getContext("webgl");
if (!gl) return { visible: 0, hash: 0 };
const size = 32;
const pixels = new Uint8Array(size * size * 4);
gl.readPixels(Math.max(0, Math.floor((gl.drawingBufferWidth - size) / 2)), Math.max(0, Math.floor((gl.drawingBufferHeight - size) / 2)), size, size, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let visible = 0;
let hash = 2166136261;
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index] > 40 || pixels[index + 1] > 40 || pixels[index + 2] > 40) visible++;
hash = Math.imul(hash ^ pixels[index], 16777619);
hash = Math.imul(hash ^ pixels[index + 1], 16777619);
hash = Math.imul(hash ^ pixels[index + 2], 16777619);
}
return { visible, hash: hash >>> 0 };
});
const beforeOrbit = await sampleCanvas();
expect(beforeOrbit.visible).toBeGreaterThan(0);
if (!bounds) throw new Error("mobile viewport has no bounds");
await page.mouse.move(bounds.x + bounds.width * 0.25, bounds.y + bounds.height * 0.45);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width * 0.35, bounds.y + bounds.height * 0.5, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(200);
const afterOrbit = await sampleCanvas();
expect(afterOrbit.visible).toBeGreaterThan(0);
expect(afterOrbit.hash).not.toBe(beforeOrbit.hash);
await page.screenshot({ path: testInfo.outputPath("pbr-mobile-390x844.png"), fullPage: true });
});
test("shows mode-specific Blender Decimate inspector controls", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
const mode = page.getByRole("combobox", { name: "Decimate 模式" });
await mode.selectOption("UNSUBDIV");
await expect(page.getByRole("spinbutton", { name: "Un-Subdivide 迭代" })).toHaveValue("1");
await mode.selectOption("DISSOLVE_PLANAR");
await expect(page.getByRole("slider", { name: "Dissolve 角度" })).toHaveValue("5");
await expect(page.getByRole("checkbox", { name: "Dissolve 边界" })).toBeVisible();
await expect(page.getByRole("group", { name: "分隔" })).toBeVisible();
await mode.selectOption("COLLAPSE");
await expect(page.getByRole("slider", { name: "Decimate 比例" })).toHaveValue("0.5");
await expect(page.getByRole("checkbox", { name: "Decimate 三角化" })).toBeVisible();
await expect(page.getByRole("checkbox", { name: "Decimate 对称" })).toBeVisible();
await expect(page.getByRole("combobox", { name: "Decimate 属性策略" })).toHaveValue("PRESERVE");
await page.getByRole("button", { name: "预览 Decimate" }).click();
await expect(page.locator("[data-testid=engine-status]")).toContainText("Preview", { timeout: 10_000 });
await page.getByRole("button", { name: "取消 Decimate 预览" }).click();
await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r1");
await page.getByRole("button", { name: "生成 LOD" }).click();
await expect(page.locator("[data-testid=engine-status]")).toContainText("LOD:", { timeout: 15_000 });
});
test("discovers and reuses a valid LOD cache after an application refresh", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "生成 LOD" }).click();
await expect(page.locator("[data-testid=engine-status]")).toContainText("LOD:", { timeout: 15_000 });
await page.reload();
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.locator("[data-testid=engine-status]")).toContainText("cached LOD mesh", { timeout: 15_000 });
});
test("workspace context routes mode and operator search state", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Modeling" }).click();
await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Modeling");
await page.getByRole("button", { name: "Object Mode" }).click();
await expect(page.getByRole("button", { name: "Edit Mode" })).toBeVisible();
await page.getByRole("button", { name: "操作搜索" }).click();
await page.getByRole("textbox", { name: "搜索操作" }).fill("cube");
await expect(page.getByRole("button", { name: "Add Cube" })).toBeVisible();
});
test("keeps Outliner selection bound to SceneIR activeObjectId", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.locator(".tree-row.child", { hasText: "BasicCamera" }).click();
await expect(page.locator(".tree-row.selected")).toContainText("BasicCamera");
});
test("Outliner visibility button applies a native command", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
const visibilityButton = page.getByRole("button", { name: "隐藏 BasicCube", exact: true });
await visibilityButton.click();
await expect(page.getByRole("button", { name: "显示 BasicCube", exact: true })).toBeVisible();
await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r2 (3 objects)");
});
test("timeline transport controls update the imported frame range", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "跳到最后一帧" }).click();
await expect(page.locator(".frame-number")).toHaveText("24");
await page.getByRole("button", { name: "跳到第一帧" }).click();
await expect(page.locator(".frame-number")).toHaveText("1");
});
test("loads the local web_engine WASM worker", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ ok: boolean; ready?: boolean; error?: string }>((resolve) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const requestId = "e2e-web-engine";
worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { status: { ready: boolean } }; error?: { message: string } }>) => {
if (event.data.requestId !== requestId) return;
worker.terminate();
resolve({ ok: event.data.kind === "result" && event.data.ok === true, ready: event.data.result?.status.ready, error: event.data.error?.message });
};
worker.onerror = (event) => {
worker.terminate();
resolve({ ok: false, error: event.message });
};
worker.postMessage({ requestId, command: { type: "init" } });
}));
expect(result).toEqual({ ok: true, ready: true });
});
test("transfers binary mesh buffers from the WebEngine worker", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate((input) => new Promise<{ status: string; positions: number; indices: number; byteLength: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const requestId = "e2e-binary-geometry";
worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { meshes: Array<{ geometryStatus: string }> }; geometryBuffers?: Array<{ positions: ArrayBuffer; indices: ArrayBuffer; byteLength: number }> }; error?: { message: string } }>) => {
if (event.data.requestId !== requestId) return;
if (event.data.kind !== "result") return;
worker.terminate();
if (!event.data.ok || !event.data.result?.snapshot || !event.data.result.geometryBuffers?.[0]) {
reject(new Error(event.data.error?.message ?? `binary geometry response missing: ${JSON.stringify(event.data)}`));
return;
}
const mesh = event.data.result.snapshot.meshes[0];
const payload = event.data.result.geometryBuffers[0];
resolve({ status: mesh.geometryStatus, positions: payload.positions.byteLength, indices: payload.indices.byteLength, byteLength: payload.byteLength });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.status).toBe("binary");
expect(result.positions).toBe(8 * 3 * 4);
expect(result.indices).toBe(12 * 3 * 4);
expect(result.byteLength).toBeGreaterThan(result.positions + result.indices);
});
test("returns a revisioned native SceneDelta", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate((input) => new Promise<{ baseRevision: number; nextRevision: number; added: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openRequestId = "e2e-delta-open";
const deltaRequestId = "e2e-delta-read";
worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { delta?: { baseRevision: number; nextRevision: number; nodes?: { added?: unknown[] } } }; error?: { message: string } }>) => {
if (event.data.kind !== "result") return;
if (event.data.requestId === openRequestId && event.data.ok) {
worker.postMessage({ requestId: deltaRequestId, command: { type: "delta" } });
return;
}
if (event.data.requestId !== deltaRequestId) return;
worker.terminate();
const delta = event.data.result?.delta;
if (!event.data.ok || !delta) {
reject(new Error(event.data.error?.message ?? "delta response missing"));
return;
}
resolve({ baseRevision: delta.baseRevision, nextRevision: delta.nextRevision, added: delta.nodes?.added?.length ?? 0 });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result).toEqual({ baseRevision: 0, nextRevision: 1, added: 3 });
});
test("applies an object-mode command and returns the matching SceneDelta", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate((input) => new Promise<{
frame: number;
frameBase: number;
frameNext: number;
visible: boolean;
visibilityBase: number;
visibilityNext: number;
}>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openRequestId = "e2e-command-open";
const frameRequestId = "e2e-command-frame";
const visibilityRequestId = "e2e-command-visibility";
let openedRevision = 0;
worker.onmessage = (event: MessageEvent<{
kind: string;
requestId: string;
ok?: boolean;
result?: {
snapshot?: { revision: number; frame: { current: number }; nodes: Array<{ id: string; visible: boolean }> };
delta?: { baseRevision: number; nextRevision: number; frame?: { current: number } };
};
error?: { message: string };
}>) => {
if (event.data.kind !== "result") return;
if (event.data.requestId === openRequestId && event.data.ok && event.data.result?.snapshot) {
openedRevision = event.data.result.snapshot.revision;
worker.postMessage({ requestId: frameRequestId, command: { type: "applyCommand", payload: { type: "setFrame", frame: 12 } } });
return;
}
if (event.data.requestId === frameRequestId && event.data.ok && event.data.result?.delta) {
const delta = event.data.result.delta;
if (delta.baseRevision !== openedRevision || delta.nextRevision !== openedRevision + 1 || delta.frame?.current !== 12) {
worker.terminate();
reject(new Error(`unexpected frame command delta: ${JSON.stringify(delta)}`));
return;
}
worker.postMessage({ requestId: visibilityRequestId, command: { type: "applyCommand", payload: { type: "setObjectVisibility", objectId: "object:BasicCube", visible: false } } });
return;
}
if (event.data.requestId !== visibilityRequestId) return;
worker.terminate();
if (!event.data.ok || !event.data.result?.snapshot || !event.data.result.delta) {
reject(new Error(event.data.error?.message ?? "visibility command failed"));
return;
}
const snapshot = event.data.result.snapshot;
const delta = event.data.result.delta;
resolve({
frame: snapshot.frame.current,
frameBase: openedRevision,
frameNext: openedRevision + 1,
visible: snapshot.nodes.find((node) => node.id === "object:BasicCube")?.visible ?? true,
visibilityBase: delta.baseRevision,
visibilityNext: delta.nextRevision,
});
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.frame).toBe(12);
expect(result.frameBase).toBe(1);
expect(result.frameNext).toBe(2);
expect(result.visible).toBe(false);
expect(result.visibilityBase).toBe(2);
expect(result.visibilityNext).toBe(3);
});
test("undoes and redoes native scene commands without a Three.js shadow state", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate((input) => new Promise<{ undoFrame: number; redoFrame: number; undoRevision: number; redoRevision: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const ids = { open: "history-open", apply: "history-apply", undo: "history-undo", redo: "history-redo" };
let undoFrame = 0;
let undoRevision = 0;
worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; frame: { current: number } } }; error?: { message: string } }>) => {
if (event.data.kind !== "result") return;
if (!event.data.ok) { worker.terminate(); reject(new Error(event.data.error?.message ?? "history command failed")); return; }
if (event.data.requestId === ids.open) worker.postMessage({ requestId: ids.apply, command: { type: "applyCommand", payload: { type: "setFrame", frame: 12 } } });
else if (event.data.requestId === ids.apply) worker.postMessage({ requestId: ids.undo, command: { type: "applyCommand", payload: { type: "undo" } } });
else if (event.data.requestId === ids.undo && event.data.result?.snapshot) {
undoFrame = event.data.result.snapshot.frame.current;
undoRevision = event.data.result.snapshot.revision;
worker.postMessage({ requestId: ids.redo, command: { type: "applyCommand", payload: { type: "redo" } } });
}
else if (event.data.requestId === ids.redo && event.data.result?.snapshot) {
worker.terminate();
resolve({ undoFrame, redoFrame: event.data.result.snapshot.frame.current, undoRevision, redoRevision: event.data.result.snapshot.revision });
}
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: ids.open, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.undoFrame).toBe(1);
expect(result.redoFrame).toBe(12);
expect(result.undoRevision).toBe(3);
expect(result.redoRevision).toBe(4);
});
test("wires Blender Web top-bar undo and redo to native history", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "跳到最后一帧" }).click();
await expect(page.locator(".frame-number")).toHaveText("24");
await page.getByRole("button", { name: "撤销" }).click();
await expect(page.locator(".frame-number")).toHaveText("1");
await page.getByRole("button", { name: "重做" }).click();
await expect(page.locator(".frame-number")).toHaveText("24");
});
test("applies Blender's native Decimate topology through the WebEngine command path", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate((input) => new Promise<{ originalTriangles: number; outputTriangles: number; reportedOriginal: number; reportedOutput: number; originalVertices: number; outputVertices: number; geometryBytes: number; normals: number; corners: number; uvs: number; materials: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openRequestId = "e2e-decimate-open";
const decimateRequestId = "e2e-decimate-apply";
worker.onmessage = (event: MessageEvent<{
kind: string;
requestId: string;
ok?: boolean;
result?: { snapshot?: { revision: number; meshes: Array<{ id: string; vertexCount: number; triangleCount?: number }> }; geometryDelta?: { replaced: Array<{ meshId: string; positions: ArrayBuffer; indices: ArrayBuffer; normals?: ArrayBuffer; triangleCornerIndices?: ArrayBuffer; uvs?: ArrayBuffer; triangleMaterialIndices?: ArrayBuffer }> }; simplify?: { originalTriangleCount: number; outputTriangleCount: number } };
error?: { message: string };
}>) => {
if (event.data.kind !== "result") return;
if (event.data.requestId === openRequestId && event.data.ok && event.data.result?.snapshot) {
const mesh = event.data.result.snapshot.meshes[0];
worker.postMessage({ requestId: decimateRequestId, command: { type: "applyCommand", payload: {
type: "decimateMesh",
meshId: mesh.id,
profile: { schemaVersion: 1, sourceMeshRevision: event.data.result.snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, vertexGroup: null, vertexGroupFactor: 1, vertexGroupInvert: false, triangulate: false, useSymmetry: false, symmetryAxis: 0, symmetryTolerance: 1e-4 },
} } });
return;
}
if (event.data.requestId !== decimateRequestId) return;
worker.terminate();
if (!event.data.ok || !event.data.result?.snapshot || !event.data.result.geometryDelta?.replaced[0] || !event.data.result.simplify) {
reject(new Error(event.data.error?.message ?? "native decimate command failed"));
return;
}
const mesh = event.data.result.snapshot.meshes[0];
const geometry = event.data.result.geometryDelta.replaced[0];
resolve({
originalTriangles: 12,
outputTriangles: mesh.triangleCount ?? 0,
reportedOriginal: event.data.result.simplify.originalTriangleCount,
reportedOutput: event.data.result.simplify.outputTriangleCount,
originalVertices: 8,
outputVertices: mesh.vertexCount,
geometryBytes: geometry.positions.byteLength + geometry.indices.byteLength,
normals: geometry.normals ? new Float32Array(geometry.normals).length : 0,
corners: geometry.triangleCornerIndices ? new Uint32Array(geometry.triangleCornerIndices).length : 0,
uvs: geometry.uvs ? new Float32Array(geometry.uvs).length : 0,
materials: geometry.triangleMaterialIndices ? new Uint32Array(geometry.triangleMaterialIndices).length : 0,
});
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.outputTriangles).toBeLessThan(result.originalTriangles);
expect(result.reportedOriginal).toBe(result.originalTriangles);
expect(result.reportedOutput).toBe(result.outputTriangles);
expect(result.outputVertices).toBeLessThan(result.originalVertices);
expect(result.geometryBytes).toBeGreaterThan(0);
expect(result.normals).toBe(result.outputVertices * 3);
expect(result.corners).toBe(result.outputTriangles * 3);
expect(result.uvs).toBe(result.outputTriangles * 6);
expect(result.materials).toBe(result.outputTriangles);
});
test("previews Decimate on an isolated WASM handle without changing source revision", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate((input) => new Promise<{ openedRevision: number; previewRevision: number; previewTriangles: number; sourceRevision: number; sourceTriangles: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openRequestId = "e2e-preview-open";
const previewRequestId = "e2e-preview-run";
const sourceRequestId = "e2e-preview-source";
let previewTriangles = 0;
worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; meshes: Array<{ id: string; triangleCount?: number }> }; simplify?: { outputTriangleCount: number } }; error?: { message: string } }>) => {
if (event.data.kind !== "result") return;
if (event.data.requestId === openRequestId && event.data.ok && event.data.result?.snapshot) {
const snapshot = event.data.result.snapshot;
const mesh = snapshot.meshes[0];
worker.postMessage({ requestId: previewRequestId, command: { type: "applyCommand", payload: {
type: "previewDecimateMesh",
meshId: mesh.id,
profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, triangulate: false, useSymmetry: false },
} } });
return;
}
if (event.data.requestId === previewRequestId && event.data.ok && event.data.result?.snapshot && event.data.result.simplify) {
previewTriangles = event.data.result.snapshot.meshes[0].triangleCount ?? 0;
worker.postMessage({ requestId: sourceRequestId, command: { type: "snapshot" } });
return;
}
if (event.data.requestId !== sourceRequestId) return;
worker.terminate();
if (!event.data.ok || !event.data.result?.snapshot) {
reject(new Error(event.data.error?.message ?? "preview source snapshot failed"));
return;
}
resolve({
openedRevision: 1,
previewRevision: event.data.result.snapshot.revision,
previewTriangles,
sourceRevision: event.data.result.snapshot.revision,
sourceTriangles: event.data.result.snapshot.meshes[0].triangleCount ?? 0,
});
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.openedRevision).toBe(1);
expect(result.previewRevision).toBe(result.openedRevision);
expect(result.previewTriangles).toBeLessThan(result.sourceTriangles);
expect(result.sourceRevision).toBe(result.openedRevision);
expect(result.sourceTriangles).toBe(12);
});
test("generates independent multi-level LOD geometry from the original mesh", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate((input) => new Promise<{ sourceRevision: number; sourceTriangles: number; levels: Array<{ level: number; meshId: string; budget: number; triangles: number; vertices: number; bytes: number }>; manifestBudgets: number[] }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openRequestId = "e2e-lod-open";
const generateRequestId = "e2e-lod-generate";
const sourceRequestId = "e2e-lod-source";
let generated: { levels: Array<{ level: number; meshId: string; triangleBudget: number; outputTriangleCount: number; outputVertexCount: number; geometryBuffers: Array<{ positions: ArrayBuffer; indices: ArrayBuffer }> }>; manifest: { sourceMeshRevision: number; levels: Array<{ triangleBudget: number }> } } | undefined;
worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; meshes: Array<{ id: string; triangleCount?: number }> }; lod?: typeof generated }; error?: { message: string } }>) => {
if (event.data.kind !== "result") return;
if (event.data.requestId === openRequestId && event.data.ok && event.data.result?.snapshot) {
const snapshot = event.data.result.snapshot;
const mesh = snapshot.meshes[0];
worker.postMessage({ requestId: generateRequestId, command: { type: "generateLOD", payload: {
meshId: mesh.id,
sourceMeshRevision: snapshot.revision,
levels: [
{ triangleBudget: 10, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.9, triangulate: false, useSymmetry: false } },
{ triangleBudget: 6, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, triangulate: false, useSymmetry: false } },
],
} } });
return;
}
if (event.data.requestId === generateRequestId && event.data.ok && event.data.result?.lod) {
generated = event.data.result.lod;
worker.postMessage({ requestId: sourceRequestId, command: { type: "snapshot" } });
return;
}
if (event.data.requestId !== sourceRequestId) return;
worker.terminate();
if (!event.data.ok || !event.data.result?.snapshot || !generated) {
reject(new Error(event.data.error?.message ?? "LOD generation failed"));
return;
}
resolve({
sourceRevision: event.data.result.snapshot.revision,
sourceTriangles: event.data.result.snapshot.meshes[0].triangleCount ?? 0,
manifestBudgets: generated.manifest.levels.map((level) => level.triangleBudget),
levels: generated.levels.map((level) => ({
level: level.level,
meshId: level.meshId,
budget: level.triangleBudget,
triangles: level.outputTriangleCount,
vertices: level.outputVertexCount,
bytes: level.geometryBuffers.reduce((total, geometry) => total + geometry.positions.byteLength + geometry.indices.byteLength, 0),
})),
});
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.sourceRevision).toBe(1);
expect(result.sourceTriangles).toBe(12);
expect(result.manifestBudgets).toEqual([10, 6]);
expect(result.levels).toHaveLength(2);
expect(result.levels[0].meshId).toBe("mesh:Cube.001:lod:0");
expect(result.levels[1].meshId).toBe("mesh:Cube.001:lod:1");
expect(result.levels.every((level) => level.triangles > 0 && level.vertices > 0 && level.bytes > 0)).toBe(true);
});
test("isolates unstable native Collapse targets and keeps successful LOD levels", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate((input) => new Promise<{ levels: number; errors: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; meshes: Array<{ id: string; triangleCount?: number }> }; lod?: { levels: unknown[]; errors?: unknown[] } }; error?: { message: string } }>) => {
if (event.data.kind !== "result") return;
if (!event.data.ok) { worker.terminate(); reject(new Error(event.data.error?.message ?? "LOD isolation failed")); return; }
if (event.data.requestId === "lod-unstable-open" && event.data.result?.snapshot) {
const mesh = event.data.result.snapshot.meshes[0];
worker.postMessage({ requestId: "lod-unstable-generate", command: { type: "generateLOD", payload: { meshId: mesh.id, sourceMeshRevision: event.data.result.snapshot.revision, levels: [
{ triangleBudget: 9, profile: { schemaVersion: 1, sourceMeshRevision: event.data.result.snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.75, triangulate: false, useSymmetry: false } },
{ triangleBudget: 6, profile: { schemaVersion: 1, sourceMeshRevision: event.data.result.snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, triangulate: false, useSymmetry: false } },
] } } });
}
else if (event.data.requestId === "lod-unstable-generate" && event.data.result?.lod) {
worker.terminate();
resolve({ levels: event.data.result.lod.levels.length, errors: event.data.result.lod.errors?.length ?? 0 });
}
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: "lod-unstable-open", command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.levels).toBeGreaterThan(0);
expect(result.errors).toBeGreaterThanOrEqual(0);
});
test("validates Blender Decimate and LOD profiles at the protocol boundary", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
return await new Promise<{ ok: boolean; profile?: { mode: string; ratio?: number; attributePolicy: string; skinMaxInfluences?: number }; levels?: number; errorCode?: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/simplify-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; profile?: { mode: string; ratio?: number; attributePolicy: string; skinMaxInfluences?: number }; levels?: number; errorCode?: string; error?: string }>) => {
worker.terminate();
if (!event.data.ok) reject(new Error(event.data.error ?? "simplify protocol failed"));
else resolve(event.data);
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({
profile: {
schemaVersion: 1,
mode: "COLLAPSE",
sourceMeshRevision: 4,
ratio: 0.25,
attributePolicy: "PRESERVE",
skinPolicy: { maxInfluences: 4, minWeight: 0.001, maxPositionError: 0.01, shapeKeys: "REJECT" },
triangleBudget: 1200,
vertexGroup: "KeepHead",
vertexGroupFactor: 0.8,
vertexGroupInvert: false,
triangulate: true,
useSymmetry: true,
symmetryAxis: 0,
symmetryTolerance: 0.0001,
},
manifest: {
schemaVersion: 1,
meshId: "mesh:Character",
sourceMeshRevision: 4,
levels: [
{ level: 0, sourceMeshRevision: 4, triangleBudget: 4800, meshId: "mesh:Character:l0" },
{ level: 1, sourceMeshRevision: 4, triangleBudget: 1200, meshId: "mesh:Character:l1", screenSpaceError: 2 },
],
},
invalidProfile: { schemaVersion: 1, mode: "COLLAPSE", sourceMeshRevision: 4, ratio: 0.5, vertexGroupFactor: 0.2 },
});
});
});
expect(result).toEqual({ ok: true, profile: { mode: "COLLAPSE", ratio: 0.25, attributePolicy: "PRESERVE", skinMaxInfluences: 4 }, levels: 2, errorCode: "MISSING_VERTEX_GROUP" });
});
test("normalizes skin influences and rejects shape-key loss explicitly", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ rejected: string; preservedAllowed: boolean; normalized?: number[] }>((resolve, reject) => {
const worker = new Worker("/src/workers/skin-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; result?: { reason?: string; normalized?: { weights: number[] } }; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.result) reject(new Error(event.data.error ?? "skin gate failed"));
else resolve({ rejected: event.data.result.reason ?? "", preservedAllowed: event.data.result.allowed, normalized: event.data.result.normalized?.weights.slice(0, 4) });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const skin = { boneNames: ["Root", "Arm"], indices: [0, 1, 0, 1, 0, 1, 0, 1], weights: [0.7, 0.3, 0, 0, 0.2, 0.8, 0, 0], bindMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] };
const shapeKeys = [{ name: "Smile", positions: [0, 0, 0, 1, 0, 0] }];
const policy = { maxInfluences: 2, minWeight: 0.001, maxPositionError: 0.01, shapeKeys: "REJECT" as const };
worker.postMessage({ vertexCount: 2, skin, shapeKeys, policy: { ...policy, shapeKeys: "PRESERVE" } });
}));
const rejected = await page.evaluate(() => new Promise<string>((resolve, reject) => {
const worker = new Worker("/src/workers/skin-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; result?: { reason?: string }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.result) reject(new Error(event.data.error ?? "skin gate failed")); else resolve(event.data.result.reason ?? ""); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ vertexCount: 2, skin: { boneNames: ["Root", "Arm"], indices: [0, 1, 0, 1, 0, 1, 0, 1], weights: [0.7, 0.3, 0, 0, 0.2, 0.8, 0, 0], bindMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }, shapeKeys: [{ name: "Smile", positions: [0, 0, 0, 1, 0, 0] }], policy: { maxInfluences: 2, minWeight: 0.001, maxPositionError: 0.01, shapeKeys: "REJECT" } });
}));
result.rejected = rejected;
expect(result).toEqual({ rejected: "SHAPE_KEYS_REJECTED", preservedAllowed: true, normalized: [0.7, 0.3, 0, 0] });
});
test("remaps skin weights and shape keys through the native Collapse worker path", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(riggedBlend));
const result = await page.evaluate((input) => new Promise<{ vertices: number; skinEntries: number; shapeEntries: number; normalized: boolean }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openId = "skin-remap-open";
const disableDecimateId = "skin-remap-disable-decimate";
const disableArmatureId = "skin-remap-disable-armature";
const collapseId = "skin-remap-collapse";
let meshId = "";
let decimateUuid = "";
let armatureUuid = "";
const fail = (message: string) => { worker.terminate(); reject(new Error(message)); };
worker.onmessage = (event: MessageEvent<{
kind: string;
requestId: string;
ok?: boolean;
result?: { snapshot?: { revision: number; meshes: Array<{ id: string; vertexCount: number; shapeKeys?: Array<{ positions: number[] }>; skinWeights?: { indices: number[]; weights: number[] }; modifierStack?: Array<{ uuid: string; type: string; name: string }> }> } };
error?: { message: string };
}>) => {
if (event.data.kind !== "result") return;
if (!event.data.ok || !event.data.result?.snapshot) return fail(event.data.error?.message ?? "skin remap worker request failed");
const snapshot = event.data.result.snapshot;
const mesh = snapshot.meshes.find((candidate) => candidate.id === meshId) ?? snapshot.meshes[0];
if (!mesh) return fail("rigged mesh is missing");
if (event.data.requestId === openId) {
meshId = mesh.id;
decimateUuid = mesh.modifierStack?.find((modifier) => modifier.name === "Preview Decimate")?.uuid ?? "";
armatureUuid = mesh.modifierStack?.find((modifier) => modifier.type === "ARMATURE")?.uuid ?? "";
if (!decimateUuid || !armatureUuid) return fail("rigged modifier stack is missing");
worker.postMessage({ requestId: disableDecimateId, command: { type: "applyCommand", payload: { type: "setModifierEnabled", meshId, modifierUuid: decimateUuid, enabled: false } } });
return;
}
if (event.data.requestId === disableDecimateId) {
worker.postMessage({ requestId: disableArmatureId, command: { type: "applyCommand", payload: { type: "setModifierEnabled", meshId, modifierUuid: armatureUuid, enabled: false } } });
return;
}
if (event.data.requestId === disableArmatureId) {
worker.postMessage({ requestId: collapseId, command: { type: "applyCommand", payload: { type: "decimateMesh", meshId, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, vertexGroup: null, vertexGroupFactor: 1, vertexGroupInvert: false, triangulate: false, useSymmetry: false, symmetryAxis: 0, symmetryTolerance: 0.0001, skinPolicy: { maxInfluences: 4, minWeight: 0.001, maxPositionError: 0.001, shapeKeys: "PRESERVE" } } } } });
return;
}
if (event.data.requestId !== collapseId || !mesh.skinWeights || !mesh.shapeKeys?.[0]) return fail("skin remap result is missing");
const normalized = Array.from({ length: mesh.vertexCount }, (_, vertex) => {
const total = mesh.skinWeights?.weights.slice(vertex * 4, vertex * 4 + 4).reduce((sum, weight) => sum + weight, 0) ?? 0;
return Math.abs(total - 1) < 1e-5;
}).every(Boolean);
worker.terminate();
resolve({ vertices: mesh.vertexCount, skinEntries: mesh.skinWeights.indices.length, shapeEntries: mesh.shapeKeys[0].positions.length, normalized });
};
worker.onerror = (event) => fail(event.message);
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: openId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result).toEqual({ vertices: 3, skinEntries: 12, shapeEntries: 9, normalized: true });
});
test("reports GLB export blockers before any binary export", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ canExport: boolean; codes: string[] }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean; warnings: Array<{ code: string }> }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB warning report missing")); else resolve({ canExport: event.data.report.canExport, codes: event.data.report.warnings.map((warning) => warning.code) }); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ snapshot: { meshes: [{ id: "mesh:Summary", name: "Summary", geometryStatus: "summary-only", vertexCount: 0, edgeCount: 0, faceCount: 0, cornerCount: 0 }], images: [{ id: "image:External", name: "External", assetId: "asset:External", sourcePath: "textures/a.png" }], materials: [], nodes: [], collections: [], scenes: [] } });
}));
expect(result.canExport).toBe(false);
expect(result.codes).toEqual(expect.arrayContaining(["SUMMARY_ONLY_MESH", "EXTERNAL_IMAGE"]));
});
test("blocks Shader graphs that cannot be mapped to glTF PBR", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ canExport: boolean; codes: string[] }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean; warnings: Array<{ code: string }> }; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB Shader graph report missing"));
else resolve({ canExport: event.data.report.canExport, codes: event.data.report.warnings.map((warning) => warning.code) });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ snapshot: {
meshes: [{ id: "mesh:Graph", name: "Graph", geometryStatus: "available", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, topology: "triangles", positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] }],
materials: [{ id: "material:Graph", name: "Graph", baseColor: [1, 1, 1, 1], roughness: 0.5, metallic: 0, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, nodes: [
{ id: "rgb", type: "RGB", name: "Color" },
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
{ id: "output", type: "OUTPUT", name: "Output" },
], links: [
{ fromNodeId: "rgb", fromSocket: "Color", toNodeId: "principled", toSocket: "Base Color" },
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
] }],
images: [], animations: [], nodes: [], collections: [], scenes: [], frame: { current: 1, start: 1, end: 1 },
} });
}));
expect(result.canExport).toBe(false);
expect(result.codes).toContain("SHADER_GRAPH_UNMAPPABLE");
});
test("maps bounded RGB and Value Shader constants to glTF PBR factors", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ canExport: boolean; pbr?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number } }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean }; pbrSummary?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number }; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB Shader factor report missing"));
else resolve({ canExport: event.data.report.canExport, pbr: event.data.pbrSummary });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ snapshot: {
meshes: [{ id: "mesh:Constants", name: "Constants", geometryStatus: "available", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, topology: "triangles", positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] }],
materials: [{ id: "material:Constants", name: "Constants", baseColor: [1, 1, 1, 1], roughness: 0.5, metallic: 0.1, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, nodes: [
{ id: "rgb", type: "RGB", name: "Color", defaultValue: [0.1, 0.3, 0.7, 1] },
{ id: "roughness", type: "VALUE", name: "Roughness", defaultValue: [0.42] },
{ id: "metallic", type: "VALUE", name: "Metallic", defaultValue: [0.75] },
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
{ id: "output", type: "OUTPUT", name: "Output" },
], links: [
{ fromNodeId: "rgb", fromSocket: "Color", toNodeId: "principled", toSocket: "Base Color" },
{ fromNodeId: "roughness", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
{ fromNodeId: "metallic", fromSocket: "Value", toNodeId: "principled", toSocket: "Metallic" },
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
] }],
images: [], animations: [], nodes: [], collections: [], scenes: [], frame: { current: 1, start: 1, end: 1 },
} });
}));
expect(result.canExport).toBe(true);
expect(result.pbr?.baseColorFactor).toEqual([0.1, 0.3, 0.7, 1]);
expect(result.pbr?.roughnessFactor).toBeCloseTo(0.42);
expect(result.pbr?.metallicFactor).toBeCloseTo(0.75);
});
test("exports a local SceneIR mesh as a standards-shaped GLB", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ canExport: boolean; byteLength: number; magic?: number; version?: number; firstMorphDelta?: number[] }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer;
const indices = new Uint32Array([0, 1, 2]).buffer;
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean }; byteLength?: number; magic?: number; version?: number; firstMorphDelta?: number[]; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB export failed"));
else resolve({ canExport: event.data.report.canExport, byteLength: event.data.byteLength ?? 0, magic: event.data.magic, version: event.data.version, firstMorphDelta: event.data.firstMorphDelta });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({
snapshot: {
schemaVersion: 1,
revision: 1,
sceneId: "scene:test",
source: { kind: "mock" },
coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 },
nodes: [{ id: "object:Triangle", name: "Triangle", type: "MESH", parentId: null, dataId: "mesh:Triangle", visible: true, selectable: true, localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } }],
meshes: [{ id: "mesh:Triangle", name: "Triangle", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:Triangle", topology: "triangles", shapeKeys: [{ name: "Lift", positions: [0, 0, 0.5, 1, 0, 0, 0, 1, 0] }] }],
materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [], activeObjectId: "object:Triangle", frame: { current: 1, start: 1, end: 1 },
},
geometryBuffers: [{ schemaVersion: 1, meshId: "mesh:Triangle", byteLength: positions.byteLength + indices.byteLength, positions, indices }],
}, [positions, indices]);
}));
expect(result.canExport).toBe(true);
expect(result.byteLength).toBeGreaterThan(20);
expect(result.magic).toBe(0x46546c67);
expect(result.version).toBe(2);
expect(result.firstMorphDelta?.map((value) => Number(value.toFixed(4)))).toEqual([0, 0.5, 0]);
});
test("keeps per-vertex UV and color attributes in GLB output", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ ok: boolean; error?: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer;
const indices = new Uint32Array([0, 1, 2]).buffer;
const uvs = new Float32Array([0, 0, 1, 0, 0, 1]).buffer;
const colors = new Float32Array([1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1]).buffer;
worker.onmessage = (event: MessageEvent<{ ok: boolean; attributes?: string[]; error?: string }>) => {
worker.terminate();
if (!event.data.ok) reject(new Error(event.data.error ?? "GLB vertex attributes failed"));
else resolve({ ok: event.data.attributes?.includes("TEXCOORD_0") === true && event.data.attributes?.includes("COLOR_0") === true, error: event.data.error });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({
snapshot: {
schemaVersion: 1, revision: 1, sceneId: "scene:attributes", source: { kind: "mock" },
nodes: [], meshes: [{ id: "mesh:Triangle", name: "Triangle", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:Triangle", topology: "triangles" }],
materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [], activeObjectId: null,
frame: { current: 1, start: 1, end: 1 },
},
geometryBuffers: [{ schemaVersion: 1, meshId: "mesh:Triangle", byteLength: positions.byteLength + indices.byteLength + uvs.byteLength + colors.byteLength, positions, indices, uvs, colors }],
}, [positions, indices, uvs, colors]);
}));
expect(result).toEqual({ ok: true });
});
test("evaluates modifier dependency order and blocks unevaluated or cyclic stacks", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ status: string; order: string[]; cycleStatus: string; cycleBlockedBy: string[] }>((resolve, reject) => {
const worker = new Worker("/src/workers/modifier-graph-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { status: string; order: string[] }; cycle?: { status: string; blockedBy: string[] }; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report || !event.data.cycle) reject(new Error(event.data.error ?? "modifier graph report missing"));
else resolve({ status: event.data.report.status, order: event.data.report.order, cycleStatus: event.data.cycle.status, cycleBlockedBy: event.data.cycle.blockedBy });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result).toEqual({ status: "METADATA_ONLY", order: ["mod:source", "mod:decimate"], cycleStatus: "BLOCKED", cycleBlockedBy: ["MODIFIER_DEPENDENCY_CYCLE"] });
});
test("embeds local textures and exports glTF skin and animation records", async ({ page }) => {
await page.goto("/");
const textureBytes = await import("node:fs").then((fs) => fs.readFileSync(localTexturePng));
const result = await page.evaluate((input) => new Promise<{ canExport: boolean; imageCount: number; skinCount: number; animationCount: number; imageByteLength: number; imageMimeType?: string; imageSignature: number[]; skinJointCount: number; animationChannelCount: number; inverseBindType?: string; attributes: string[]; roundTripCompatible: boolean; roundTripMismatches: string[] }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer;
const indices = new Uint32Array([0, 1, 2]).buffer;
const texture = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean }; attributes?: string[]; gltfSummary?: { imageCount: number; skinCount: number; animationCount: number; imageByteLength: number; imageMimeType?: string; imageSignature: number[]; skinJointCount: number; animationChannelCount: number; inverseBindType?: string }; roundTrip?: { compatible: boolean; mismatches: string[] }; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report || !event.data.gltfSummary || !event.data.roundTrip) reject(new Error(event.data.error ?? "GLB skin export failed"));
else resolve({ canExport: event.data.report.canExport, ...event.data.gltfSummary, attributes: event.data.attributes ?? [], roundTripCompatible: event.data.roundTrip.compatible, roundTripMismatches: event.data.roundTrip.mismatches });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({
snapshot: {
schemaVersion: 1, revision: 3, sceneId: "scene:skin", source: { kind: "mock" },
nodes: [
{ id: "object:Armature", name: "Armature", type: "ARMATURE", parentId: null, dataId: "armature:Rig", visible: true, selectable: true, localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } },
{ id: "object:Triangle", name: "Triangle", type: "MESH", parentId: null, dataId: "mesh:Triangle", visible: true, selectable: true, localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } },
],
meshes: [{ id: "mesh:Triangle", name: "Triangle", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:Triangle", topology: "triangles", materialSlotIds: ["material:Skin"], skinWeights: { boneNames: ["Root", "Tip"], indices: [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0], weights: [0.5, 0.5, 0, 0, 0.5, 0.5, 0, 0, 0.5, 0.5, 0, 0], bindMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], armatureId: "armature:Rig", jointIds: ["bone:Root", "bone:Tip"] } }],
armatures: [{ id: "armature:Rig", name: "Rig", objectId: "object:Armature", bones: [
{ id: "bone:Root", name: "Root", parentId: null, head: [0, 0, 0], tail: [0, 0, 1], restMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], poseMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] },
{ id: "bone:Tip", name: "Tip", parentId: "bone:Root", head: [0, 0, 1], tail: [0, 0, 2], restMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1], poseMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1] },
] }],
materials: [{ id: "material:Skin", name: "Skin", baseColor: [1, 1, 1, 1], roughness: 0.5, metallic: 0, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, imageIds: ["image:Texture"] }],
cameras: [], lights: [], worlds: [], images: [{ id: "image:Texture", name: "Texture", assetId: "asset:Texture", mimeType: "image/png", sourcePath: "texture.png" }], collections: [], scenes: [], activeObjectId: "object:Triangle",
animations: [{ id: "action:Move", name: "Move", targetId: "object:Triangle", frameStart: 1, frameEnd: 2, channels: [{ path: "location[0]", keyframes: [{ frame: 1, value: [0] }, { frame: 2, value: [1] }] }, { path: "location[1]", keyframes: [{ frame: 1, value: [0] }, { frame: 2, value: [0] }] }, { path: "location[2]", keyframes: [{ frame: 1, value: [0] }, { frame: 2, value: [0] }] }] }],
frame: { current: 1, start: 1, end: 2 },
},
geometryBuffers: [{ schemaVersion: 1, meshId: "mesh:Triangle", byteLength: positions.byteLength + indices.byteLength, positions, indices }],
assetBuffers: [{ assetId: "asset:Texture", mimeType: "image/png", data: texture }],
}, [positions, indices, texture]);
}), new Uint8Array(textureBytes));
expect(result.canExport).toBe(true);
expect(result.imageCount).toBe(1);
expect(result.skinCount).toBe(1);
expect(result.animationCount).toBe(1);
expect(result.imageByteLength).toBe(textureBytes.byteLength);
expect(result.imageMimeType).toBe("image/png");
expect(result.imageSignature).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
expect(result.skinJointCount).toBe(2);
expect(result.animationChannelCount).toBeGreaterThan(0);
expect(result.inverseBindType).toBe("MAT4");
expect(result.attributes).toEqual(expect.arrayContaining(["JOINTS_0", "WEIGHTS_0"]));
expect(result.roundTripCompatible, result.roundTripMismatches.join("; ")).toBe(true);
expect(result.roundTripMismatches).toEqual([]);
});
test("reports lightweight budget violations without altering usage", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ withinBudget: boolean; violations: Array<[string, number]>; usage: Record<string, number> }>((resolve, reject) => {
const worker = new Worker("/src/workers/budget-test.worker.ts", { type: "module" });
const usage = { triangles: 1200, vertices: 700, indexBytes: 14400, materialSlots: 3, textureBytes: 4096, gpuBytes: 32768 };
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { withinBudget: boolean; violations: Array<{ metric: string; excess: number }>; usage: Record<string, number> }; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "budget report missing"));
else resolve({ withinBudget: event.data.report.withinBudget, violations: event.data.report.violations.map((item) => [item.metric, item.excess]), usage: event.data.report.usage });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({ budget: { schemaVersion: 1, triangles: 1000, vertices: 800, gpuBytes: 20000 }, usage });
}));
expect(result.withinBudget).toBe(false);
expect(result.violations).toEqual([["triangles", 200], ["gpuBytes", 12768]]);
expect(result.usage).toEqual({ triangles: 1200, vertices: 700, indexBytes: 14400, materialSlots: 3, textureBytes: 4096, gpuBytes: 32768 });
});
test("aggregates project, collection, object and LOD budgets without double counting LOD", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ project: number; collection: number; object: number; lod: number; projectActual: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/budget-aggregation-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { project?: { violations: Array<{ excess: number }>; usage: { triangles: number } }; collections: Record<string, { violations: Array<{ excess: number }> }>; objects: Record<string, { violations: Array<{ excess: number }> }>; lod: Record<string, { violations: Array<{ excess: number }> }>; }; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report?.project) reject(new Error(event.data.error ?? "budget aggregation report missing"));
else resolve({
project: event.data.report.project.violations[0]?.excess ?? 0,
collection: event.data.report.collections.B?.violations[0]?.excess ?? 0,
object: event.data.report.objects.A?.violations[0]?.excess ?? 0,
lod: event.data.report.lod.L0?.violations[0]?.excess ?? 0,
projectActual: event.data.report.project.usage.triangles,
});
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const usage = (triangles: number) => ({ triangles, vertices: triangles, indexBytes: triangles * 12, materialSlots: 1, textureBytes: 0, gpuBytes: triangles * 16 });
worker.postMessage({
objects: [{ objectId: "A", collectionIds: ["A", "B"], usage: usage(100) }, { objectId: "B", collectionIds: ["B"], usage: usage(50) }],
lod: [{ lodId: "L0", objectId: "A", usage: usage(25) }],
scopes: { project: { schemaVersion: 1, triangles: 120 }, collections: { B: { schemaVersion: 1, triangles: 120 } }, objects: { A: { schemaVersion: 1, vertices: 90 } }, lod: { L0: { schemaVersion: 1, triangles: 20 } } },
});
}));
expect(result).toEqual({ project: 30, collection: 30, object: 10, lod: 5, projectActual: 150 });
});
test("round-trips LOD geometry through the local binary mesh cache container", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ byteLength: number; meshId: string; positionBytes: number; uvBytes: number }>((resolve, reject) => {
const worker = new Worker("/src/workers/mesh-cache-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<{ ok: boolean; byteLength?: number; levels?: Array<{ geometryBuffers: Array<{ meshId: string; positions: ArrayBuffer; uvs?: ArrayBuffer }> }>; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.levels?.[0]?.geometryBuffers[0]) reject(new Error(event.data.error ?? "mesh cache round trip failed"));
else {
const geometry = event.data.levels[0].geometryBuffers[0];
resolve({ byteLength: event.data.byteLength ?? 0, meshId: geometry.meshId, positionBytes: geometry.positions.byteLength, uvBytes: geometry.uvs?.byteLength ?? 0 });
}
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer;
const indices = new Uint32Array([0, 1, 2]).buffer;
const uvs = new Float32Array([0, 0, 1, 0, 0, 1]).buffer;
worker.postMessage({ levels: [{ level: 0, meshId: "mesh-cache-l0", triangleBudget: 1, outputTriangleCount: 1, outputVertexCount: 3, geometryBuffers: [{ schemaVersion: 1, meshId: "mesh-cache-l0", byteLength: positions.byteLength + indices.byteLength + uvs.byteLength, positions, indices, uvs }] }] }, [positions, indices, uvs]);
}));
expect(result.byteLength).toBeGreaterThan(result.positionBytes + result.uvBytes);
expect(result.meshId).toBe("mesh-cache-l0");
expect(result.positionBytes).toBe(36);
expect(result.uvBytes).toBe(24);
});
test("blocks ImageIR paths outside the project asset sandbox", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(attributeBlend));
const result = await page.evaluate((input) => new Promise<{ status: string; assetId: string; sourcePath?: string; safeSourcePath?: string; pathStatus?: string; errorCode?: string; hasBase64: boolean }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openRequestId = "e2e-asset-open";
const assetRequestId = "e2e-asset-request";
worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { asset?: { status: string; assetId: string; sourcePath?: string; [key: string]: unknown } }; error?: { message: string } }>) => {
if (event.data.kind !== "result") return;
if (event.data.requestId === openRequestId && event.data.ok) {
worker.postMessage({ requestId: assetRequestId, command: { type: "requestAsset", assetId: "image:AttributeTexture" } });
return;
}
if (event.data.requestId !== assetRequestId) return;
worker.terminate();
if (!event.data.ok || !event.data.result?.asset) {
reject(new Error(event.data.error?.message ?? "asset request failed"));
return;
}
const asset = event.data.result.asset;
resolve({
status: asset.status,
assetId: asset.assetId,
sourcePath: asset.sourcePath,
safeSourcePath: typeof asset.safeSourcePath === "string" ? asset.safeSourcePath : undefined,
pathStatus: typeof asset.pathStatus === "string" ? asset.pathStatus : undefined,
errorCode: typeof asset.errorCode === "string" ? asset.errorCode : undefined,
hasBase64: Object.keys(asset).some((key) => key.toLowerCase().includes("base64")),
});
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.status).toBe("blocked");
expect(result.assetId).toBe("image:AttributeTexture");
expect(result.sourcePath).toMatch(/textures\/attribute\.png$/);
expect(result.safeSourcePath).toBeUndefined();
expect(result.pathStatus).toBe("blocked");
expect(result.errorCode).toBe("ASSET_PATH_OUTSIDE_PROJECT");
expect(result.hasBase64).toBe(false);
});
test("extracts Blender packed image bytes through the local asset request API", async ({ page }) => {
await page.goto("/");
const packedBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/packed_image_scene.blend");
const expectedTexture = path.resolve(import.meta.dirname, "../../../tests/golden/W-010/desktop-1440x900.png");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(packedBlend));
const expected = await import("node:fs").then((fs) => fs.readFileSync(expectedTexture));
const result = await page.evaluate((input) => new Promise<{ status: string; byteLength: number; packedByteLength?: number; signature: number[]; mimeType?: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openRequestId = "e2e-packed-open";
const assetRequestId = "e2e-packed-request";
let packedByteLength: number | undefined;
worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { images: Array<{ assetId: string; packedByteLength?: number }> }; asset?: { status: string; data?: ArrayBuffer; mimeType?: string } }; error?: { message: string } }>) => {
if (event.data.kind !== "result") return;
if (event.data.requestId === openRequestId && event.data.ok) {
const image = event.data.result?.snapshot?.images.find((candidate) => candidate.assetId === "image:PackedTexture");
if (!image) {
worker.terminate();
reject(new Error("packed SceneIR image missing"));
return;
}
packedByteLength = image.packedByteLength;
worker.postMessage({ requestId: assetRequestId, command: { type: "requestAsset", assetId: "image:PackedTexture" } });
return;
}
if (event.data.requestId !== assetRequestId) return;
worker.terminate();
const asset = event.data.result?.asset;
if (!event.data.ok || !asset?.data) {
reject(new Error(event.data.error?.message ?? "packed asset request failed"));
return;
}
const data = new Uint8Array(asset.data);
resolve({ status: asset.status, byteLength: data.byteLength, packedByteLength, signature: Array.from(data.slice(0, 8)), mimeType: asset.mimeType });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.status).toBe("packed");
expect(result.byteLength).toBe(expected.byteLength);
expect(result.packedByteLength).toBe(expected.byteLength);
expect(result.mimeType).toBe("image/png");
expect(result.signature).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
});
test("matches Blender Depsgraph deformation golden within the declared error budget", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(riggedBlend));
const golden = JSON.parse(await import("node:fs").then((fs) => fs.readFileSync(deformationGolden, "utf8"))) as { positions: number[]; vertexCount: number; tolerance: { maxPositionError: number; rmsPositionError: number } };
const result = await page.evaluate((input) => new Promise<{ vertexCount: number; maxError: number; rmsError: number; positions: number[] }>((resolve, reject) => {
const engine = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openRequestId = "e2e-deformation-open";
engine.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: unknown; geometryBuffers?: Array<{ meshId: string; positions: ArrayBuffer }> }; error?: { message: string } }>) => {
if (event.data.kind !== "result" || event.data.requestId !== openRequestId) return;
if (!event.data.ok || !event.data.result?.snapshot) {
engine.terminate();
reject(new Error(event.data.error?.message ?? "deformation fixture open failed"));
return;
}
const deformation = new Worker("/src/workers/deformation-test.worker.ts", { type: "module" });
deformation.onmessage = (deformationEvent: MessageEvent<{ ok: boolean; positions?: number[]; error?: string }>) => {
engine.terminate();
deformation.terminate();
if (!deformationEvent.data.ok || !deformationEvent.data.positions) reject(new Error(deformationEvent.data.error ?? "deformation evaluation failed"));
else {
const positions = deformationEvent.data.positions;
resolve({ vertexCount: positions.length / 3, maxError: 0, rmsError: 0, positions });
}
};
deformation.onerror = (error) => { engine.terminate(); deformation.terminate(); reject(new Error(error.message)); };
deformation.postMessage({ snapshot: event.data.result.snapshot, meshId: "mesh:RiggedShapeMesh", geometryBuffers: event.data.result.geometryBuffers });
};
engine.onerror = (error) => { engine.terminate(); reject(new Error(error.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
engine.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
const errors = result.positions.map((value, index) => value - golden.positions[index]);
const maxError = Math.max(...errors.map((value) => Math.abs(value)));
const rmsError = Math.sqrt(errors.reduce((sum, value) => sum + value * value, 0) / errors.length);
expect(result.vertexCount).toBe(golden.vertexCount);
expect(maxError).toBeLessThanOrEqual(golden.tolerance.maxPositionError);
expect(rmsError).toBeLessThanOrEqual(golden.tolerance.rmsPositionError);
});
test("evaluates the full Blender Depsgraph or reports its safe capability gate", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(riggedBlend));
const golden = JSON.parse(await import("node:fs").then((fs) => fs.readFileSync(deformationGolden, "utf8"))) as { positions: number[]; tolerance: { maxPositionError: number; rmsPositionError: number } };
const result = await page.evaluate((input) => new Promise<{ report?: DepsgraphE2EReport; blocked?: { code?: string; message?: string } }>((resolve, reject) => {
const engine = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const openRequestId = "e2e-depsgraph-open";
const evalRequestId = "e2e-depsgraph-evaluate";
engine.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { depsgraph?: DepsgraphE2EReport }; error?: { code?: string; message?: string } }>) => {
if (event.data.kind !== "result") return;
if (event.data.requestId === openRequestId && event.data.ok) {
engine.postMessage({ requestId: evalRequestId, command: { type: "evaluateDepsgraph" } });
return;
}
if (event.data.requestId !== evalRequestId) return;
engine.terminate();
if (!event.data.ok || !event.data.result?.depsgraph) {
if (event.data.error?.code === "CAPABILITY_MISSING" && event.data.error.message?.includes("headless frame evaluation is memory-safe")) {
resolve({ blocked: event.data.error });
return;
}
reject(new Error(event.data.error?.message ?? "Depsgraph evaluation failed"));
return;
}
resolve({ report: event.data.result.depsgraph });
};
engine.onerror = (error) => { engine.terminate(); reject(new Error(error.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
engine.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
if (result.blocked) {
expect(result.blocked.code).toBe("CAPABILITY_MISSING");
expect(result.blocked.message).toContain("headless frame evaluation is memory-safe");
return;
}
expect(result.report).toBeDefined();
if (!result.report) throw new Error("Depsgraph report missing after successful evaluation");
expect(result.report.status).toBe("EVALUATED");
expect(result.report.objectCount).toBeGreaterThanOrEqual(result.report.meshObjectCount);
expect(result.report.meshObjectCount).toBeGreaterThan(0);
const mesh = result.report.meshes.find((candidate) => candidate.sourceMeshId === "mesh:RiggedShapeMesh");
expect(mesh).toBeDefined();
expect(mesh?.vertexCount).toBeGreaterThan(0);
expect(mesh?.triangleCount).toBeGreaterThan(0);
expect(mesh?.positions).toHaveLength((mesh?.vertexCount ?? 0) * 3);
expect(mesh?.indices).toHaveLength((mesh?.triangleCount ?? 0) * 3);
expect(mesh?.modifierCount).toBeGreaterThan(0);
expect(mesh?.modifiers.map((modifier) => modifier.type)).toEqual(["Armature", "Decimate"]);
expect(mesh?.modifiers.map((modifier) => modifier.status)).toEqual(["EVALUATED", "BLOCKED"]);
expect(mesh?.modifiers[1]?.showViewport).toBe(true);
expect(mesh?.modifiers[1]?.errorCode).toBe("BLENDER_MODIFIER_ERROR");
expect(mesh?.modifiers[0]?.targetObjectIds).toEqual(["object:RiggedArmatureObject"]);
expect(mesh?.modifiers[1]?.dependsOn).toEqual([mesh?.modifiers[0]?.uuid]);
const errors = (mesh?.positions ?? []).map((value, index) => value - golden.positions[index]);
const maxError = Math.max(...errors.map((value) => Math.abs(value)));
const rmsError = Math.sqrt(errors.reduce((sum, value) => sum + value * value, 0) / errors.length);
expect(maxError).toBeLessThanOrEqual(golden.tolerance.maxPositionError);
expect(rmsError).toBeLessThanOrEqual(golden.tolerance.rmsPositionError);
});
test("exports layered Action keyframes through SceneIR", async ({ page }) => {
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(animationBlend));
const result = await page.evaluate((input) => new Promise<{ frame: { start: number; end: number }; target: string; channels: Array<{ path: string; keyframes: number }> }>((resolve, reject) => {
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const requestId = "e2e-animation-open";
worker.onmessage = (event: MessageEvent<{
kind: string;
requestId: string;
ok?: boolean;
result?: {
snapshot?: {
frame: { start: number; end: number };
animations: Array<{ targetId: string; channels: Array<{ path: string; keyframes: unknown[] }> }>;
};
};
error?: { message: string };
}>) => {
if (event.data.requestId !== requestId || event.data.kind !== "result") return;
worker.terminate();
const snapshot = event.data.result?.snapshot;
const animation = snapshot?.animations.find((item) => item.targetId === "object:AnimatedObject");
if (!event.data.ok || !snapshot || !animation) {
reject(new Error(event.data.error?.message ?? "animation response missing"));
return;
}
resolve({ frame: snapshot.frame, target: animation.targetId, channels: animation.channels.map((channel) => ({ path: channel.path, keyframes: channel.keyframes.length })) });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
worker.postMessage({ requestId, command: { type: "openBlend", buffer } }, [buffer]);
}), new Uint8Array(bytes));
expect(result.frame).toEqual({ current: 1, start: 1, end: 10 });
expect(result.target).toBe("object:AnimatedObject");
expect(result.channels).toEqual([
{ path: "location[0]", keyframes: 2 },
{ path: "location[1]", keyframes: 2 },
{ path: "location[2]", keyframes: 2 },
]);
});
test("patches changed mesh buffer ranges without replacing stable topology", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { applyMeshGeometryDelta, diffMeshGeometryBuffers } = await import("/src/three-adapter/geometry-delta.ts");
const before = [{ schemaVersion: 1 as const, meshId: "mesh:range", byteLength: 36,
positions: new Float32Array([0, 0, 0, 1, 1, 1]).buffer,
indices: new Uint32Array([0, 1, 0]).buffer }];
const after = [{ ...before[0], positions: new Float32Array([0, 0, 0, 1, 1.25, 1]).buffer }];
const delta = diffMeshGeometryBuffers(before, after);
const applied = applyMeshGeometryDelta(before, delta);
return {
patches: delta.patches.length,
replaced: delta.replaced.length,
patchBytes: delta.patches.reduce((total, patch) => total + patch.data.byteLength, 0),
fullBytes: after[0].positions.byteLength,
value: new Float32Array(applied[0].positions)[4],
};
});
expect(result.replaced).toBe(0);
expect(result.patches).toBeGreaterThan(0);
expect(result.patchBytes).toBeLessThan(result.fullBytes);
expect(result.value).toBe(1.25);
});
test("renders through the capability-gated OffscreenCanvas worker", async ({ page }) => {
await page.goto("/?offscreen=1");
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-renderer-backend", "offscreen-worker");
await expect(canvas).toHaveAttribute("data-pbr-profile", "physical-v1");
await expect(canvas).toHaveAttribute("data-tone-mapping", "aces");
await expect(canvas).toHaveAttribute("data-shadow-map", "pcf-1024");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
expect(await canvas.getAttribute("data-renderer-error")).toBeNull();
});
test("coalesces linked mesh objects into a raycastable instance group", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "链接复制对象" }).click();
await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-instance-groups", "1");
});
test("returns structured gates for the undeclared capability protocols", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
type Response = { requestId: string; kind: string; ok?: boolean; error?: { code?: string; message?: string } };
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const pending = new Map<string, (response: Response) => void>();
worker.onmessage = (event: MessageEvent<Response>) => pending.get(event.data.requestId)?.(event.data);
const send = (payload: unknown, requestId: string): Promise<Response> => new Promise((resolve) => {
pending.set(requestId, resolve);
worker.postMessage({ requestId, command: { type: "applyCommand", payload } });
});
const sample = { position: [0, 0, 0], normal: [0, 0, 1], radius: 0.25, strength: 0.5, pressure: 1, time: 0 };
const sculpt = await send({ type: "sculptStroke", stroke: { schemaVersion: 1, meshId: "mesh:missing", brush: "DRAW", samples: Array.from({ length: 2049 }, () => sample), symmetry: [false, false, false], mirrorObjectSpace: true } }, "gate-sculpt");
const sculptMirror = await send({ type: "sculptStroke", stroke: { schemaVersion: 1, meshId: "mesh:missing", brush: "DRAW", samples: [sample], symmetry: [true, false, false], mirrorObjectSpace: false } }, "gate-sculpt-mirror");
const cycle = await send({ type: "setGeometryNodeGraph", meshId: "mesh:missing", graph: {
schemaVersion: 1, id: "graph:cycle", name: "Cycle", interfaceInputs: [], interfaceOutputs: [],
nodes: [
{ id: "a", type: "GeometryNodeTransform", name: "A", sockets: [{ id: "out", name: "Out", direction: "OUTPUT", dataType: "FLOAT" }, { id: "in", name: "In", direction: "INPUT", dataType: "FLOAT" }] },
{ id: "b", type: "GeometryNodeTransform", name: "B", sockets: [{ id: "out", name: "Out", direction: "OUTPUT", dataType: "FLOAT" }, { id: "in", name: "In", direction: "INPUT", dataType: "FLOAT" }] },
],
links: [{ fromNodeId: "a", fromSocketId: "out", toNodeId: "b", toSocketId: "in" }, { fromNodeId: "b", fromSocketId: "out", toNodeId: "a", toSocketId: "in" }],
} }, "gate-gn");
const external = await send({ type: "setGeometryNodeGraph", meshId: "mesh:missing", graph: {
schemaVersion: 1, id: "graph:external", name: "External", interfaceInputs: [], interfaceOutputs: [], links: [],
nodes: [{ id: "object-info", type: "GeometryNodeObjectInfo", name: "Object Info", properties: { resourceId: "object:missing" }, sockets: [] }],
} }, "gate-gn-external");
const shader = await send({ type: "setShaderGraph", materialId: "material:missing", graph: {
schemaVersion: 1, id: "shader:valid", materialId: "material:missing", outputNodeId: "out", nodes: [{ id: "out", type: "MATERIAL_OUTPUT", name: "Material Output", sockets: [] }], links: [],
} }, "gate-shader");
const arbitraryShader = await send({ type: "setShaderGraph", materialId: "material:missing", graph: {
schemaVersion: 1, id: "shader:math", materialId: "material:missing", outputNodeId: "out", nodes: [
{ id: "mix", type: "MIX", name: "Mix", sockets: [] },
{ id: "out", type: "MATERIAL_OUTPUT", name: "Material Output", sockets: [] },
], links: [],
} }, "gate-shader-arbitrary");
const unsupportedMath = await send({ type: "setShaderGraph", materialId: "material:missing", graph: {
schemaVersion: 1, id: "shader:power", materialId: "material:missing", outputNodeId: "out", nodes: [
{ id: "math", type: "MATH", name: "Power", properties: { operation: "POWER" }, sockets: [] },
{ id: "out", type: "MATERIAL_OUTPUT", name: "Material Output", sockets: [] },
], links: [],
} }, "gate-shader-math-operation");
const nla = await send({ type: "setNLAStack", objectId: "object:missing", tracks: [{
schemaVersion: 1, id: "track:1", ownerId: "object:missing", name: "Track", muted: false, solo: false, selected: true,
strips: [{ id: "strip:1", actionId: "action:missing", frameStart: 1, frameEnd: 10, actionFrameStart: 1, actionFrameEnd: 10, scale: 1, repeat: 1, blendIn: 0, blendOut: 0, influence: 1, blendMode: "REPLACE", extrapolation: "HOLD", muted: false, selected: true }],
}] }, "gate-nla");
worker.terminate();
return { sculpt: sculpt.error, sculptMirror: sculptMirror.error, cycle: cycle.error, external: external.error, shader: shader.error, arbitraryShader: arbitraryShader.error, unsupportedMath: unsupportedMath.error, nla: nla.error };
});
expect(result.sculpt?.code).toBe("SCULPT_STROKE_BUDGET_EXCEEDED");
expect(result.sculptMirror?.code).toBe("CAPABILITY_MISSING");
expect(result.cycle?.code).toBe("GN_DEPENDENCY_CYCLE");
expect(result.external?.code).toBe("GN_EXTERNAL_RESOURCE_MISSING");
expect(result.shader?.code).toBe("SHADER_EXTERNAL_RESOURCE_MISSING");
expect(result.shader?.message).toContain("N-013");
expect(result.arbitraryShader?.code).toBe("SHADER_NODE_UNSUPPORTED");
expect(result.unsupportedMath?.code).toBe("SHADER_NODE_UNSUPPORTED");
expect(result.nla?.code).toBe("NLA_PATH_INCOMPATIBLE");
});
test("exposes PBR-007 to PBR-012 renderer security gates", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, { taskId: string; status: string; code?: string }>>((resolve, reject) => {
type Response = { requestId: string; kind: string; ok?: boolean; result?: { capabilityGate?: { taskId: string; status: string; issues: Array<{ code: string }> } }; error?: { message?: string } };
const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" });
const pending = new Map<string, (response: Response) => void>();
worker.onmessage = (event: MessageEvent<Response>) => pending.get(event.data.requestId)?.(event.data);
const send = (requestId: string, request: unknown): Promise<Response> => new Promise((resolveRequest) => {
pending.set(requestId, resolveRequest);
worker.postMessage({ requestId, command: { type: "queryRenderCapability", request } });
});
void Promise.all([
send("gpu", { kind: "WEBGPU_BACKEND" }),
send("bloom", { kind: "POSTPROCESS", passes: ["BLOOM"] }),
send("volume", { kind: "VOLUME" }),
send("sss", { kind: "SUBSURFACE" }),
send("shader", { kind: "ARBITRARY_SHADER", nodeTypes: ["MIX"] }),
]).then((responses) => {
worker.terminate();
const output: Record<string, { taskId: string; status: string; code?: string }> = {};
for (const [index, name] of ["gpu", "bloom", "volume", "sss", "shader"].entries()) {
const gate = responses[index].result?.capabilityGate;
output[name] = { taskId: gate?.taskId ?? "", status: gate?.status ?? "", code: gate?.issues[0]?.code };
}
resolve(output);
}).catch(reject);
}));
expect(result.gpu).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "WEBGPU_RENDERER_UNAVAILABLE" });
expect(result.bloom).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "POSTPROCESS_PASS_UNAVAILABLE" });
expect(result.volume).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "VOLUME_SHADER_UNAVAILABLE" });
expect(result.sss).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "SUBSURFACE_SHADER_UNAVAILABLE" });
expect(result.shader).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "SHADER_NODE_UNSUPPORTED" });
});
test("transfers packed raster assets into the PBR viewport with an explicit status", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", path.resolve(import.meta.dirname, "../../../tests/files/web/packed_image_scene.blend"));
await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects", { timeout: 20_000 });
const canvas = page.locator("canvas.viewport-canvas");
await expect.poll(async () => Number(await canvas.getAttribute("data-texture-loaded") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
await expect(canvas).toHaveAttribute("data-texture-status", "ready");
});
test("uses the same packed texture payload in the OffscreenCanvas renderer", async ({ page }) => {
await page.goto("/?offscreen=1");
await page.setInputFiles("[data-testid=blend-file-input]", path.resolve(import.meta.dirname, "../../../tests/files/web/packed_image_scene.blend"));
const canvas = page.locator("canvas.viewport-canvas");
await expect.poll(async () => Number(await canvas.getAttribute("data-texture-loaded") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
await expect(canvas).toHaveAttribute("data-texture-status", "ready");
await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
});