electron-builder-config.test.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
  2. import { dirname, join, relative, resolve } from "node:path";
  3. import { describe, expect, it } from "vitest";
  4. import { parse } from "yaml";
  5. // F4 (D41 #4/#6) packaging contract: the editor's electron-builder config must
  6. // stay valid, its files globs must cover the compiled entrypoints, and every
  7. // main-process runtime import must resolve inside the production dependency
  8. // closure (so the packaged app's auto-collected node_modules contains it).
  9. const APP_DIR = resolve(process.cwd());
  10. const CONFIG = readFileSync(join(APP_DIR, "electron-builder.yml"), "utf8");
  11. const config = parse(CONFIG) as Record<string, unknown>;
  12. const PACKAGE_JSON = JSON.parse(readFileSync(join(APP_DIR, "package.json"), "utf8")) as {
  13. main?: string;
  14. scripts?: Record<string, string>;
  15. dependencies?: Record<string, string>;
  16. };
  17. function asStringList(value: unknown): string[] {
  18. return Array.isArray(value) ? value.map((v) => String(v)) : [];
  19. }
  20. function escapeRegex(text: string): string {
  21. return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  22. }
  23. /** Minimal glob matcher for electron-builder-style patterns (`**` + `*`). */
  24. function globMatches(pattern: string, relPath: string): boolean {
  25. const parts = pattern.split("/");
  26. const pathParts = relPath.split("/");
  27. const queue = [{ p: 0, q: 0 }];
  28. const seen = new Set<string>();
  29. while (queue.length > 0) {
  30. const { p, q } = queue.pop() as { p: number; q: number };
  31. const key = `${p}:${q}`;
  32. if (seen.has(key)) continue;
  33. seen.add(key);
  34. if (p === parts.length) {
  35. if (q === pathParts.length) return true;
  36. continue;
  37. }
  38. const part = parts[p] ?? "";
  39. if (part === "**") {
  40. queue.push({ p: p + 1, q });
  41. if (q < pathParts.length) queue.push({ p, q: q + 1 });
  42. continue;
  43. }
  44. if (q >= pathParts.length) continue;
  45. const re = new RegExp(`^${part.split("*").map(escapeRegex).join("[^/]*")}$`);
  46. if (re.test(pathParts[q] ?? "")) queue.push({ p: p + 1, q: q + 1 });
  47. }
  48. return false;
  49. }
  50. function walkFiles(dir: string, base: string, out: string[]): string[] {
  51. for (const entry of readdirSync(dir)) {
  52. const abs = join(dir, entry);
  53. const rel = relative(base, abs).split("\\").join("/");
  54. if (statSync(abs).isDirectory()) walkFiles(abs, base, out);
  55. else out.push(rel);
  56. }
  57. return out;
  58. }
  59. function fileList(): string[] {
  60. return walkFiles(join(APP_DIR, "dist"), APP_DIR, []);
  61. }
  62. function productionDeps(): Set<string> {
  63. const closure = new Set<string>();
  64. const visit = (pkgDir: string): void => {
  65. const pkgPath = join(pkgDir, "package.json");
  66. if (!existsSync(pkgPath)) return;
  67. const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { dependencies?: Record<string, string> };
  68. for (const dep of Object.keys(pkg.dependencies ?? {})) {
  69. if (closure.has(dep)) continue;
  70. closure.add(dep);
  71. const found = join(pkgDir, "node_modules", dep);
  72. if (existsSync(found)) visit(found);
  73. }
  74. };
  75. visit(APP_DIR);
  76. return closure;
  77. }
  78. function mainProcessImports(): Map<string, string[]> {
  79. const imports = new Map<string, string[]>();
  80. const source = readFileSync(join(APP_DIR, "dist/main/main.js"), "utf8");
  81. const specs = [...source.matchAll(/^\s*(?:import|export)[^'"`]*?\bfrom\s+["']([^"']+)["']/gm)].map((m) => m[1] ?? "");
  82. specs.push(...[...source.matchAll(/^\s*import\s+["']([^"']+)["']/gm)].map((m) => m[1] ?? ""));
  83. for (const spec of specs) {
  84. if (spec.startsWith(".") || spec.startsWith("node:")) continue;
  85. const top = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0] ?? spec;
  86. if (imports.has(top)) continue;
  87. imports.set(top, []);
  88. try {
  89. findPkgDir(top);
  90. } catch {
  91. imports.set(top, [spec]);
  92. }
  93. }
  94. return imports;
  95. }
  96. /** Node-style node_modules walk upward from the app dir (follows symlinks). */
  97. function findPkgDir(top: string): string {
  98. let dir = APP_DIR;
  99. for (;;) {
  100. const candidate = join(dir, "node_modules", top);
  101. if (existsSync(join(candidate, "package.json"))) return candidate;
  102. const parent = dirname(dir);
  103. if (parent === dir) throw new Error(`cannot find package ${top}`);
  104. dir = parent;
  105. }
  106. }
  107. describe("electron-builder config (F4 editor distribution)", () => {
  108. it("is valid and pins appId/productName/output/targets", () => {
  109. expect(typeof config).toBe("object");
  110. expect(config.appId).toBe("com.nengine.editor");
  111. expect(config.productName).toBe("nEngine Editor");
  112. expect(config.directories).toMatchObject({ output: "dist-app" });
  113. const linux = config.linux as { target?: string[]; category?: string };
  114. expect(asStringList(linux?.target)).toContain("AppImage");
  115. expect(linux?.category).toBe("Development");
  116. const win = config.win as { target?: string[] };
  117. expect(asStringList(win?.target)).toContain("nsis");
  118. const files = asStringList(config.files);
  119. expect(files).toContain("dist/**");
  120. expect(files).toContain("package.json");
  121. });
  122. it("main/scripts point at the packaged layout (dist-app, electron-builder --linux)", () => {
  123. expect(PACKAGE_JSON.main).toBe("dist/main/main.js");
  124. expect(PACKAGE_JSON.scripts?.dist).toBe("electron-builder --linux");
  125. expect(PACKAGE_JSON.scripts?.["dist:win"]).toBe("electron-builder --win");
  126. });
  127. it("files globs cover the compiled entrypoints of a fresh build output", () => {
  128. expect(existsSync(join(APP_DIR, "dist/main/main.js"))).toBe(true);
  129. expect(existsSync(join(APP_DIR, "dist/preload/preload.js"))).toBe(true);
  130. expect(existsSync(join(APP_DIR, "dist/renderer/index.html"))).toBe(true);
  131. const files = asStringList(config.files);
  132. const listing = fileList();
  133. expect(listing).toContain("dist/main/main.js");
  134. expect(listing).toContain("dist/main/mcp-stdio.js");
  135. expect(listing).toContain("dist/preload/preload.js");
  136. expect(listing).toContain("dist/renderer/index.html");
  137. const covered = new Set(listing.filter((rel) => files.some((pattern) => globMatches(pattern, rel))));
  138. expect(covered.has("dist/main/main.js")).toBe(true);
  139. expect(covered.has("dist/main/mcp-stdio.js")).toBe(true);
  140. expect(covered.has("dist/preload/preload.js")).toBe(true);
  141. expect(covered.has("dist/renderer/index.html")).toBe(true);
  142. expect(covered.size).toBeGreaterThan(0);
  143. });
  144. it("main-process runtime imports resolve inside the production dependency closure", () => {
  145. const closure = productionDeps();
  146. const unresolvable = mainProcessImports();
  147. for (const [top, missing] of unresolvable) {
  148. // electron is provided by the Electron runtime binary, not packaged
  149. if (top === "electron") continue;
  150. expect(missing, `cannot resolve ${top} from the compiled main process`).toEqual([]);
  151. expect(closure.has(top), `@nengine/editor must declare ${top} (or a dependency chain to it) as a runtime dependency`).toBe(true);
  152. }
  153. expect(closure.has("vite")).toBe(true);
  154. expect(closure.has("@modelcontextprotocol/sdk")).toBe(true);
  155. });
  156. });