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 => { 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))); } });