Publish Forma Engine 0.3.0 source with documentation and CI

This commit is contained in:
emil28092005
2026-09-09 15:49:08 +03:00
commit e52bc0e33b
70 changed files with 19610 additions and 0 deletions
+137
View File
@@ -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,
);
});
+187
View File
@@ -0,0 +1,187 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { unzipSync } from "fflate";
import { normalizeOptions } from "../native/options.mjs";
import { buildKit } from "../engine/build-kit.ts";
import { defaultProject } from "../engine/templates.ts";
import { BuildManager } from "../server/builds.ts";
const { readGame } = createRequire(import.meta.url)(
"../native/desktop/protocol.cjs",
);
const cleanup = createRequire(import.meta.url)("../native/cleanup.cjs");
test("Windows packaging cleanup removes only abandoned root temporary files", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "forma-cleanup-"));
try {
await fs.writeFile(path.join(root, "Game.exe"), "actual executable");
await fs.writeFile(path.join(root, ".electron.exe.Abc123"), "abandoned");
await fs.writeFile(path.join(root, ".electron.exe.user-file"), "preserve");
await fs.mkdir(path.join(root, ".electron.exe.Dir123"));
await fs.symlink(path.join(root, "Game.exe"), path.join(root, ".electron.exe.Link12"));
const context = { appOutDir: root, packager: { appInfo: { productFilename: "Game" } } };
await cleanup({ ...context, electronPlatformName: "linux" });
assert.equal((await fs.readdir(root)).length, 5);
await cleanup({ ...context, electronPlatformName: "win32" });
assert.deepEqual((await fs.readdir(root)).sort(), [".electron.exe.Dir123", ".electron.exe.Link12", ".electron.exe.user-file", "Game.exe"].sort());
assert.equal(await fs.readFile(path.join(root, "Game.exe"), "utf8"), "actual executable");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
const ready: any = {
targets: {
linux: { ready: true, missing: [] },
windows: { ready: true, missing: [] },
android: { ready: true, missing: [], releaseSigningConfigured: false },
},
};
test("Build options reject command/path injection and invalid package versions", () => {
for (const value of [
{ target: "linux;id" },
{ appId: "../../escape" },
{ name: "../../game" },
{ version: "1.0.0\ncommand" },
{ width: NaN },
{ versionCode: 0 },
{ fullscreen: "yes" },
])
assert.throws(() => normalizeOptions(value));
assert.equal(
normalizeOptions({ name: "Bob's Game", target: "android" }).name,
"Bob's Game",
);
});
test("Desktop protocol serves local fetch/wasm and rejects symlink escapes, other hosts and writes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "forma-protocol-"));
try {
await fs.mkdir(path.join(root, "game"));
await fs.writeFile(path.join(root, "secret"), "private");
await fs.writeFile(path.join(root, "game", "index.html"), "<canvas/>");
await fs.writeFile(
path.join(root, "game", "test.wasm"),
new Uint8Array([0, 97, 115, 109]),
);
await fs.symlink(
path.join(root, "secret"),
path.join(root, "game", "escape"),
);
const game = path.join(root, "game");
const page = await readGame(game, "forma://game/");
assert.equal(page.status, 200);
assert.match(
page.headers["Content-Security-Policy"],
/worker-src 'self' blob:/,
);
assert.equal(
(await readGame(game, "forma://game/test.wasm")).headers["Content-Type"],
"application/wasm",
);
for (const url of [
"forma://evil/index.html",
"https://game/index.html",
"forma://game/escape",
"forma://game/%2e%2e%2fsecret",
"forma://game/%5csecret",
])
assert.notEqual((await readGame(game, url)).status, 200);
assert.equal((await readGame(game, "forma://game/", "POST")).status, 405);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
test("Build kit includes same game, offline templates and exact lockfile; excludes secrets/toolchains", async () => {
const p = defaultProject(true);
const read = async (uri: string) => {
const clean = uri.replace(/^\//, "");
return new Uint8Array(await fs.readFile(path.resolve("public", clean)));
};
const kit = unzipSync(
await buildKit(p, { target: "android", name: "Bob's Game" }, read),
);
assert.ok(kit["native/game/player.js"]);
assert.ok(
kit["native/android/app/src/main/java/com/forma/shell/MainActivity.java"],
);
assert.ok(kit["native/package-lock.json"]);
assert.ok(kit["native/desktop/main.cjs"]);
assert.ok(kit["native/cleanup.cjs"]);
assert.equal(
JSON.parse(new TextDecoder().decode(kit["native/game/project.forma.json"]))
.id,
p.id,
);
assert.ok(
!Object.keys(kit).some((n) =>
/node_modules|\.mcp-token|keystore|\.toolchains/.test(n),
),
);
assert.equal(
JSON.parse(new TextDecoder().decode(kit["native/build-config.json"]))
.target,
"android",
);
});
test("Cancelling an in-flight asset snapshot prevents starting builder and survives restart", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "forma-job-"));
let release!: (v: Uint8Array) => void;
let firstRead = true;
const m = new BuildManager(dir, () => {
if (firstRead) {
firstRead = false;
return new Promise((resolve) => {
release = resolve;
});
}
return Promise.resolve(new Uint8Array());
});
m.capabilities = async () => ready;
await m.init();
const p = defaultProject(true);
try {
await assert.rejects(
m.start(p, { target: "linux" }, p.revision + 1),
/REVISION_CONFLICT/,
);
const j = await m.start(p, { target: "linux" }, p.revision);
while (!release) await new Promise((r) => setTimeout(r, 5));
await m.cancel(j.id);
release(new Uint8Array());
await m.close();
assert.equal(m.get(j.id).status, "cancelled");
await assert.rejects(
m.artifact(j.id, "anything.exe"),
/Artifact not found/,
);
const restored = new BuildManager(dir, async () => new Uint8Array());
await restored.init();
assert.equal(restored.get(j.id).status, "cancelled");
await restored.close();
assert.equal(p.revision, j.revision);
} finally {
await m.close();
await fs.rm(dir, { recursive: true, force: true });
}
});
test("Asset read failure becomes a durable failed build, without reporting an artifact", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "forma-job-"));
const m = new BuildManager(dir, async () => {
throw Error("Missing model bytes");
});
m.capabilities = async () => ready;
await m.init();
try {
const p = defaultProject(true),
j = await m.start(p, { target: "linux" }, p.revision);
for (let i = 0; i < 100 && m.get(j.id).status === "building"; i++)
await new Promise((r) => setTimeout(r, 10));
assert.equal(m.get(j.id).status, "failed");
assert.match(m.get(j.id).error!, /Missing model/);
assert.equal(m.get(j.id).artifacts, undefined);
} finally {
await m.close();
await fs.rm(dir, { recursive: true, force: true });
}
});
+41
View File
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import R from '@dimforge/rapier3d-compat';
import { CharacterMotor } from '../engine/character.ts';
await R.init();
function setup(floors: {x:number,y:number,z:number,w:number,h:number,d:number}[]) {
const world=new R.World({x:0,y:-24,z:0});world.timestep=1/60;
for(const f of floors){const b=world.createRigidBody(R.RigidBodyDesc.fixed().setTranslation(f.x,f.y,f.z));world.createCollider(R.ColliderDesc.cuboid(f.w/2,f.h/2,f.d/2),b);}
const body=world.createRigidBody(R.RigidBodyDesc.kinematicPositionBased().setTranslation(0,.95,0));
const col=world.createCollider(R.ColliderDesc.capsule(.6,.3),body),c=world.createCharacterController(.025);
c.enableAutostep(.25,.2,true);
const motor=new CharacterMotor(body,col,c,{gravity:24,snapDistance:.12},R);
const tick=(n=1)=>{for(let i=0;i<n;i++){motor.step(1/60);world.step();}};
tick(20);return {world,body,motor,tick};
}
test('Character jump has a ballistic arc and lands; ceiling stops upward velocity',()=>{
const h=setup([{x:0,y:-.5,z:0,w:30,h:1,d:30}]);
try{assert.ok(h.motor.grounded);const y=h.body.translation().y;
h.motor.set({y:10});let peak=y,air=false;
for(let i=0;i<90;i++){h.tick();peak=Math.max(peak,h.body.translation().y);air||=!h.motor.grounded;}
assert.ok(air);assert.ok(peak-y>1.8&&peak-y<2.2,`rise ${peak-y}`);assert.ok(h.motor.grounded);assert.ok(Math.abs(h.body.translation().y-y)<.06);
const b=h.world.createRigidBody(R.RigidBodyDesc.fixed().setTranslation(0,2.6,0));h.world.createCollider(R.ColliderDesc.cuboid(2,.1,2),b);h.world.step();
h.motor.set({y:10});h.tick(10);assert.ok(h.motor.velocity.y<=0);assert.ok(h.body.translation().y<1.7);
}finally{h.world.free();}
});
test('Swept dash cannot pass through a thin wall; reports wall normal',()=>{
const h=setup([{x:0,y:-.5,z:0,w:30,h:1,d:30},{x:0,y:2,z:-3,w:10,h:5,d:.1}]);
try{h.motor.set({z:-24});h.tick(30);assert.ok(h.body.translation().z>-2.7);assert.ok(h.motor.contacts.some(c=>c.normal[2]>.9));
h.motor.teleport([0,4,-1]);assert.equal(h.motor.velocity.z,0);assert.equal(h.motor.grounded,false);assert.equal(h.motor.contacts.length,0);
}finally{h.world.free();}
});
test('A four metre gap requires a jump; low-gravity wall travel preserves clearance',()=>{
const floors=[{x:0,y:-.5,z:3,w:10,h:1,d:10},{x:0,y:-.5,z:-11,w:10,h:1,d:10}];
const h=setup(floors);
try{h.motor.set({z:-10});h.tick(32);assert.ok(h.body.translation().y<.5,'walking must fall into gap');
h.motor.teleport([0,.95,-1]);h.tick(2);h.motor.set({z:-10,y:10});h.tick(60);assert.ok(h.motor.grounded,'jump lands on second roof');assert.ok(h.body.translation().z<-6);
const b=h.world.createRigidBody(R.RigidBodyDesc.fixed().setTranslation(2,3,-15));h.world.createCollider(R.ColliderDesc.cuboid(.2,6,15),b);h.world.step();
h.motor.teleport([1.45,5,-9]);h.motor.set({x:1.4,z:-10,y:-.7,gravityScale:.08});h.tick(45);
assert.ok(h.body.translation().x<1.51);assert.ok(h.body.translation().z<-16);assert.ok(h.body.translation().y>3.8);assert.ok(h.motor.contacts.some(c=>c.normal[0]<-.9));
}finally{h.world.free();}
});
+155
View File
@@ -0,0 +1,155 @@
export function node(
id: string,
parentId: string | null = null,
components: Record<string, any> = {},
) {
return {
id,
name: id,
parentId,
enabled: true,
transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] },
components,
};
}
export function project(nodes: any[] = []) {
return {
format: "forma",
version: 1,
id: "test_project",
name: "Regression fixture",
revision: 0,
activeSceneId: "scene_main",
scenes: [{ id: "scene_main", name: "Main", entities: nodes }],
assets: [],
scripts: [],
settings: {
background: "#f3f0eb",
ambient: 0.8,
shadows: false,
renderScale: 1,
},
} as any;
}
export function scene(p: any) {
return p.scenes.find((s: any) => s.id === p.activeSceneId);
}
export function findNode(p: any, id: string) {
return scene(p).entities.find((e: any) => e.id === id);
}
/** A generated triangle, optionally skinned and animated; no game assets. */
export function triangleGlb(animated = false): Buffer {
const chunks: Buffer[] = [];
const views: any[] = [];
const accessors: any[] = [];
let length = 0;
const add = (
data: Float32Array | Uint16Array,
type: string,
count: number,
bounds: Record<string, any> = {},
) => {
const bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
views.push({ buffer: 0, byteOffset: length, byteLength: bytes.length });
chunks.push(bytes);
length += bytes.length;
const padding = (4 - (length % 4)) % 4;
if (padding) {
chunks.push(Buffer.alloc(padding));
length += padding;
}
accessors.push({
bufferView: views.length - 1,
componentType: data instanceof Float32Array ? 5126 : 5123,
count,
type,
...bounds,
});
return accessors.length - 1;
};
const position = add(
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
"VEC3",
3,
{ min: [0, 0, 0], max: [1, 1, 0] },
);
const normal = add(new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]), "VEC3", 3);
const indices = add(new Uint16Array([0, 1, 2]), "SCALAR", 3);
const attributes: Record<string, number> = {
POSITION: position,
NORMAL: normal,
};
const document: any = {
asset: { version: "2.0", generator: "Forma test fixture" },
scene: 0,
scenes: [{ nodes: animated ? [0, 1] : [0] }],
nodes: [{ name: "Triangle", mesh: 0 }],
meshes: [{ primitives: [{ attributes, indices }] }],
};
if (animated) {
attributes.JOINTS_0 = add(new Uint16Array(12), "VEC4", 3);
attributes.WEIGHTS_0 = add(
new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]),
"VEC4",
3,
);
const inverseBindMatrices = add(
new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
"MAT4",
1,
);
const input = add(new Float32Array([0, 1]), "SCALAR", 2, {
min: [0],
max: [1],
});
const output = add(new Float32Array([0, 0, 0, 0, 0.2, 0]), "VEC3", 2);
document.nodes[0].skin = 0;
document.nodes.push({ name: "Joint" });
document.skins = [{ joints: [1], inverseBindMatrices }];
document.animations = [
{
name: "Translate",
samplers: [{ input, output, interpolation: "LINEAR" }],
channels: [{ sampler: 0, target: { node: 1, path: "translation" } }],
},
];
}
Object.assign(document, {
buffers: [{ byteLength: length }],
bufferViews: views,
accessors,
});
const json = Buffer.from(JSON.stringify(document));
const jsonPadding = Buffer.alloc((4 - (json.length % 4)) % 4, 0x20);
const body = Buffer.concat(chunks);
const header = Buffer.alloc(12);
header.writeUInt32LE(0x46546c67, 0);
header.writeUInt32LE(2, 4);
header.writeUInt32LE(
12 + 8 + json.length + jsonPadding.length + 8 + body.length,
8,
);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(json.length + jsonPadding.length, 0);
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
const bodyHeader = Buffer.alloc(8);
bodyHeader.writeUInt32LE(body.length, 0);
bodyHeader.writeUInt32LE(0x004e4942, 4);
return Buffer.concat([
header,
jsonHeader,
json,
jsonPadding,
bodyHeader,
body,
]);
}
export const triangleAsset = (animated = false) => ({
id: "asset_triangle",
name: "Triangle.glb",
kind: "model" as const,
uri:
"data:model/gltf-binary;base64," + triangleGlb(animated).toString("base64"),
});
+140
View File
@@ -0,0 +1,140 @@
import test from "node:test";
import assert from "node:assert/strict";
import { extrude, transformed } from "../engine/geometry.ts";
import { validateGeometry } from "../engine/schema.ts";
function volume(g: any) {
let sum = 0;
const p = g.positions,
ix = g.indices;
for (let i = 0; i < ix.length; i += 3) {
const a = ix[i] * 3,
b = ix[i + 1] * 3,
c = ix[i + 2] * 3;
sum +=
(p[a] * (p[b + 1] * p[c + 2] - p[b + 2] * p[c + 1]) +
p[a + 1] * (p[b + 2] * p[c] - p[b] * p[c + 2]) +
p[a + 2] * (p[b] * p[c + 1] - p[b + 1] * p[c])) /
6;
}
return sum;
}
const near = (actual: number, expected: number) =>
assert.ok(
Math.abs(actual - expected) < 1e-5,
"expected " + actual + " to equal " + expected,
);
test("a rectangular extrusion produces a closed solid with correct volume", () => {
const g = extrude(
[
[0, 0],
[3, 0],
[3, 2],
[0, 2],
],
2.5,
);
validateGeometry(g);
near(Math.abs(volume(g)), 15);
});
test("concave profile preserves missing corner for both winding orders", () => {
const profile: [[number, number], ...[number, number][]] = [
[0, 0],
[3, 0],
[3, 1],
[1, 1],
[1, 3],
[0, 3],
];
for (const points of [profile, [...profile].reverse()]) {
const g = extrude(points, 2);
validateGeometry(g);
near(Math.abs(volume(g)), 10);
const p = g.positions;
for (let i = 0; i < g.indices.length; i += 3) {
const ids = g.indices.slice(i, i + 3).map((v: number) => v * 3);
const ys = ids.map((v: number) => p[v + 1]);
if (Math.max(...ys) - Math.min(...ys) > 1e-6) continue;
const x = ids.reduce((s: number, j: number) => s + p[j], 0) / 3,
z = ids.reduce((s: number, j: number) => s + p[j + 2], 0) / 3;
assert.ok(
!(x > 1 + 1e-6 && z > 1 + 1e-6),
"a cap triangle fills the concave cutout",
);
}
}
});
test("mirroring geometry preserves outward triangle orientation", () => {
const g = extrude(
[
[0, 0],
[2, 0],
[2, 1],
[0, 1],
],
1,
);
const mirrored = transformed(g, [7, 2, -3], [-2, 3, 4]);
validateGeometry(mirrored);
near(volume(mirrored), volume(g) * 24);
});
test("mesh validator rejects corrupt or non-finite input before runtime upload", () => {
const good = { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] };
validateGeometry(good);
for (const bad of [
{ ...good, positions: [0, 0, 0, 1, 0, 0, 0, 1, Infinity] },
{ ...good, positions: [0, 0, 0, 1] },
{ ...good, indices: [0, 1, 3] },
{ ...good, indices: [0, -1, 2] },
{ ...good, indices: [0, 0.5, 2] },
{ ...good, indices: [0, 1] },
{ ...good, normals: [0, 1, 0] },
{ ...good, uvs: [0, 0] },
])
assert.throws(() => validateGeometry(bad as any));
});
test("extrusion rejects degenerate profiles and nonpositive height", () => {
for (const [profile, depth] of [
[
[
[0, 0],
[1, 0],
],
1,
],
[
[
[0, 0],
[1, 0],
[2, 0],
],
1,
],
[
[
[0, 0],
[1, 0],
[0, 1],
],
0,
],
[
[
[0, 0],
[1, 0],
[0, 1],
],
-1,
],
[
[
[0, 0],
[1, 0],
[0, NaN],
],
1,
],
] as any[])
assert.throws(() => extrude(profile, depth));
});
+186
View File
@@ -0,0 +1,186 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { createService } from "../server/index.ts";
import { unpackProject } from "../engine/archive.ts";
import { unzipSync } from "fflate";
import { triangleGlb } from "./fixtures.ts";
test("real MCP HTTP + stdio share revisions, models, scripts, resources and exports", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "forma-test-"));
const s = await createService({ projectDir: dir, port: 0, blank: true });
const client = new Client({ name: "acceptance", version: "1" });
let bridge: Client | undefined;
try {
const url = "http://127.0.0.1:" + s.port + "/mcp";
const unauthorized = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
});
assert.equal(unauthorized.status, 401);
const origin = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
origin: "https://untrusted.example",
authorization: "Bearer " + s.token,
},
body: "{}",
});
assert.equal(origin.status, 403);
await client.connect(
new StreamableHTTPClientTransport(new URL(url), {
requestInit: { headers: { authorization: "Bearer " + s.token } },
}),
);
const tools = await client.listTools();
assert.ok(tools.tools.length >= 25);
assert.ok(tools.tools.some((t) => t.name === "runtime_capture"));
const page = await fetch("http://127.0.0.1:" + s.port + "/").then((r) =>
r.text(),
);
for (const asset of ["/studio/editor.js", "/studio/editor.css"]) {
assert.ok(page.includes(asset));
assert.equal(
(await fetch("http://127.0.0.1:" + s.port + asset)).status,
200,
);
}
const toggle = async (enabled: boolean) =>
fetch("http://127.0.0.1:" + s.port + "/api/mcp", {
method: "POST",
headers: {
"content-type": "application/json",
origin: "http://127.0.0.1:" + s.port,
},
body: JSON.stringify({ enabled }),
});
assert.equal((await toggle(false)).status, 200);
await assert.rejects(() =>
client.callTool({ name: "project_read", arguments: {} }),
);
assert.equal((await toggle(true)).status, 200);
const resources = await client.listResources();
assert.equal(resources.resources.length, 3);
assert.ok(
(await client.readResource({ uri: "forma://reference/scripts" })).contents
.length,
);
const prompts = await client.listPrompts();
assert.deepEqual(prompts.prompts.map((p) => p.name), ["create_scene"]);
const prompt = await client.getPrompt({ name: "create_scene", arguments: { theme: "architecture" } });
assert.match(JSON.stringify(prompt.messages), /architecture/);
const invoke = async (name: string, args: any = {}) => {
const r = await client.callTool({ name, arguments: args });
if (r.isError) throw Error(JSON.stringify(r.content));
return r.structuredContent as any;
};
let p = await invoke("project_read");
assert.equal(p.revision, 0);
assert.equal(p.assets.length, 0);
assert.equal(p.scripts.length, 0);
assert.equal(s.service.store.project.scenes[0].entities.length, 0);
await invoke("model_generate", {
kind: "arena",
expectedRevision: 0,
seed: 41,
obstacles: 3,
requestId: "arena-1",
});
assert.equal(s.service.store.project.revision, 1);
assert.ok(s.service.store.project.scenes[0].entities.length > 20);
const stale = await client.callTool({
name: "node_create",
arguments: { name: "stale", expectedRevision: 0 },
});
assert.equal(stale.isError, true);
assert.equal(s.service.store.project.revision, 1);
await invoke("model_generate", {
kind: "arena",
expectedRevision: 0,
seed: 41,
obstacles: 3,
requestId: "arena-1",
});
assert.equal(s.service.store.project.revision, 1);
const bytes = triangleGlb(true);
await invoke("asset_import_glb", {
expectedRevision: 1,
name: "test.glb",
base64: bytes.toString("base64"),
instantiate: true,
});
assert.equal(s.service.store.project.revision, 2);
bridge = new Client({ name: "stdio-acceptance", version: "1" });
await bridge.connect(
new StdioClientTransport({
command: process.execPath,
args: [
fileURLToPath(new URL("../server/stdio.mjs", import.meta.url)),
"--project",
dir,
"--url",
url,
],
cwd: os.tmpdir(),
stderr: "pipe",
}),
);
assert.equal((await bridge.listTools()).tools.length, tools.tools.length);
const change = await bridge.callTool({
name: "node_create",
arguments: {
name: "From stdio",
id: "stdio_object",
expectedRevision: 2,
components: { mesh: { type: "sphere", size: [1, 1, 1] } },
},
});
assert.equal(change.isError, undefined);
assert.equal(s.service.store.project.revision, 3);
assert.ok(
s.service.store.project.scenes[0].entities.some(
(n) => n.id === "stdio_object",
),
);
await invoke("history_undo", { expectedRevision: 3 });
assert.equal(s.service.store.project.revision, 4);
assert.ok(
!s.service.store.project.scenes[0].entities.some(
(n) => n.id === "stdio_object",
),
);
await invoke("history_redo", { expectedRevision: 4 });
const save = await invoke("project_save");
assert.equal(
unpackProject(new Uint8Array(await readFile(save.path))).revision,
5,
);
const exported = await invoke("project_export_web");
const files = unzipSync(new Uint8Array(await readFile(exported.path)));
assert.ok(files["index.html"] && files["player.js"] && files["player.css"]);
assert.ok(files["player.js"].length > 100000);
assert.equal(
JSON.parse(new TextDecoder().decode(files["project.forma.json"]))
.revision,
5,
);
const noEditor = await client.callTool({
name: "runtime_play",
arguments: {},
});
assert.equal(noEditor.isError, true);
assert.match(JSON.stringify(noEditor.content), /EDITOR_DISCONNECTED/);
} finally {
await bridge?.close();
await client.close();
await s.close();
await rm(dir, { recursive: true, force: true });
}
});
+222
View File
@@ -0,0 +1,222 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Worker as NodeWorker } from "node:worker_threads";
import { NullEngine } from "@babylonjs/core";
import { FormaRuntime } from "../engine/runtime.ts";
import { defaultProject } from "../engine/templates.ts";
import { entity, activeScene } from "../engine/schema.ts";
import { triangleAsset } from "./fixtures.ts";
function worker(source: string) {
const w = new NodeWorker(
'const {parentPort}=require("node:worker_threads");global.self=global;global.postMessage=m=>parentPort.postMessage(m);' +
source +
';parentPort.on("message",data=>self.onmessage({data}));',
{ eval: true },
);
const adapter: any = {
onmessage: null,
onerror: null,
postMessage: (m: any) => w.postMessage(m),
terminate: () => void w.terminate(),
};
w.on("message", (m) => adapter.onmessage?.({ data: m }));
w.on("error", (e) => adapter.onerror?.({ message: e.message }));
return adapter as Worker;
}
function runtime() {
const engine = new NullEngine({
renderWidth: 800,
renderHeight: 600,
textureSize: 512,
deterministicLockstep: true,
lockstepMaxSteps: 4,
});
return new FormaRuntime(
{} as HTMLCanvasElement,
{},
{
engine,
headless: true,
createWorker: worker,
readAsset: async (uri) =>
new Uint8Array(Buffer.from(uri.slice(uri.indexOf(",") + 1), "base64")),
},
);
}
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
test(
"Babylon skeletal import, worker movement, Rapier collision and Stop restoration",
{ timeout: 15000 },
async () => {
const r = runtime(),
p = defaultProject();
p.assets = [triangleAsset(true)];
p.scripts = [
{
id: "move_fixture",
name: "Move fixture",
fields: { speed: { type: "number", default: 4 } },
source:
'({start(api){api.state.ticks=0;api.animate("Translate")},update(api,dt){api.state.ticks++;api.move([api.input.x*api.params.speed*dt,0,0]);api.patch(api.get().id,{components:{data:{ticks:api.state.ticks}}})}})',
},
];
const moving = entity(
"Moving triangle",
{
mesh: { type: "model", assetId: "asset_triangle" },
collider: {
shape: "capsule",
radius: 0.3,
height: 1.8,
offset: [0, 0.9, 0],
},
rigidbody: { type: "kinematic" },
script: { scriptId: "move_fixture" },
data: { ticks: 0 },
},
[0, 0.06, 0],
"moving",
);
activeScene(p).entities = [
entity(
"Floor",
{
mesh: { type: "box", size: [12, 0.5, 12] },
collider: { shape: "box", size: [12, 0.5, 12] },
rigidbody: { type: "fixed" },
},
[0, -0.25, 0],
"floor",
),
entity(
"Wall",
{
mesh: { type: "box", size: [0.4, 3, 12] },
collider: { shape: "box", size: [0.4, 3, 12] },
rigidbody: { type: "fixed" },
},
[2, 1.5, 0],
"wall",
),
moving,
entity(
"Camera",
{ camera: { targetId: "moving", offset: [0, 13, -10], fov: 0.72 } },
[0, 13, -10],
"camera",
),
];
try {
await r.play(p);
assert.equal(r.importInfo.get("asset_triangle").skeletons, 1);
assert.deepEqual(r.importInfo.get("asset_triangle").clips, ["Translate"]);
assert.equal(r.animations.get("moving")!.length, 1);
r.setInput({ x: 1, durationMs: 1400 });
await wait(1500);
const snap = r.snapshot();
const current = snap.entities.find((n) => n.id === "moving")!;
assert.ok(current.components.data.ticks > 10, JSON.stringify(snap.logs));
assert.ok(
snap.animations.moving.some(
(g) => g.name === "Translate" && g.frame !== null,
),
);
assert.ok(
!snap.logs.some((l) => l.level === "error"),
JSON.stringify(snap.logs),
);
const pos = current.transform.position;
assert.ok(
pos[0] > 1.1 && pos[0] < 1.55,
"Kinematic body stops at the wall: " + pos,
);
assert.ok(
pos[1] > -0.2 && pos[1] < 0.3,
"Kinematic body remains on floor: " + pos,
);
await r.stop(p);
assert.deepEqual(
r.state.find((n) => n.id === "moving")!.transform.position,
moving.transform.position,
);
assert.equal(
r.state.find((n) => n.id === "moving")!.components.data.ticks,
0,
);
assert.equal(r.playing, false);
} finally {
r.dispose();
}
},
);
test(
"parent rebuild preserves children; queued model replacement uses fresh bytes",
{ timeout: 15000 },
async () => {
const r = runtime(),
p = defaultProject();
p.assets = [triangleAsset(true)];
const parent = entity("Parent", {}, [0, 0, 0], "parent"),
child = entity(
"Child",
{ mesh: { type: "box", size: [1, 1, 1] } },
[1, 0, 0],
"child",
);
child.parentId = parent.id;
activeScene(p).entities = [parent, child];
try {
await r.load(p);
const childNode = r.nodes.get("child")!;
parent.components.mesh = { type: "sphere", size: [1, 1, 1] };
await r.load(p);
assert.equal(childNode.isDisposed(), false);
assert.equal(childNode.parent, r.nodes.get("parent"));
const n = entity(
"Model",
{ mesh: { type: "model", assetId: "asset_triangle" } },
[0, 0, 0],
"model",
);
activeScene(p).entities.push(n);
await r.load(p);
assert.equal(r.animations.get("model")!.length, 1);
p.assets[0] = triangleAsset();
await r.load(p);
assert.equal(r.animations.get("model")!.length, 0);
assert.ok(
!r.logs.some((l) => l.level === "error"),
JSON.stringify(r.logs),
);
} finally {
r.dispose();
}
},
);
test(
"runaway project script is terminated while runtime remains recoverable",
{ timeout: 10000 },
async () => {
const r = runtime(),
p = defaultProject(true);
p.scripts.push({
id: "bad_loop",
name: "Bad loop fixture",
source: "({update(){while(true){}}})",
fields: {},
});
activeScene(p).entities = [
entity("Loop", { script: { scriptId: "bad_loop" } }, [0, 0, 0], "loop"),
];
try {
await r.play(p);
await wait(2100);
assert.ok(r.logs.some((l) => l.message.includes("1500")));
await r.stop(p);
assert.equal(r.playing, false);
assert.equal(r.state.length, 1);
} finally {
r.dispose();
}
},
);
+276
View File
@@ -0,0 +1,276 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ProjectStore } from "../engine/store.ts";
import { validateProject } from "../engine/schema.ts";
import { project, node, scene, findNode } from "./fixtures.ts";
const add = (
id: string,
parentId: string | null = null,
components: Record<string, any> = {},
) => ({ op: "node.create", args: { entity: node(id, parentId, components) } });
const rename = (name: string) => ({ op: "project.rename", args: { name } });
test("a failed multi-command transaction rolls back every preceding mutation", () => {
const store = new ProjectStore(project([node("existing")]));
const before = structuredClone(store.project);
assert.throws(() =>
store.transaction({
commands: [
rename("Should not persist"),
add("new_object"),
{ op: "node.reparent", args: { id: "existing", parentId: "missing" } },
],
expectedRevision: 0,
}),
);
assert.deepEqual(store.project, before);
});
test("stale revisions cannot overwrite a newer editor change", () => {
const store = new ProjectStore(project());
store.transaction({ commands: [rename("Current")], expectedRevision: 0 });
assert.equal(store.project.revision, 1);
assert.throws(() =>
store.transaction({ commands: [rename("Stale")], expectedRevision: 0 }),
);
assert.equal(store.project.name, "Current");
assert.equal(store.project.revision, 1);
});
test("undo and redo restore complete edits with monotonic revisions", () => {
const store = new ProjectStore(project());
store.transaction({
commands: [add("root"), add("child", "root")],
expectedRevision: 0,
});
assert.equal(scene(store.project).entities.length, 2);
const revision = store.project.revision;
store.undo();
assert.equal(scene(store.project).entities.length, 0);
assert.ok(store.project.revision > revision);
const undoneRevision = store.project.revision;
store.redo();
assert.equal(findNode(store.project, "child").parentId, "root");
assert.ok(store.project.revision > undoneRevision);
validateProject(store.project);
});
test("a retry of an already accepted request cannot duplicate objects", () => {
const store = new ProjectStore(project());
const tx = {
commands: [add("only_once")],
expectedRevision: 0,
requestId: "request_123",
};
store.transaction(tx);
store.transaction(tx);
assert.equal(store.project.revision, 1);
assert.equal(scene(store.project).entities.length, 1);
});
test("a fresh edit after undo discards redo history", () => {
const store = new ProjectStore(project());
store.transaction({ commands: [rename("First")] });
store.transaction({ commands: [rename("Second")] });
store.undo();
store.transaction({ commands: [rename("Branch")] });
const before = structuredClone(store.project);
try {
store.redo();
} catch {}
assert.deepEqual(store.project, before);
});
test("hierarchy cycles are rejected atomically, including indirect cycles", () => {
const store = new ProjectStore(
project([node("a"), node("b", "a"), node("c", "b")]),
);
const before = structuredClone(store.project);
assert.throws(() =>
store.transaction({
commands: [{ op: "node.reparent", args: { id: "a", parentId: "c" } }],
}),
);
assert.deepEqual(store.project, before);
assert.throws(() =>
store.transaction({
commands: [{ op: "node.reparent", args: { id: "b", parentId: "b" } }],
}),
);
});
test("deleting a hierarchy root removes its full subtree but preserves siblings", () => {
const store = new ProjectStore(
project([node("a"), node("b", "a"), node("c", "b"), node("survivor")]),
);
store.transaction({ commands: [{ op: "node.delete", args: { id: "a" } }] });
assert.deepEqual(
scene(store.project).entities.map((e: any) => e.id),
["survivor"],
);
store.undo();
assert.equal(scene(store.project).entities.length, 4);
});
test("duplicate remaps internal hierarchy, camera and entity properties only", () => {
const p = project([
node("root"),
node("child", "root"),
node("outside"),
node("camera", "root", {
camera: { targetId: "child", offset: [0, 10, -8] },
}),
node("behavior", "root", {
script: {
scriptId: "script_refs",
params: { target: "child", external: "outside", literal: "child" },
},
}),
]);
p.scripts = [
{
id: "script_refs",
name: "References",
source: "({ update() {} })",
fields: {
target: { type: "entity", default: "" },
external: { type: "entity", default: "" },
literal: { type: "string", default: "" },
},
},
];
const store = new ProjectStore(p);
const oldIds = new Set(scene(store.project).entities.map((e: any) => e.id));
store.transaction({
commands: [{ op: "node.duplicate", args: { id: "root" } }],
});
const copies = scene(store.project).entities.filter(
(e: any) => !oldIds.has(e.id),
);
assert.equal(copies.length, 4);
const copyRoot = copies.find((e: any) => e.parentId === null);
assert.ok(copyRoot);
const copyCamera = copies.find((e: any) => e.components.camera),
copyBehavior = copies.find((e: any) => e.components.script);
const copyChild = copies.find(
(e: any) => e !== copyRoot && !e.components.camera && !e.components.script,
);
assert.ok(copyChild);
for (const e of copies.filter((e: any) => e !== copyRoot))
assert.equal(e.parentId, copyRoot.id);
assert.equal(copyCamera.components.camera.targetId, copyChild.id);
assert.equal(copyBehavior.components.script.params.target, copyChild.id);
assert.equal(copyBehavior.components.script.params.external, "outside");
assert.equal(copyBehavior.components.script.params.literal, "child");
validateProject(store.project);
});
test("schema rejects duplicate node IDs, dangling parents and prototype keys", () => {
assert.throws(() => validateProject(project([node("same"), node("same")])));
assert.throws(() => validateProject(project([node("child", "missing")])));
const p = project([node("a")]);
p.scenes[0].entities[0].components = JSON.parse(
'{"data":{"__proto__":{"admin":true}}}',
);
assert.throws(() => validateProject(p));
assert.equal(({} as any).admin, undefined);
});
test("component type cannot alter the components object prototype", () => {
const store = new ProjectStore(project([node("safe")]));
const before = structuredClone(store.project);
assert.throws(() =>
store.transaction({
commands: [
{
op: "component.set",
args: {
id: "safe",
type: "__proto__",
value: { mesh: { type: "box", size: [1, 1, 1] } },
},
},
],
}),
);
assert.deepEqual(store.project, before);
assert.equal(
Object.getPrototypeOf(findNode(store.project, "safe").components),
Object.prototype,
);
});
test("prefab instances remap internal references independently from their source", () => {
const p = project([
node("root"),
node("child", "root"),
node("camera", "root", {
camera: { targetId: "child", offset: [0, 10, -8] },
}),
node("behavior", "root", {
script: { scriptId: "target_default", params: {} },
}),
]);
p.scripts = [
{
id: "target_default",
name: "Default target",
source: "({ update() {} })",
fields: { target: { type: "entity", default: "child" } },
},
];
const store = new ProjectStore(p);
store.transaction({
commands: [
{ op: "prefab.create", args: { id: "root", assetId: "prefab_test" } },
],
});
const originalIds = new Set(
scene(store.project).entities.map((e: any) => e.id),
);
store.transaction({
commands: [
{
op: "prefab.instantiate",
args: { assetId: "prefab_test", position: [4, 0, 2] },
},
],
});
const copies = scene(store.project).entities.filter(
(e: any) => !originalIds.has(e.id),
);
assert.equal(copies.length, 4);
const root = copies.find((e: any) => e.parentId === null),
child = copies.find(
(e: any) =>
e.parentId !== null && !e.components.camera && !e.components.script,
);
assert.deepEqual(root.transform.position, [4, 0, 2]);
assert.equal(
copies.find((e: any) => e.components.camera).components.camera.targetId,
child.id,
);
assert.equal(
copies.find((e: any) => e.components.script).components.script.params
.target,
child.id,
);
assert.equal(
findNode(store.project, "camera").components.camera.targetId,
"child",
);
assert.equal(store.project.scripts[0].fields.target.default, "child");
validateProject(store.project);
});
test("deleting an in-use asset cannot leave broken scene references", () => {
const p = project([node("model", null, { mesh: { assetId: "triangle" } })]);
p.assets = [
{
id: "triangle",
name: "Triangle",
kind: "geometry",
geometry: { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] },
},
];
const store = new ProjectStore(p),
before = structuredClone(store.project);
assert.throws(() =>
store.transaction({
commands: [{ op: "asset.delete", args: { id: "triangle" } }],
}),
);
assert.deepEqual(store.project, before);
});
+17
View File
@@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { builtinScripts, defaultProject } from "../engine/templates.ts";
import { activeScene, validateProject } from "../engine/schema.ts";
test("new projects are independent empty scenes with no bundled game content", () => {
const first = defaultProject();
const second = defaultProject(false);
validateProject(first);
validateProject(second);
assert.notEqual(first.id, second.id);
assert.notEqual(first.activeSceneId, second.activeSceneId);
assert.equal(activeScene(first).entities.length, 0);
assert.deepEqual(first.assets, []);
assert.deepEqual(first.scripts, []);
assert.deepEqual(builtinScripts(), []);
});
+102
View File
@@ -0,0 +1,102 @@
import test from "node:test";
import assert from "node:assert/strict";
import vm from "node:vm";
import { workerSource } from "../engine/script-host.ts";
import { entity } from "../engine/schema.ts";
function worker() {
const messages: any[] = [];
const context = vm.createContext({
self: {},
postMessage: (m: any) => messages.push(structuredClone(m)),
structuredClone,
Math,
console,
});
vm.runInContext(workerSource, context);
return {
send: (data: any) => {
context.self.onmessage({ data: structuredClone(data) });
return messages.pop();
},
};
}
test("script field defaults, overrides and per-instance state remain independent", () => {
const w = worker();
const scripts = [
{
id: "counter",
name: "Counter",
fields: { step: { type: "number", default: 2 } },
source:
"({start(api){api.state.count=0},update(api){api.state.count+=api.params.step;api.patch(api.get().id,{components:{data:{count:api.state.count}}})}})",
},
];
const a = entity(
"Default",
{ script: { scriptId: "counter" } },
[0, 0, 0],
"a",
);
const b = entity(
"Override",
{ script: { scriptId: "counter", params: { step: 5 } } },
[0, 0, 0],
"b",
);
w.send({ type: "init", entities: [a, b], scripts });
for (let tick = 1; tick <= 3; tick++) {
const frame = w.send({
type: "tick",
entities: [a, b],
input: {},
dt: 0.02,
});
assert.equal(
frame.commands.find((c: any) => c.id === "a").patch.components.data.count,
tick * 2,
);
assert.equal(
frame.commands.find((c: any) => c.id === "b").patch.components.data.count,
tick * 5,
);
}
});
test("newly spawned instances receive start and update; failing behavior is isolated", () => {
const scripts = [
{
id: "good",
name: "Good",
source:
'({start(api){api.state.ticks=0;api.log("start")},update(api){api.state.ticks++;api.log(api.state.ticks)}})',
fields: {},
},
{
id: "bad",
name: "Bad",
source: '({update(){throw Error("fixture failure")}})',
fields: {},
},
],
w = worker(),
a = entity("A", { script: { scriptId: "good" } }, [0, 0, 0], "a");
w.send({ type: "init", entities: [a], scripts });
const b = entity("B", { script: { scriptId: "good" } }, [0, 0, 0], "b"),
bad = entity("Bad", { script: { scriptId: "bad" } }, [0, 0, 0], "bad");
const m = w.send({ type: "tick", entities: [a, b, bad], input: {}, dt: 0.1 });
assert.equal(
m.commands.filter((c: any) => c.id === "b" && c.message === "start").length,
1,
);
assert.ok(m.commands.some((c: any) => c.id === "a" && c.message === "1"));
assert.ok(m.commands.some((c: any) => c.type === "error"));
const second = w.send({
type: "tick",
entities: [a, b, bad],
input: {},
dt: 0.1,
});
assert.ok(
second.commands.some((c: any) => c.id === "b" && c.message === "2"),
);
assert.ok(!second.commands.some((c: any) => c.type === "error"));
});