Files

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 });
}
});