Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { projectArchive, unpackProject } from "../engine/archive.ts";
|
||||
import { validateProject } from "../engine/schema.ts";
|
||||
import { project, node, findNode, triangleGlb } from "./fixtures.ts";
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
async function toBytes(result: any): Promise<Uint8Array> {
|
||||
if (result instanceof Uint8Array) return result;
|
||||
if (result instanceof ArrayBuffer) return new Uint8Array(result);
|
||||
if (typeof result?.arrayBuffer === "function")
|
||||
return new Uint8Array(await result.arrayBuffer());
|
||||
throw new TypeError("Archive builder did not return bytes or a Blob");
|
||||
}
|
||||
const decodeData = (uri: string) =>
|
||||
Buffer.from(uri.slice(uri.indexOf(",") + 1), "base64");
|
||||
|
||||
test("project archive restores scripts, exposed properties, geometry and transforms", async () => {
|
||||
const p = project([
|
||||
node("model", null, {
|
||||
mesh: { assetId: "geometry_test" },
|
||||
script: { scriptId: "spin", params: { speed: 2 } },
|
||||
}),
|
||||
]);
|
||||
p.scenes[0].entities[0].transform.position = [4, 1, -7];
|
||||
p.assets = [
|
||||
{
|
||||
id: "geometry_test",
|
||||
name: "Triangle",
|
||||
kind: "geometry",
|
||||
geometry: { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] },
|
||||
},
|
||||
];
|
||||
p.scripts = [
|
||||
{
|
||||
id: "spin",
|
||||
name: "Spin",
|
||||
source:
|
||||
"({ update(api, dt) { api.rotate(0, dt * api.params.speed, 0); } })",
|
||||
fields: { speed: { type: "number", default: 1 } },
|
||||
},
|
||||
];
|
||||
const restored = await unpackProject(await toBytes(await projectArchive(p)));
|
||||
validateProject(restored);
|
||||
assert.deepEqual(restored, p);
|
||||
assert.deepEqual(findNode(restored, "model").transform.position, [4, 1, -7]);
|
||||
});
|
||||
test("GLB bytes survive project export and reimport without a network dependency", async () => {
|
||||
const model = triangleGlb(true);
|
||||
const p = project([
|
||||
node("triangle", null, { mesh: { assetId: "triangle_model" } }),
|
||||
]);
|
||||
p.assets = [
|
||||
{
|
||||
id: "triangle_model",
|
||||
name: "Triangle",
|
||||
kind: "model",
|
||||
uri: "data:model/gltf-binary;base64," + model.toString("base64"),
|
||||
},
|
||||
];
|
||||
const restored = await unpackProject(await toBytes(await projectArchive(p)));
|
||||
validateProject(restored);
|
||||
assert.match(restored.assets[0].uri!, /^data:/);
|
||||
assert.deepEqual(decodeData(restored.assets[0].uri!), model);
|
||||
});
|
||||
test("archive import rejects a missing referenced asset instead of silently losing it", async () => {
|
||||
const p = project([node("triangle", null, { mesh: { assetId: "model" } })]);
|
||||
p.assets = [
|
||||
{ id: "model", name: "Missing", kind: "model", uri: "assets/missing.glb" },
|
||||
];
|
||||
const archive = zipSync({ "project.forma.json": strToU8(JSON.stringify(p)) });
|
||||
await assert.rejects(async () => unpackProject(archive));
|
||||
});
|
||||
test("archive rejects asset references that escape its asset directory", async () => {
|
||||
const model = triangleGlb();
|
||||
for (const uri of [
|
||||
"../outside.glb",
|
||||
"assets/../../outside.glb",
|
||||
"/etc/passwd",
|
||||
"assets\\..\\outside.glb",
|
||||
]) {
|
||||
const p = project([
|
||||
node("model_node", null, { mesh: { assetId: "model" } }),
|
||||
]);
|
||||
p.assets = [{ id: "model", name: "Unsafe", kind: "model", uri }];
|
||||
const archive = zipSync({
|
||||
"project.forma.json": strToU8(JSON.stringify(p)),
|
||||
[uri]: new Uint8Array(model),
|
||||
});
|
||||
await assert.rejects(
|
||||
async () => unpackProject(archive),
|
||||
"must reject " + uri,
|
||||
);
|
||||
}
|
||||
});
|
||||
test("foreign or malformed archives fail with an explicit error", async () => {
|
||||
for (const bytes of [
|
||||
new Uint8Array([1, 2, 3]),
|
||||
zipSync({ "readme.txt": strToU8("not a project") }),
|
||||
strToU8('{"format":"forma","version":999}'),
|
||||
]) {
|
||||
await assert.rejects(async () => unpackProject(bytes));
|
||||
}
|
||||
});
|
||||
test("asset IDs that normalize to the same file name cannot corrupt an export", async () => {
|
||||
const a = triangleGlb(true);
|
||||
const b = triangleGlb();
|
||||
const p = project();
|
||||
p.assets = [
|
||||
{
|
||||
id: "asset/a",
|
||||
name: "A",
|
||||
kind: "model",
|
||||
uri: "data:model/gltf-binary;base64," + a.toString("base64"),
|
||||
},
|
||||
{
|
||||
id: "asset_a",
|
||||
name: "B",
|
||||
kind: "model",
|
||||
uri: "data:model/gltf-binary;base64," + b.toString("base64"),
|
||||
},
|
||||
];
|
||||
// Rejecting unsafe IDs is valid; accepting them must preserve distinct content.
|
||||
try {
|
||||
validateProject(p);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const restored = await unpackProject(await toBytes(await projectArchive(p)));
|
||||
assert.deepEqual(
|
||||
decodeData(restored.assets.find((x: any) => x.id === "asset/a")!.uri!),
|
||||
a,
|
||||
);
|
||||
assert.deepEqual(
|
||||
decodeData(restored.assets.find((x: any) => x.id === "asset_a")!.uri!),
|
||||
b,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user