Files
workinf_Blender_Wasm/web/tests/e2e/server-blend-cross-abi.spec.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

142 lines
9.1 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const root = path.resolve(import.meta.dirname, "../../..");
const inspect = `import bpy,json
r={"frame":bpy.context.scene.frame_current,"objects":sorted((o.name,o.type,list(o.location)) for o in bpy.context.scene.objects),"meshes":sorted((m.name,len(m.vertices),len(m.polygons),[list(v.co) for v in list(m.vertices)[:10]]) for m in bpy.data.meshes),"modifiers":sorted((o.name,[(m.name,m.type,m.show_viewport) for m in o.modifiers]) for o in bpy.context.scene.objects),"images":sorted((i.name,bool(i.packed_file),list(i.size)) for i in bpy.data.images if i.type not in {'RENDER_RESULT','COMPOSITING'}),"materials":sorted(m.name for m in bpy.data.materials),"collections":sorted((c.name,sorted(o.name for o in c.objects)) for c in bpy.data.collections)}
r["particles"]=sorted((o.name,[(p.name,p.settings.name,p.settings.type,p.settings.count) for p in o.particle_systems]) for o in bpy.context.scene.objects)
print("ROUNDTRIP_JSON="+json.dumps(r,sort_keys=True))`;
function desktop(file: string) {
const output = execFileSync(path.join(root, "build_blender_native/bin/blender"), ["--background", file, "--python-exit-code", "1", "--python-expr", inspect], { encoding: "utf8", timeout: 30_000, maxBuffer: 1024 * 1024 });
const line = output.split("\n").find((line) => line.startsWith("ROUNDTRIP_JSON="));
if (!line) throw new Error(`Missing native inspection result: ${output.slice(-2000)}`);
return JSON.parse(line.slice("ROUNDTRIP_JSON=".length));
}
for (const fixture of ["WBV2-P2-003-MESH.EDIT_CORE.blend", "WBV2-P4-001-MODIFIER.DECLARED_SLICES.blend", "WBV2P3005Library.blend"]) {
test(`WASM edited blend has desktop-readable DNA: ${fixture}`, async ({ page }, testInfo) => {
test.setTimeout(90_000);
const file = path.join(root, "tests/files/web/generated", fixture);
const before = desktop(file);
await page.goto("/");
const saved = await page.evaluate(async (bytes) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient();
try {
await client.openBlend(Uint8Array.from(bytes).buffer);
await client.applyCommand({ type: "setFrame", frame: 7 });
return Array.from(new Uint8Array(await client.saveBlend()));
} catch (error) { throw new Error(JSON.stringify(error)); }
finally { client.terminate(); }
}, Array.from(fs.readFileSync(file)));
const edited = testInfo.outputPath("edited.blend");
fs.writeFileSync(edited, Buffer.from(saved));
const after = desktop(edited);
expect(after).toEqual({ ...before, frame: 7 });
await testInfo.attach("cross-abi", { body: JSON.stringify({ fixture, before, after }), contentType: "application/json" });
});
}
test("invalid modifier edits leave Main serializable and unchanged", async ({ page }) => {
await page.goto("/");
const fixture = fs.readFileSync(path.join(root, "tests/files/web/generated/WBV2-P4-001-MODIFIER.DECLARED_SLICES.blend"));
const result = await page.evaluate(async (bytes) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient();
try {
await client.openBlend(Uint8Array.from(bytes).buffer);
const before = await client.applyCommand({ type: "setFrame", frame: 7 });
const error = await client.applyCommand({ type: "addModifier", meshId: "missing:99999", modifierType: "PARTICLE_SYSTEM" })
.then(() => "unexpected success", (error) => error.code);
const savedBytes = (await client.saveBlend()).byteLength;
const after = await client.snapshot();
return { error, savedBytes, unchanged: JSON.stringify(before.snapshot) === JSON.stringify(after.snapshot) };
} catch (error) { throw new Error(JSON.stringify(error)); }
finally { client.terminate(); }
}, Array.from(fixture));
expect(result.error).toBe("INVALID_ARGUMENT");
expect(result.savedBytes).toBeGreaterThan(0);
expect(result.unchanged).toBe(true);
});
test("particle modifier creation and removal preserve linked systems across native recovery", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const fixture = path.join(root, "tests/files/web/modifier_stack_scene.blend");
await page.goto("/");
const files = await page.evaluate(async (bytes) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient();
const saved: Record<string, number[]> = {};
const save = async (label: string) => { saved[label] = Array.from(new Uint8Array(await client.saveBlend())); };
try {
const opened = await client.openBlend(Uint8Array.from(bytes).buffer);
const meshId = opened.snapshot.nodes.find((node) => node.name === "BasicCube")!.dataId!;
await save("before");
const added = await client.applyCommand({ type: "addModifier", meshId, modifierType: "PARTICLE_SYSTEM", name: "Web Particle" });
const modifier = added.snapshot.meshes.find((mesh) => mesh.id === meshId)!.modifierStack!.find((item) => item.type === "PARTICLE_SYSTEM")!;
await save("added");
await client.applyCommand({ type: "undo" });
await save("undo");
await client.applyCommand({ type: "redo" });
await save("redo");
await client.openBlend(Uint8Array.from(saved.added).buffer);
await save("reopened");
await client.applyCommand({ type: "removeModifier", meshId, modifierUuid: modifier.uuid });
await save("removed");
await client.applyCommand({ type: "undo" });
await save("restored");
return saved;
} finally { client.terminate(); }
}, Array.from(fs.readFileSync(fixture)));
const states: Record<string, ReturnType<typeof desktop>> = {};
for (const [label, bytes] of Object.entries(files)) {
const filename = testInfo.outputPath(`${label}.blend`);
fs.writeFileSync(filename, Buffer.from(bytes));
states[label] = desktop(filename);
}
const systems = states.added.particles.find(([name]: [string]) => name === "BasicCube")[1];
expect(systems).toHaveLength(1);
expect(systems[0]).toEqual(["Web Particle", expect.any(String), "EMITTER", expect.any(Number)]);
expect(states.added).not.toEqual(states.before);
for (const label of ["undo", "removed"]) expect(states[label]).toEqual(states.before);
for (const label of ["redo", "reopened", "restored"]) expect(states[label]).toEqual(states.added);
await testInfo.attach("particle-lifecycle", { body: JSON.stringify(states), contentType: "application/json" });
});
test("native server-result history restores packed assets and truncates redo on edit", async ({ page }) => {
await page.goto("/");
const bytes = (name: string) => Array.from(fs.readFileSync(path.join(root, "tests/files/web/generated", name)));
const result = await page.evaluate(async ({ source, replacement }) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const client = new WebEngineClient();
try {
const initial = await client.openBlend(Uint8Array.from(source).buffer);
const image = initial.snapshot.images.find((image) => image.name === "WBV2P3005AppendImage");
if (!image) throw new Error("missing packed image fixture");
const original = await client.requestAsset(image.id);
const digest = async (data: ArrayBuffer | undefined) => data ? Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (n) => n.toString(16).padStart(2, "0")).join("") : null;
const applied = await client.applyServerBlendResult(Uint8Array.from(replacement).buffer, initial.snapshot.revision);
await client.applyCommand({ type: "undo" });
const restored = await client.requestAsset(image.id);
const redone = await client.applyCommand({ type: "redo" });
await client.applyCommand({ type: "undo" });
const edited = await client.applyCommand({ type: "setFrame", frame: 8 });
const redoError = await client.applyCommand({ type: "redo" }).then(() => "unexpected success", (error) => error.message);
const afterFailedRedo = await client.snapshot();
return { originalStatus: original.status, restoredStatus: restored.status, originalDigest: await digest(original.data), restoredDigest: await digest(restored.data), resultImages: applied.snapshot.images.map((i) => i.name), redoImages: redone.snapshot.images.map((i) => i.name), redoError, redoFailureUnchanged: JSON.stringify(edited.snapshot) === JSON.stringify(afterFailedRedo.snapshot) };
} catch (error) { throw new Error(JSON.stringify(error)); }
finally { client.terminate(); }
}, { source: bytes("WBV2P3005Library.blend"), replacement: bytes("WBV2-P24-001-OBJECT_OPERATIONS.MOVE_COLLECTION.blend") });
expect(result.originalStatus).toBe("packed");
expect(result.restoredStatus).toBe("packed");
expect(result.originalDigest).toBeTruthy();
expect(result.restoredDigest).toBe(result.originalDigest);
expect(result.resultImages).not.toContain("WBV2P3005AppendImage");
expect(result.redoImages).not.toContain("WBV2P3005AppendImage");
expect(result.redoError).toContain("redo history is empty");
expect(result.redoFailureUnchanged).toBe(true);
});