| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- 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<string, unknown>;
- const PACKAGE_JSON = JSON.parse(readFileSync(join(APP_DIR, "package.json"), "utf8")) as {
- main?: string;
- scripts?: Record<string, string>;
- dependencies?: Record<string, string>;
- };
- 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<string>();
- 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<string> {
- const closure = new Set<string>();
- 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<string, string> };
- 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<string, string[]> {
- const imports = new Map<string, string[]>();
- 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);
- });
- });
|