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>
142 lines
9.1 KiB
TypeScript
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);
|
|
});
|