import { mkdtemp, readFile, rm, writeFile, mkdir, stat, readdir } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { assetByteSize, createEngine, type ContentBundle, type IntentSystem, type TimeSystem } from "@nengine/core";
import { DEFAULT_OUT_DIR, compileBundle, packGame, parsePackArgs, splitBundleAssets, USAGE } from "../src/index.js";
import { DEFAULT_PRODUCT_NAME, generateDesktop } from "../src/desktop.js";
const repoRoot = resolve(process.cwd(), "../..");
const sampleContentDir = resolve(repoRoot, "examples", "sample-game", "content");
const sampleModsDir = resolve(repoRoot, "examples", "sample-game", "mods");
describe("parsePackArgs", () => {
it("resolves --content and --out relative to cwd", () => {
const args = parsePackArgs({ argv: ["--content", "examples/sample-game/content", "--out", "out"], cwd: repoRoot });
expect(args).not.toBeNull();
expect(args?.contentDir).toBe(sampleContentDir);
expect(args?.outDir).toBe(resolve(repoRoot, "out"));
});
it("defaults --out to ./dist-pack", () => {
const args = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
expect(args?.outDir).toBe(resolve(repoRoot, DEFAULT_OUT_DIR));
});
it("returns null for --help", () => {
expect(parsePackArgs({ argv: ["--help"] })).toBeNull();
expect(parsePackArgs({ argv: ["-h"] })).toBeNull();
expect(USAGE).toContain("Usage: nengine-pack");
});
it("throws when --content is missing", () => {
expect(() => parsePackArgs({ argv: [] })).toThrow("--content
is required");
});
it("parses --desktop and --name (D32)", () => {
const args = parsePackArgs({
argv: ["--content", "content", "--desktop", "--name", "Pinefield Valley"],
cwd: repoRoot,
});
expect(args?.desktop).toBe(true);
expect(args?.name).toBe("Pinefield Valley");
const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
expect(plain?.desktop).toBe(false);
expect(plain?.name).toBeUndefined();
expect(USAGE).toContain("--desktop");
expect(USAGE).toContain("--name");
});
it("parses --mods and resolves it relative to cwd (D36)", () => {
const args = parsePackArgs({ argv: ["--content", "content", "--mods", "examples/sample-game/mods"], cwd: repoRoot });
expect(args?.modsDir).toBe(sampleModsDir);
const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
expect(plain?.modsDir).toBeUndefined();
expect(USAGE).toContain("--mods ");
});
it("--installer implies --desktop (D36)", () => {
const args = parsePackArgs({ argv: ["--content", "content", "--installer"], cwd: repoRoot });
expect(args?.installer).toBe(true);
expect(args?.desktop).toBe(true);
const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
expect(plain?.installer).toBe(false);
expect(plain?.desktop).toBe(false);
expect(USAGE).toContain("--installer");
});
it("parses --split-assets (D37) with the default staying inline", () => {
const args = parsePackArgs({ argv: ["--content", "content", "--split-assets"], cwd: repoRoot });
expect(args?.splitAssets).toBe(true);
expect(USAGE).toContain("--split-assets");
const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
expect(plain?.splitAssets).toBe(false);
});
});
describe("desktop packaging (D32)", () => {
it("generateDesktop writes a runnable shell: valid package.json JSON + main.cjs wiring + node --check", async () => {
const out = await mkdtemp(join(tmpdir(), "nengine-desktop-"));
try {
const result = await generateDesktop(out, { productName: "Pinefield Valley" });
expect(result.dir).toBe(join(out, "desktop"));
expect(existsSync(join(out, "desktop", "main.cjs"))).toBe(true);
expect(existsSync(join(out, "desktop", "package.json"))).toBe(true);
expect(existsSync(join(out, "desktop", "README-desktop.md"))).toBe(true);
const pkg = JSON.parse(await readFile(result.packageFile, "utf8"));
expect(pkg.name).toBe("pinefield-valley");
expect(pkg.main).toBe("main.cjs");
expect(pkg.scripts.start).toBe("electron .");
expect(pkg.devDependencies.electron).toMatch(/^\^\d+\./);
const main = await readFile(result.mainFile, "utf8");
expect(main).toContain("createServer");
expect(main).toContain("127.0.0.1");
expect(main).toContain("loadURL");
expect(main).toContain("Pinefield Valley");
expect(main).toContain("contextIsolation: true");
expect(main).toContain("nodeIntegration: false");
expect(main).toContain("window-all-closed");
expect(main).toContain('resolve(__dirname, "..")');
execFileSync(process.execPath, ["--check", result.mainFile], { stdio: "pipe" });
} finally {
await rm(out, { recursive: true, force: true });
}
});
it("generateDesktop defaults the product name when --name is absent", async () => {
const out = await mkdtemp(join(tmpdir(), "nengine-desktop-"));
try {
const result = await generateDesktop(out);
const main = await readFile(result.mainFile, "utf8");
expect(main).toContain(DEFAULT_PRODUCT_NAME);
} finally {
await rm(out, { recursive: true, force: true });
}
});
it("packGame --desktop writes the desktop app alongside the static site", async () => {
const out = await mkdtemp(join(tmpdir(), "nengine-pack-desktop-"));
try {
const result = await packGame({
contentDir: sampleContentDir,
outDir: out,
desktop: true,
name: "Pinefield Valley",
});
expect(result.desktopDir).toBe(join(out, "desktop"));
expect(existsSync(join(out, "desktop", "main.cjs"))).toBe(true);
expect(existsSync(join(out, "desktop", "package.json"))).toBe(true);
const pkg = JSON.parse(await readFile(join(out, "desktop", "package.json"), "utf8"));
expect(pkg.name).toBe("pinefield-valley");
} finally {
await rm(out, { recursive: true, force: true });
}
});
it("generateDesktop installer: electron-builder.yml + dist scripts + site files copied (D36)", async () => {
const out = await mkdtemp(join(tmpdir(), "nengine-installer-"));
try {
await mkdir(join(out, "assets"), { recursive: true });
await writeFile(join(out, "index.html"), "", "utf8");
await writeFile(join(out, "bundle.json"), "{}", "utf8");
const result = await generateDesktop(out, { productName: "Pinefield Valley", installer: true });
expect(result.installerFile).toBe(join(out, "desktop", "electron-builder.yml"));
expect(existsSync(result.installerFile as string)).toBe(true);
const yaml = await readFile(result.installerFile as string, "utf8");
expect(yaml).toContain("appId: com.nengine.pinefield-valley");
expect(yaml).toContain("productName: Pinefield Valley");
expect(yaml).toContain("files:");
expect(yaml).toContain("bundle.json");
expect(yaml).toContain("assets/**");
expect(yaml).toContain("linux:");
expect(yaml).toContain("- AppImage");
expect(yaml).toContain("category: Game");
expect(yaml).toContain("win:");
expect(yaml).toContain("- nsis");
expect(yaml).toContain("oneClick: true");
const pkg = JSON.parse(await readFile(result.packageFile, "utf8"));
expect(pkg.scripts.dist).toBe("electron-builder --linux");
expect(pkg.scripts["dist:win"]).toBe("electron-builder --win");
expect(pkg.devDependencies["electron-builder"]).toMatch(/^\^\d+\./);
// installer mode copies the site into desktop/ so electron-builder can
// package it (main.cjs resolves the site as ".." — the app dir once packaged)
expect(existsSync(join(out, "desktop", "index.html"))).toBe(true);
expect(existsSync(join(out, "desktop", "bundle.json"))).toBe(true);
expect(existsSync(join(out, "desktop", "assets"))).toBe(true);
const readme = await readFile(join(out, "desktop", "README-desktop.md"), "utf8");
expect(readme).toContain("Build an installer");
expect(readme).toContain("npm run dist");
} finally {
await rm(out, { recursive: true, force: true });
}
});
});
describe("compileBundle", () => {
it("compiles a valid content dir into the nengine-bundle format", async () => {
const bundle = await compileBundle(sampleContentDir);
expect(bundle.format).toBe("nengine-bundle");
expect(bundle.version).toBe(1);
expect(bundle.content["map"]?.[0]).toMatchObject({ id: "farm", width: 16, height: 16 });
expect(bundle.content["time"]?.[0]).toMatchObject({ id: "default" });
expect(bundle.content["item"]?.some((d) => (d as { id?: string }).id === "hoe")).toBe(true);
expect(bundle.content["sprite"]?.some((d) => (d as { id?: string }).id === "player")).toBe(true);
});
it("collects content/assets/ binaries into bundle.assets as base64 (D31)", async () => {
const bundle = await compileBundle(sampleContentDir);
expect(bundle.assets).toBeDefined();
const ids = Object.keys(bundle.assets ?? {});
expect(ids).toEqual(
expect.arrayContaining(["grass-1", "water-1", "tilled-1", "plant-sprout", "coop-1", "fence-1"]),
);
expect(bundle.assets?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
expect(typeof bundle.assets?.["grass-1"]?.data).toBe("string");
expect(bundle.assets?.["click-1"]).toMatchObject({ kind: "audio", mime: "audio/ogg" });
});
it("inline bundles carry a metadata assetsIndex alongside the base64 assets (D37)", async () => {
const bundle = await compileBundle(sampleContentDir);
expect(bundle.assetsIndex).toBeDefined();
expect(bundle.assetsIndex?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
expect(typeof bundle.assetsIndex?.["grass-1"]?.size).toBe("number");
expect(bundle.assetsIndex?.["grass-1"]?.file).toBe("nengine-assets/grass-1.png");
expect(bundle.assets?.["grass-1"]?.data).toBeDefined();
});
it("splitBundleAssets strips the bytes into a metadata-only assetsIndex (D37)", async () => {
const inline = await compileBundle(sampleContentDir);
const split = splitBundleAssets(inline);
expect(split.assets).toBeUndefined();
expect(split.assetsIndex).toBeDefined();
const grass = split.assetsIndex?.["grass-1"];
expect(grass).toMatchObject({ kind: "image", mime: "image/png", size: assetByteSize(inline.assets?.["grass-1"] as { data: string }) });
expect(grass?.file).toBe("nengine-assets/grass-1.png");
expect(grass?.file?.endsWith(".png")).toBe(true);
expect(split.assetsIndex?.["click-1"]?.file?.endsWith(".ogg")).toBe(true);
// no base64 bytes in the split form
expect(JSON.stringify(split)).not.toContain(";base64,");
// content/mods are untouched
expect(split.content).toEqual(inline.content);
});
it("compileBundle(splitAssets: true) returns the split layout", async () => {
const split = await compileBundle(sampleContentDir, undefined, true);
expect(split.assets).toBeUndefined();
expect(Object.keys(split.assetsIndex ?? {}).length).toBeGreaterThan(0);
expect(split.assetsIndex?.["grass-1"]?.file).toBe("nengine-assets/grass-1.png");
});
it("throws for a missing content dir", async () => {
await expect(compileBundle(resolve(repoRoot, "does-not-exist"))).rejects.toThrow(/validation failed/);
});
it("fails loudly with per-kind/per-id messages for invalid content", async () => {
const tmp = await mkdtemp(join(tmpdir(), "nengine-pack-"));
try {
await mkdir(join(tmp, "maps"), { recursive: true });
await writeFile(join(tmp, "maps", "bad.yaml"), ["id: bad", "width: nope", "height: 2", ""].join("\n"));
await expect(compileBundle(tmp)).rejects.toThrow(/map\/bad/);
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
it("packages a mods dir into bundle.mods with manifest + entry source text (D36)", async () => {
const bundle = await compileBundle(sampleContentDir, sampleModsDir);
expect(bundle.mods).toBeDefined();
const mod = bundle.mods?.["sample-game-mod"];
expect(mod).toBeDefined();
expect(mod?.manifest).toEqual({ id: "sample-game-mod", version: "0.1.0" });
expect(typeof mod?.code).toBe("string");
expect(mod?.code.length).toBeGreaterThan(100);
expect(mod?.code).toContain("onDayStart");
});
it("leaves bundle.mods undefined without --mods (zero regression)", async () => {
const bundle = await compileBundle(sampleContentDir);
expect(bundle.mods).toBeUndefined();
});
it("fails loudly when a mods dir entry is missing or its manifest is invalid (D36)", async () => {
const tmp = await mkdtemp(join(tmpdir(), "nengine-modpack-"));
try {
await mkdir(join(tmp, "no-entry"), { recursive: true });
await mkdir(join(tmp, "no-manifest"), { recursive: true });
await writeFile(join(tmp, "no-manifest", "index.mjs"), "export default { hooks: {} };", "utf8");
await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/mod packaging failed/);
await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/no-entry: no index\.mjs or index\.js entry/);
await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/no-manifest: manifest \{ id, version \} required/);
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
});
describe("packGame integration", () => {
it("builds a static site + bundle.json and the bundle boots via createEngine({bundle})", async () => {
const out = await mkdtemp(join(tmpdir(), "nengine-pack-out-"));
try {
const result = await packGame({ contentDir: sampleContentDir, outDir: out });
expect(existsSync(join(out, "bundle.json"))).toBe(true);
expect(existsSync(join(out, "index.html"))).toBe(true);
expect(existsSync(join(out, "assets"))).toBe(true);
const html = await readFile(join(out, "index.html"), "utf8");
expect(html).toMatch(/src="\.\/assets\/[^"]+\.js"/);
expect(html).not.toContain("apps/dev");
const raw = await readFile(join(out, "bundle.json"), "utf8");
const bundle = JSON.parse(raw) as ContentBundle;
expect(bundle.format).toBe("nengine-bundle");
expect(bundle.version).toBe(1);
expect(Object.keys(bundle.content).length).toBeGreaterThanOrEqual(11);
expect(Object.keys(bundle.assets ?? {}).length).toBeGreaterThan(0);
expect(bundle.assets?.["grass-1"]?.kind).toBe("image");
const engine = createEngine({ bundle });
await engine.start();
try {
const player = engine.world.get("player");
expect(player?.type).toBe("player");
expect(player?.x).toBe(2);
expect(player?.y).toBe(4);
expect(engine.assets.size).toBeGreaterThan(0);
expect(engine.assets.get("grass-1")?.kind).toBe("image");
const time = engine.systems.get("time");
expect(time?.get()).toEqual({ day: 1, season: "spring", tickInDay: 0 });
const intent = engine.systems.get("intent");
if (!intent) throw new Error("intent system not booted");
const interact = intent.handle("interact", { tile: { x: 10, y: 10 } });
expect(interact.ok).toBe(true);
expect(engine.world.get("plot:10:10")?.tilled).toBe(true);
engine.tick(480);
expect(time?.get().day).toBe(2);
// engine.tick is the packaged game's day driver (D23 — the dev
// "Advance day" button steps the same simulation path).
const sow = intent.handle("interact", { tile: { x: 10, y: 10 } });
expect(sow.ok).toBe(true);
expect(sow.detail).toContain("sowed");
expect(engine.world.get("plot:10:10")?.spriteRef).toBe("parsnip-sprout");
expect(intent.handle("interact", { tile: { x: 10, y: 10 } }).detail).toBe("watered plot");
engine.tick(480);
engine.tick(480);
const grown = engine.world.get("plot:10:10") as { stageIndex?: number; spriteRef?: string };
expect(grown?.stageIndex).toBe(1);
expect(grown?.spriteRef).toBe("parsnip-grown");
} finally {
await engine.stop();
}
} finally {
await rm(out, { recursive: true, force: true });
}
});
it("packGame --mods packages Mods into bundle.json and they boot in bundle mode (D36)", async () => {
const out = await mkdtemp(join(tmpdir(), "nengine-pack-mods-"));
try {
const result = await packGame({ contentDir: sampleContentDir, outDir: out, modsDir: sampleModsDir });
expect(result.modCount).toBe(1);
const raw = await readFile(join(out, "bundle.json"), "utf8");
const bundle = JSON.parse(raw) as ContentBundle;
expect(bundle.mods?.["sample-game-mod"]?.manifest).toEqual({ id: "sample-game-mod", version: "0.1.0" });
expect(typeof bundle.mods?.["sample-game-mod"]?.code).toBe("string");
const engine = createEngine({ bundle });
await engine.start();
try {
expect(engine.mods).toHaveLength(1);
expect(engine.mods[0]?.id).toBe("sample-game-mod");
engine.tick(480);
expect(engine.world.get("mod:sample-stats")?.greetings).toBe(1);
expect(engine.world.get("npc:mayor")?.type).toBe("npc");
} finally {
await engine.stop();
}
} finally {
await rm(out, { recursive: true, force: true });
}
});
it("packGame --split-assets writes nengine-assets/ files + a metadata-only bundle.json (D37)", async () => {
const out = await mkdtemp(join(tmpdir(), "nengine-pack-split-"));
try {
const inline = await compileBundle(sampleContentDir);
const result = await packGame({ contentDir: sampleContentDir, outDir: out, splitAssets: true });
expect(result.bundle.assets).toBeUndefined();
expect(result.bundle.assetsIndex).toBeDefined();
const raw = await readFile(join(out, "bundle.json"), "utf8");
const bundle = JSON.parse(raw) as ContentBundle;
expect(bundle.assets).toBeUndefined();
expect(bundle.assetsIndex).toBeDefined();
expect(bundle.assetsIndex?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png", file: "nengine-assets/grass-1.png" });
expect(bundle.assetsIndex?.["click-1"]?.file).toBe("nengine-assets/click-1.ogg");
expect(raw).not.toContain(";base64,");
// sibling files exist and match the source bytes
const nengineAssets = join(out, "nengine-assets");
expect(existsSync(nengineAssets)).toBe(true);
expect(existsSync(join(nengineAssets, "grass-1.png"))).toBe(true);
expect(existsSync(join(nengineAssets, "click-1.ogg"))).toBe(true);
const files = await readdir(nengineAssets);
expect(files.length).toBe(Object.keys(inline.assets ?? {}).length);
expect(files.filter((f) => f.endsWith(".png")).length).toBeGreaterThan(0);
expect(files.filter((f) => f.endsWith(".ogg")).length).toBeGreaterThan(0);
// sizes recorded in the index match the actual split files (and the source)
for (const [id, meta] of Object.entries(bundle.assetsIndex ?? {})) {
const file = join(nengineAssets, meta.file?.split("/")[1] ?? "");
const bytes = (await stat(file)).size;
expect(bytes).toBe(meta.size);
expect(bytes).toBe(assetByteSize(inline.assets?.[id] as { data: string }));
}
// the split bundle still boots via createEngine (metadata-only asset table)
const engine = createEngine({ bundle });
await engine.start();
try {
expect(engine.assets.get("grass-1")).toMatchObject({ kind: "image", mime: "image/png" });
expect(engine.assets.get("grass-1")?.data).toBeUndefined();
expect(engine.assets.get("grass-1")?.file).toBe("nengine-assets/grass-1.png");
} finally {
await engine.stop();
}
} finally {
await rm(out, { recursive: true, force: true });
}
});
});