138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
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,
|
|
);
|
|
});
|