import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; // F4 (D41 #4/#6) packaging contract: the editor's electron-builder config must // stay valid, its files globs must cover the compiled entrypoints, and every // main-process runtime import must resolve inside the production dependency // closure (so the packaged app's auto-collected node_modules contains it). const APP_DIR = resolve(process.cwd()); const CONFIG = readFileSync(join(APP_DIR, "electron-builder.yml"), "utf8"); const config = parse(CONFIG) as Record; const PACKAGE_JSON = JSON.parse(readFileSync(join(APP_DIR, "package.json"), "utf8")) as { main?: string; scripts?: Record; dependencies?: Record; }; function asStringList(value: unknown): string[] { return Array.isArray(value) ? value.map((v) => String(v)) : []; } function escapeRegex(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** Minimal glob matcher for electron-builder-style patterns (`**` + `*`). */ function globMatches(pattern: string, relPath: string): boolean { const parts = pattern.split("/"); const pathParts = relPath.split("/"); const queue = [{ p: 0, q: 0 }]; const seen = new Set(); while (queue.length > 0) { const { p, q } = queue.pop() as { p: number; q: number }; const key = `${p}:${q}`; if (seen.has(key)) continue; seen.add(key); if (p === parts.length) { if (q === pathParts.length) return true; continue; } const part = parts[p] ?? ""; if (part === "**") { queue.push({ p: p + 1, q }); if (q < pathParts.length) queue.push({ p, q: q + 1 }); continue; } if (q >= pathParts.length) continue; const re = new RegExp(`^${part.split("*").map(escapeRegex).join("[^/]*")}$`); if (re.test(pathParts[q] ?? "")) queue.push({ p: p + 1, q: q + 1 }); } return false; } function walkFiles(dir: string, base: string, out: string[]): string[] { for (const entry of readdirSync(dir)) { const abs = join(dir, entry); const rel = relative(base, abs).split("\\").join("/"); if (statSync(abs).isDirectory()) walkFiles(abs, base, out); else out.push(rel); } return out; } function fileList(): string[] { return walkFiles(join(APP_DIR, "dist"), APP_DIR, []); } function productionDeps(): Set { const closure = new Set(); const visit = (pkgDir: string): void => { const pkgPath = join(pkgDir, "package.json"); if (!existsSync(pkgPath)) return; const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { dependencies?: Record }; for (const dep of Object.keys(pkg.dependencies ?? {})) { if (closure.has(dep)) continue; closure.add(dep); const found = join(pkgDir, "node_modules", dep); if (existsSync(found)) visit(found); } }; visit(APP_DIR); return closure; } function mainProcessImports(): Map { const imports = new Map(); const source = readFileSync(join(APP_DIR, "dist/main/main.js"), "utf8"); const specs = [...source.matchAll(/^\s*(?:import|export)[^'"`]*?\bfrom\s+["']([^"']+)["']/gm)].map((m) => m[1] ?? ""); specs.push(...[...source.matchAll(/^\s*import\s+["']([^"']+)["']/gm)].map((m) => m[1] ?? "")); for (const spec of specs) { if (spec.startsWith(".") || spec.startsWith("node:")) continue; const top = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0] ?? spec; if (imports.has(top)) continue; imports.set(top, []); try { findPkgDir(top); } catch { imports.set(top, [spec]); } } return imports; } /** Node-style node_modules walk upward from the app dir (follows symlinks). */ function findPkgDir(top: string): string { let dir = APP_DIR; for (;;) { const candidate = join(dir, "node_modules", top); if (existsSync(join(candidate, "package.json"))) return candidate; const parent = dirname(dir); if (parent === dir) throw new Error(`cannot find package ${top}`); dir = parent; } } describe("electron-builder config (F4 editor distribution)", () => { it("is valid and pins appId/productName/output/targets", () => { expect(typeof config).toBe("object"); expect(config.appId).toBe("com.nengine.editor"); expect(config.productName).toBe("nEngine Editor"); expect(config.directories).toMatchObject({ output: "dist-app" }); const linux = config.linux as { target?: string[]; category?: string }; expect(asStringList(linux?.target)).toContain("AppImage"); expect(linux?.category).toBe("Development"); const win = config.win as { target?: string[] }; expect(asStringList(win?.target)).toContain("nsis"); const files = asStringList(config.files); expect(files).toContain("dist/**"); expect(files).toContain("package.json"); }); it("main/scripts point at the packaged layout (dist-app, electron-builder --linux)", () => { expect(PACKAGE_JSON.main).toBe("dist/main/main.js"); expect(PACKAGE_JSON.scripts?.dist).toBe("electron-builder --linux"); expect(PACKAGE_JSON.scripts?.["dist:win"]).toBe("electron-builder --win"); }); it("files globs cover the compiled entrypoints of a fresh build output", () => { expect(existsSync(join(APP_DIR, "dist/main/main.js"))).toBe(true); expect(existsSync(join(APP_DIR, "dist/preload/preload.js"))).toBe(true); expect(existsSync(join(APP_DIR, "dist/renderer/index.html"))).toBe(true); const files = asStringList(config.files); const listing = fileList(); expect(listing).toContain("dist/main/main.js"); expect(listing).toContain("dist/main/mcp-stdio.js"); expect(listing).toContain("dist/preload/preload.js"); expect(listing).toContain("dist/renderer/index.html"); const covered = new Set(listing.filter((rel) => files.some((pattern) => globMatches(pattern, rel)))); expect(covered.has("dist/main/main.js")).toBe(true); expect(covered.has("dist/main/mcp-stdio.js")).toBe(true); expect(covered.has("dist/preload/preload.js")).toBe(true); expect(covered.has("dist/renderer/index.html")).toBe(true); expect(covered.size).toBeGreaterThan(0); }); it("main-process runtime imports resolve inside the production dependency closure", () => { const closure = productionDeps(); const unresolvable = mainProcessImports(); for (const [top, missing] of unresolvable) { // electron is provided by the Electron runtime binary, not packaged if (top === "electron") continue; expect(missing, `cannot resolve ${top} from the compiled main process`).toEqual([]); expect(closure.has(top), `@nengine/editor must declare ${top} (or a dependency chain to it) as a runtime dependency`).toBe(true); } expect(closure.has("vite")).toBe(true); expect(closure.has("@modelcontextprotocol/sdk")).toBe(true); }); });