Files
workinf_Blender_Wasm/web/tests/e2e/glb-export-loss-report.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

128 lines
7.0 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const root = path.resolve(import.meta.dirname, "../../..");
const mainReport = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-06C/desktop-main-report.json"), "utf8"));
const reportPath = path.join(root, "tests/golden/M12-06D/web-loss-report.json");
const fixtureRoot = path.join(root, "tests/files/web/m12_glb_main_v1");
const sha256 = (bytes: Uint8Array): string => crypto.createHash("sha256").update(bytes).digest("hex");
test("writes a machine GLB loss report for every persisted Main fixture", async ({ page }, testInfo) => {
await page.goto("/");
const fixtures = mainReport.fixtures.map((fixture: { id: string; blend: { file: string; sha256: string } }) => ({
id: fixture.id,
bytes: Array.from(fs.readFileSync(path.join(fixtureRoot, fixture.blend.file))),
sourceBlendSha256: fixture.blend.sha256,
}));
const generated = await page.evaluate(async (input) => {
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
const digest = async (bytes: ArrayBuffer): Promise<string> => {
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return Array.from(hash, (value) => value.toString(16).padStart(2, "0")).join("");
};
const reports = [];
for (const fixture of input) {
const client = new WebEngineClient({ timeoutMs: 30_000 });
try {
const opened = await client.openBlend(Uint8Array.from(fixture.bytes).buffer);
const assets = [];
for (const image of opened.snapshot.images) {
const asset = await client.requestAsset(image.assetId);
if (asset.status === "packed" && asset.data && asset.mimeType) assets.push({ assetId: image.assetId, mimeType: asset.mimeType, data: asset.data });
}
const worker = new Worker("/src/workers/glb-loss-report-test.worker.ts", { type: "module" });
const result = await new Promise<{ report: any; output: ArrayBuffer | null }>((resolve, reject) => {
worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: any; output?: ArrayBuffer | null; error?: string }>) => {
worker.terminate();
if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB loss report worker failed"));
else resolve({ report: event.data.report, output: event.data.output ?? null });
};
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
const transfer: Transferable[] = [];
for (const geometry of opened.geometryBuffers) for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value);
for (const asset of assets) transfer.push(asset.data);
for (const chunk of opened.nonMeshGeometryBuffers ?? []) for (const value of Object.values(chunk)) if (value instanceof ArrayBuffer) transfer.push(value);
worker.postMessage({ snapshot: opened.snapshot, geometryBuffers: opened.geometryBuffers, assetBuffers: assets, nonMeshGeometryBuffers: opened.nonMeshGeometryBuffers ?? [] }, transfer);
});
if (result.output) {
const length = new DataView(result.output).getUint32(12, true);
const gltf = JSON.parse(new TextDecoder().decode(new Uint8Array(result.output, 20, length)));
// Basis conjugation must preserve identity and map (x,y,z) to (x,z,-y).
const axis = [0, 2, 1, 3], sign = [1, 1, -1, 1];
for (const source of opened.snapshot.nodes) {
const target = gltf.nodes.find((node: any) => node.extras?.blenderId === source.id);
if (!target) throw new Error(`Missing exported node ${source.id}`);
for (let column = 0; column < 4; column++) for (let row = 0; row < 4; row++) {
const expected = sign[row] * sign[column] * source.localMatrix[axis[column] * 4 + axis[row]];
if (Math.abs(target.matrix[column * 4 + row] - expected) > 1e-6) throw new Error(`GLB basis mismatch ${source.name} ${row},${column}`);
}
if (source.parentId) {
const parent = gltf.nodes.find((node: any) => node.extras?.blenderId === source.parentId);
if (!parent?.children?.includes(gltf.nodes.indexOf(target))) throw new Error(`GLB parent lost ${source.name}`);
}
}
}
reports.push({
fixtureId: fixture.id,
sourceBlendSha256: fixture.sourceBlendSha256,
lossReport: result.report,
output: result.output ? { byteLength: result.output.byteLength, sha256: await digest(result.output), bytes: Array.from(new Uint8Array(result.output)) } : null,
});
}
finally {
client.terminate();
}
}
return reports;
}, fixtures);
const report = {
schemaVersion: 1,
task: "M12-06D",
operation: "WEB_GLB_EXPORT_LOSS_REPORT",
fixtureCount: generated.length,
fixtures: generated,
nextTask: "M12-06E",
};
for (const fixture of generated) {
if (fixture.output?.bytes) await testInfo.attach(`${fixture.fixtureId}.glb`, { body: Buffer.from(fixture.output.bytes), contentType: "model/gltf-binary" });
}
if (process.env.UPDATE_GLB_LOSS_REPORT === "1") {
const outputRoot = path.join(root, "tests/files/web/m12_glb_web_v1");
fs.mkdirSync(outputRoot, { recursive: true });
for (const fixture of generated) {
if (fixture.output?.bytes) fs.writeFileSync(path.join(outputRoot, `${fixture.fixtureId}.glb`), Buffer.from(fixture.output.bytes));
}
}
for (const fixture of report.fixtures) if (fixture.output?.bytes) delete fixture.output.bytes;
if (process.env.UPDATE_GLB_LOSS_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
}
const expected = JSON.parse(fs.readFileSync(reportPath, "utf8"));
expect(report).toEqual(expected);
expect(report.fixtureCount).toBe(5);
for (const fixture of report.fixtures) {
expect(fixture.lossReport.schemaVersion).toBe(1);
expect(fixture.lossReport.operation).toBe("GLB_EXPORT_LOSS_REPORT");
if (fixture.fixtureId === "mesh") {
expect(fixture.lossReport.canExport).toBe(false);
expect(fixture.lossReport.errorCount).toBe(1);
expect(fixture.lossReport.losses.map((loss: { code: string }) => loss.code)).toEqual(["LINKED_MATERIAL_INPUT_UNEVALUATED", "SHADER_GRAPH_UNMAPPABLE"]);
expect(fixture.output).toBeNull();
}
else {
expect(fixture.lossReport.canExport).toBe(true);
expect(fixture.lossReport.errorCount).toBe(0);
expect(fixture.output?.byteLength).toBeGreaterThan(128);
expect(fixture.output?.sha256).toMatch(/^[0-9a-f]{64}$/);
}
expect(fixture.lossReport.losses).toEqual([...fixture.lossReport.losses].sort((left, right) =>
left.code.localeCompare(right.code) || left.severity.localeCompare(right.severity) ||
(left.id ?? "").localeCompare(right.id ?? "") || left.message.localeCompare(right.message)));
}
});