188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
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 });
|
|
}
|
|
});
|