Files
workinf_Blender_Wasm/web/tests/e2e/v1-user-loop.spec.ts
2026-08-14 22:32:09 -04:00

166 lines
9.7 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");
test("completes the V1 import-edit-save-restart-reopen-GLB loop", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
const result = await page.evaluate(async (input) => {
const [{ WebEngineClient }, { StorageClient }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([
import("/src/engine-client/WebEngineClient.ts"),
import("/src/storage/StorageClient.ts"),
import("/src/three-adapter/viewport.ts"),
import("/src/three-adapter/offscreen-viewport.ts"),
]);
const projectId = `v1-loop-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const digestGeometry = async (buffers: Array<{ meshId: string; positions: ArrayBuffer; indices: ArrayBuffer }>, meshId: string): Promise<string> => {
const geometry = buffers.find((candidate) => candidate.meshId === meshId);
if (!geometry) throw new Error(`missing geometry ${meshId}`);
const bytes = new Uint8Array(geometry.positions.byteLength + geometry.indices.byteLength);
bytes.set(new Uint8Array(geometry.positions));
bytes.set(new Uint8Array(geometry.indices), geometry.positions.byteLength);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
};
const materialByName = (snapshot: { materials: Array<{ name: string; roughness: number; metallic: number; baseColor: number[] }> }, name: string) =>
snapshot.materials.find((material) => material.name === name);
const firstEngine = new WebEngineClient({ timeoutMs: 30_000 });
const firstStorage = new StorageClient();
let editedRevision = 0;
let objectId = "";
let meshId = "";
let materialId = "";
let editedGeometrySha256 = "";
let storedRevision = 0;
try {
const opened = await firstEngine.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
const created = await firstEngine.applyCommand({ type: "createPrimitive", primitive: "CUBE", name: "V1LoopCube", location: [2, 0, 0] });
const object = created.snapshot.nodes.find((node) => node.name === "V1LoopCube");
if (!object?.dataId) throw new Error("V1 object creation did not return stable object/mesh IDs");
objectId = object.id;
meshId = object.dataId;
await firstEngine.applyCommand({ type: "setObjectTransform", objectId, translation: [2, 1, 0.5], rotationEuler: [0.1, 0.2, 0.3], scale: [1.25, 0.75, 1.5] });
await firstEngine.applyCommand({ type: "translateMeshVertices", meshId, vertexIndices: [0, 1], offset: [0, 0, 0.25] });
const slotted = await firstEngine.applyCommand({ type: "addMaterialSlot", objectId, name: "V1LoopMaterial" });
const material = materialByName(slotted.snapshot, "V1LoopMaterial");
if (!material) throw new Error("V1 material slot did not create a material");
materialId = (material as { id?: string }).id ?? "";
if (!materialId) throw new Error("V1 material has no stable ID");
const materialEdited = await firstEngine.applyCommand({
type: "setMaterialPrincipled",
materialId,
baseColor: [0.12, 0.34, 0.56, 1],
roughness: 0.23,
metallic: 0.67,
});
const undone = await firstEngine.applyCommand({ type: "undo" });
const undoMaterial = materialByName(undone.snapshot, "V1LoopMaterial");
const redone = await firstEngine.applyCommand({ type: "redo" });
const redoMaterial = materialByName(redone.snapshot, "V1LoopMaterial");
if (!undoMaterial || !redoMaterial || undoMaterial.roughness === redoMaterial.roughness) throw new Error("V1 material undo/redo did not change Main state");
if (materialEdited.snapshot.revision >= redone.snapshot.revision) throw new Error("V1 history revisions did not advance");
editedRevision = redone.snapshot.revision;
editedGeometrySha256 = await digestGeometry(redone.geometryBuffers, meshId);
const unsupported = await firstEngine.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["UNSUPPORTED"] });
if (unsupported.status !== "BLOCKED" || unsupported.issues[0]?.code !== "SHADER_NODE_UNSUPPORTED") throw new Error("V1 unsupported shader gate drifted");
const saved = await firstEngine.saveBlend();
const stored = await firstStorage.saveProject(projectId, editedRevision, saved.slice(0));
await firstStorage.saveSnapshot(projectId, editedRevision, saved.slice(0));
storedRevision = stored.revision;
}
finally {
firstEngine.terminate();
firstStorage.terminate();
}
const restartedStorage = new StorageClient();
const stored = await restartedStorage.readProject(projectId);
restartedStorage.terminate();
const restartedEngine = new WebEngineClient({ timeoutMs: 30_000 });
const reopened = await restartedEngine.openBlend(stored.buffer);
restartedEngine.terminate();
const reopenedObject = reopened.snapshot.nodes.find((node) => node.id === objectId);
const reopenedMaterial = reopened.snapshot.materials.find((material) => material.id === materialId);
const reopenedGeometrySha256 = await digestGeometry(reopened.geometryBuffers, meshId);
if (!reopenedObject || !reopenedMaterial) throw new Error("V1 reopened scene lost edited IDs");
const createCanvas = (): HTMLCanvasElement => {
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 240;
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px";
document.body.append(canvas);
return canvas;
};
const mainCanvas = createCanvas();
const main = new ViewportRenderer(mainCanvas);
main.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const gl = main.renderer.getContext();
const mainPixels = new Uint8Array(32 * 32 * 4);
gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, mainPixels);
const mainVisible = Array.from({ length: 32 * 32 }, (_, index) => mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2] > 30).filter(Boolean).length;
main.dispose();
mainCanvas.remove();
const offscreenCanvas = createCanvas();
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
offscreen.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []);
const deadline = performance.now() + 30_000;
while (Number(offscreenCanvas.dataset.rendererPixels ?? 0) === 0 && !offscreenCanvas.dataset.rendererError) {
if (performance.now() > deadline) throw new Error("V1 Offscreen viewport timed out");
await new Promise((resolve) => setTimeout(resolve, 25));
}
const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0);
const offscreenError = offscreenCanvas.dataset.rendererError;
offscreen.dispose();
offscreenCanvas.remove();
const glb = await new Promise<{ ok: boolean; byteLength: number; roundTrip?: { compatible: boolean; mismatches: string[] }; pbrSummary?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number }; error?: string }>((resolve, reject) => {
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
worker.onmessage = (event) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const transfer: Transferable[] = [];
for (const geometry of reopened.geometryBuffers) {
for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value);
}
worker.postMessage({ snapshot: reopened.snapshot, geometryBuffers: reopened.geometryBuffers, assetBuffers: [] }, transfer);
});
return {
editedRevision,
storedRevision,
persistedRevision: stored.revision,
reopenedRevision: reopened.snapshot.revision,
objectTransform: reopenedObject.transform,
material: reopenedMaterial,
geometry: [editedGeometrySha256, reopenedGeometrySha256],
viewport: { mainVisible, offscreenVisible, offscreenError },
glb,
};
}, new Uint8Array(bytes));
expect(result.editedRevision).toBeGreaterThan(1);
expect(result.storedRevision).toBe(result.editedRevision);
expect(result.persistedRevision).toBe(result.editedRevision);
expect(result.reopenedRevision).toBe(1);
expect(result.objectTransform.translation).toEqual([2, 1, 0.5]);
expect(result.objectTransform.scale).toEqual([1.25, 0.75, 1.5]);
[0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.material.baseColor[index]).toBeCloseTo(value, 6));
expect(result.material.roughness).toBeCloseTo(0.23);
expect(result.material.metallic).toBeCloseTo(0.67);
expect(result.geometry[0]).toMatch(/^[a-f0-9]{64}$/);
expect(result.geometry[1]).toBe(result.geometry[0]);
expect(result.viewport.mainVisible).toBeGreaterThan(0);
expect(result.viewport.offscreenVisible).toBeGreaterThan(0);
expect(result.viewport.offscreenError).toBeUndefined();
expect(result.glb.ok).toBe(true);
expect(result.glb.byteLength).toBeGreaterThan(1_000);
expect(result.glb.roundTrip).toEqual({ compatible: true, mismatches: [] });
[0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.glb.pbrSummary?.baseColorFactor?.[index]).toBeCloseTo(value, 6));
expect(result.glb.pbrSummary?.roughnessFactor).toBeCloseTo(0.23);
expect(result.glb.pbrSummary?.metallicFactor).toBeCloseTo(0.67);
});