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>
128 lines
7.0 KiB
TypeScript
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)));
|
|
}
|
|
});
|