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