Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user