@simplysm/sd-cli 14.1.8 → 14.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/dist/capacitor/capacitor-icon.js +2 -2
  2. package/dist/capacitor/capacitor-icon.js.map +1 -1
  3. package/dist/capacitor/capacitor-npm-config.d.ts +1 -1
  4. package/dist/capacitor/capacitor-npm-config.d.ts.map +1 -1
  5. package/dist/capacitor/capacitor-npm-config.js +11 -18
  6. package/dist/capacitor/capacitor-npm-config.js.map +1 -1
  7. package/dist/capacitor/capacitor.d.ts +1 -1
  8. package/dist/capacitor/capacitor.js +2 -2
  9. package/dist/capacitor/capacitor.js.map +1 -1
  10. package/dist/commands/check.d.ts.map +1 -1
  11. package/dist/commands/check.js +1 -0
  12. package/dist/commands/check.js.map +1 -1
  13. package/dist/commands/device.js +2 -2
  14. package/dist/commands/device.js.map +1 -1
  15. package/dist/commands/init/generators/client.d.ts.map +1 -1
  16. package/dist/commands/init/generators/client.js +1 -0
  17. package/dist/commands/init/generators/client.js.map +1 -1
  18. package/dist/commands/init/generators/root.d.ts.map +1 -1
  19. package/dist/commands/init/generators/root.js +0 -1
  20. package/dist/commands/init/generators/root.js.map +1 -1
  21. package/dist/commands/init/init-client.js +1 -1
  22. package/dist/commands/init/init-client.js.map +1 -1
  23. package/dist/commands/init/init.js +2 -2
  24. package/dist/commands/init/init.js.map +1 -1
  25. package/dist/commands/publish/deployment-phase.d.ts.map +1 -1
  26. package/dist/commands/publish/deployment-phase.js +1 -0
  27. package/dist/commands/publish/deployment-phase.js.map +1 -1
  28. package/dist/commands/publish/npm-publisher.js +3 -3
  29. package/dist/commands/publish/npm-publisher.js.map +1 -1
  30. package/dist/commands/publish/post-publish-phase.d.ts.map +1 -1
  31. package/dist/commands/publish/post-publish-phase.js +1 -0
  32. package/dist/commands/publish/post-publish-phase.js.map +1 -1
  33. package/dist/commands/publish/publish-command.d.ts.map +1 -1
  34. package/dist/commands/publish/publish-command.js +7 -12
  35. package/dist/commands/publish/publish-command.js.map +1 -1
  36. package/dist/deps/replace-deps/collect-deps.js +4 -4
  37. package/dist/deps/replace-deps/collect-deps.js.map +1 -1
  38. package/dist/deps/replace-deps/replace-deps-resolve.d.ts +4 -12
  39. package/dist/deps/replace-deps/replace-deps-resolve.d.ts.map +1 -1
  40. package/dist/deps/replace-deps/replace-deps-resolve.js +13 -49
  41. package/dist/deps/replace-deps/replace-deps-resolve.js.map +1 -1
  42. package/dist/deps/replace-deps/replace-deps.d.ts +2 -2
  43. package/dist/deps/replace-deps/replace-deps.js +3 -3
  44. package/dist/deps/server-externals/server-production-files.d.ts +3 -3
  45. package/dist/deps/server-externals/server-production-files.d.ts.map +1 -1
  46. package/dist/deps/server-externals/server-production-files.js +24 -17
  47. package/dist/deps/server-externals/server-production-files.js.map +1 -1
  48. package/dist/electron/electron.d.ts.map +1 -1
  49. package/dist/electron/electron.js +6 -11
  50. package/dist/electron/electron.js.map +1 -1
  51. package/dist/engines/BaseEngine.d.ts +1 -0
  52. package/dist/engines/BaseEngine.d.ts.map +1 -1
  53. package/dist/engines/BaseEngine.js +2 -1
  54. package/dist/engines/BaseEngine.js.map +1 -1
  55. package/dist/esbuild/esbuild-config.d.ts +6 -2
  56. package/dist/esbuild/esbuild-config.d.ts.map +1 -1
  57. package/dist/esbuild/esbuild-config.js +4 -3
  58. package/dist/esbuild/esbuild-config.js.map +1 -1
  59. package/dist/esbuild/esbuild-tsc-plugin.d.ts.map +1 -1
  60. package/dist/esbuild/esbuild-tsc-plugin.js +3 -1
  61. package/dist/esbuild/esbuild-tsc-plugin.js.map +1 -1
  62. package/dist/orchestrators/BaseOrchestrator.d.ts.map +1 -1
  63. package/dist/orchestrators/BaseOrchestrator.js +1 -0
  64. package/dist/orchestrators/BaseOrchestrator.js.map +1 -1
  65. package/dist/orchestrators/BuildOrchestrator.d.ts.map +1 -1
  66. package/dist/orchestrators/BuildOrchestrator.js +3 -5
  67. package/dist/orchestrators/BuildOrchestrator.js.map +1 -1
  68. package/dist/orchestrators/DevOrchestrator.d.ts.map +1 -1
  69. package/dist/orchestrators/DevOrchestrator.js +1 -0
  70. package/dist/orchestrators/DevOrchestrator.js.map +1 -1
  71. package/dist/orchestrators/ServerRuntimeManager.d.ts.map +1 -1
  72. package/dist/orchestrators/ServerRuntimeManager.js +4 -0
  73. package/dist/orchestrators/ServerRuntimeManager.js.map +1 -1
  74. package/dist/orchestrators/TypecheckOrchestrator.d.ts.map +1 -1
  75. package/dist/orchestrators/TypecheckOrchestrator.js +4 -6
  76. package/dist/orchestrators/TypecheckOrchestrator.js.map +1 -1
  77. package/dist/runtime/engine-watch-events.d.ts.map +1 -1
  78. package/dist/runtime/engine-watch-events.js +5 -0
  79. package/dist/runtime/engine-watch-events.js.map +1 -1
  80. package/dist/runtime/worker-events.d.ts +1 -0
  81. package/dist/runtime/worker-events.d.ts.map +1 -1
  82. package/dist/sd-cli-entry.d.ts.map +1 -1
  83. package/dist/sd-cli-entry.js +0 -4
  84. package/dist/sd-cli-entry.js.map +1 -1
  85. package/dist/sd-cli.js +2 -0
  86. package/dist/sd-cli.js.map +1 -1
  87. package/dist/typecheck/typecheck-non-package.d.ts.map +1 -1
  88. package/dist/typecheck/typecheck-non-package.js +10 -0
  89. package/dist/typecheck/typecheck-non-package.js.map +1 -1
  90. package/dist/utils/package-utils.d.ts +8 -6
  91. package/dist/utils/package-utils.d.ts.map +1 -1
  92. package/dist/utils/package-utils.js +26 -24
  93. package/dist/utils/package-utils.js.map +1 -1
  94. package/dist/utils/workspace-utils.d.ts +17 -0
  95. package/dist/utils/workspace-utils.d.ts.map +1 -0
  96. package/dist/utils/workspace-utils.js +95 -0
  97. package/dist/utils/workspace-utils.js.map +1 -0
  98. package/dist/workers/client.worker.d.ts +1 -0
  99. package/dist/workers/client.worker.d.ts.map +1 -1
  100. package/dist/workers/client.worker.js +8 -3
  101. package/dist/workers/client.worker.js.map +1 -1
  102. package/dist/workers/library-build.worker.d.ts +1 -0
  103. package/dist/workers/library-build.worker.d.ts.map +1 -1
  104. package/dist/workers/library-build.worker.js +3 -2
  105. package/dist/workers/library-build.worker.js.map +1 -1
  106. package/dist/workers/server-build.worker.d.ts +1 -0
  107. package/dist/workers/server-build.worker.d.ts.map +1 -1
  108. package/dist/workers/server-build.worker.js +12 -12
  109. package/dist/workers/server-build.worker.js.map +1 -1
  110. package/dist/workers/server-esbuild-context.d.ts.map +1 -1
  111. package/dist/workers/server-esbuild-context.js +4 -2
  112. package/dist/workers/server-esbuild-context.js.map +1 -1
  113. package/dist/workers/server-runtime.worker.d.ts +1 -0
  114. package/dist/workers/server-runtime.worker.d.ts.map +1 -1
  115. package/dist/workers/server-runtime.worker.js +9 -3
  116. package/dist/workers/server-runtime.worker.js.map +1 -1
  117. package/dist/workers/server-watch-manager.d.ts +1 -1
  118. package/dist/workers/server-watch-manager.d.ts.map +1 -1
  119. package/dist/workers/server-watch-manager.js +2 -2
  120. package/dist/workers/server-watch-manager.js.map +1 -1
  121. package/package.json +11 -11
  122. package/src/capacitor/capacitor-icon.ts +2 -2
  123. package/src/capacitor/capacitor-npm-config.ts +10 -19
  124. package/src/capacitor/capacitor.ts +2 -2
  125. package/src/commands/check.ts +1 -0
  126. package/src/commands/device.ts +2 -2
  127. package/src/commands/init/generators/client.ts +1 -0
  128. package/src/commands/init/generators/root.ts +0 -1
  129. package/src/commands/init/init-client.ts +1 -1
  130. package/src/commands/init/init.ts +2 -2
  131. package/src/commands/init/templates/client/src/sd-env.d.ts +1 -0
  132. package/src/commands/init/templates/workspace-root/mise.toml.hbs +1 -1
  133. package/src/commands/publish/deployment-phase.ts +1 -0
  134. package/src/commands/publish/npm-publisher.ts +3 -3
  135. package/src/commands/publish/post-publish-phase.ts +1 -0
  136. package/src/commands/publish/publish-command.ts +7 -15
  137. package/src/deps/replace-deps/collect-deps.ts +4 -4
  138. package/src/deps/replace-deps/replace-deps-resolve.ts +13 -56
  139. package/src/deps/replace-deps/replace-deps.ts +3 -3
  140. package/src/deps/server-externals/server-production-files.ts +31 -18
  141. package/src/electron/electron.ts +7 -13
  142. package/src/engines/BaseEngine.ts +3 -2
  143. package/src/esbuild/esbuild-config.ts +12 -3
  144. package/src/esbuild/esbuild-tsc-plugin.ts +4 -1
  145. package/src/orchestrators/BaseOrchestrator.ts +1 -0
  146. package/src/orchestrators/BuildOrchestrator.ts +3 -5
  147. package/src/orchestrators/DevOrchestrator.ts +1 -0
  148. package/src/orchestrators/ServerRuntimeManager.ts +4 -0
  149. package/src/orchestrators/TypecheckOrchestrator.ts +4 -6
  150. package/src/runtime/engine-watch-events.ts +7 -1
  151. package/src/runtime/worker-events.ts +1 -0
  152. package/src/sd-cli-entry.ts +0 -9
  153. package/src/sd-cli.ts +2 -0
  154. package/src/typecheck/typecheck-non-package.ts +11 -0
  155. package/src/utils/package-utils.ts +30 -23
  156. package/src/utils/workspace-utils.ts +117 -0
  157. package/src/workers/client.worker.ts +9 -4
  158. package/src/workers/library-build.worker.ts +4 -3
  159. package/src/workers/server-build.worker.ts +13 -13
  160. package/src/workers/server-esbuild-context.ts +5 -2
  161. package/src/workers/server-runtime.worker.ts +10 -3
  162. package/src/workers/server-watch-manager.ts +3 -3
  163. package/tests/capacitor/capacitor-build.spec.ts +142 -142
  164. package/tests/capacitor/capacitor-init.spec.ts +181 -181
  165. package/tests/capacitor/capacitor-npm-config.acc.spec.ts +114 -114
  166. package/tests/capacitor/capacitor-npm-config.spec.ts +94 -94
  167. package/tests/commands/publish-manifest.acc.spec.ts +67 -0
  168. package/tests/deps/replace-deps/collect-deps.acc.spec.ts +16 -1
  169. package/tests/deps/replace-deps/replace-deps-resolve.acc.spec.ts +9 -5
  170. package/tests/deps/replace-deps/replace-deps-setup.acc.spec.ts +3 -3
  171. package/tests/deps/server-externals/server-production-files.spec.ts +68 -0
  172. package/tests/electron/electron.spec.ts +608 -608
  173. package/tests/ts-compiler/fixtures/non-angular-pkg/.cache/typecheck-browser.tsbuildinfo +1 -1
  174. package/tests/ts-compiler/fixtures/non-angular-pkg/.cache/typecheck-node.tsbuildinfo +1 -1
  175. package/tests/ts-compiler/fixtures/non-angular-pkg/.cache/typecheck.tsbuildinfo +1 -1
  176. package/tests/utils/engine-watch-events.spec.ts +17 -0
  177. package/tests/utils/esbuild-config.spec.ts +15 -0
  178. package/tests/utils/package-utils.spec.ts +36 -4
  179. package/tests/utils/replace-deps-watch.acc.spec.ts +4 -4
  180. package/tests/utils/replace-deps-watch.spec.ts +3 -3
  181. package/tests/utils/replace-deps.spec.ts +1 -35
  182. package/tests/utils/workspace-utils.spec.ts +87 -0
  183. package/tests/workers/library-build-worker.spec.ts +1 -1
  184. package/tests/workers/server-esbuild-context.spec.ts +4 -3
  185. package/dist/commands/reinstall.d.ts +0 -13
  186. package/dist/commands/reinstall.d.ts.map +0 -1
  187. package/dist/commands/reinstall.js +0 -56
  188. package/dist/commands/reinstall.js.map +0 -1
  189. package/src/commands/init/templates/workspace-root/pnpm-workspace.yaml +0 -5
  190. package/src/commands/reinstall.ts +0 -63
  191. package/tests/angular/angular-compiler-hmr-removal.verify.md +0 -16
  192. package/tests/angular/onbuild-lint-removal.verify.md +0 -8
  193. package/tests/angular/vite-angular-plugin-sdtscompiler.verify.md +0 -13
  194. package/tests/angular/vite-angular-plugin-vitest.verify.md +0 -20
  195. package/tests/capacitor/capacitor-android-exports.verify.md +0 -11
  196. package/tests/commands/publish-npm-local-split.verify.md +0 -9
  197. package/tests/commands/publish-responsibility-split.verify.md +0 -13
  198. package/tests/commands/publish-set.verify.md +0 -7
  199. package/tests/commands/publish-storage-split.verify.md +0 -8
  200. package/tests/commands/slice3-severity-cleanup.verify.md +0 -12
  201. package/tests/deps/deps-directory-separation.verify.md +0 -15
  202. package/tests/deps/replace-deps/replace-deps-perf.verify.md +0 -15
  203. package/tests/deps/server-externals/mise-toml-parse-intent.verify.md +0 -16
  204. package/tests/electron/electron-symlink-cleanup.verify.md +0 -8
  205. package/tests/engines/engine-duplicate-output-removal.verify.md +0 -10
  206. package/tests/engines/engine-typecheck-selection.verify.md +0 -8
  207. package/tests/engines/esbuild-client-engine.verify.md +0 -15
  208. package/tests/engines/normalize-result.verify.md +0 -9
  209. package/tests/engines/vite-dependency-cleanup.verify.md +0 -24
  210. package/tests/esbuild/esbuild-angular-compiler-plugin-hmr.verify.md +0 -23
  211. package/tests/esbuild/esbuild-angular-compiler-plugin-onload.verify.md +0 -21
  212. package/tests/esbuild/esbuild-angular-compiler-plugin-onstart-extraction.verify.md +0 -16
  213. package/tests/esbuild/esbuild-angular-compiler-plugin-sdtscompiler.verify.md +0 -15
  214. package/tests/esbuild/esbuild-angular-compiler-plugin-stylesheet.verify.md +0 -31
  215. package/tests/esbuild/esbuild-angular-compiler-plugin-worker.verify.md +0 -59
  216. package/tests/esbuild/esbuild-angular-compiler-plugin.verify.md +0 -21
  217. package/tests/esbuild/esbuild-postcss-plugin-chunking.verify.md +0 -17
  218. package/tests/esbuild/esbuild-tsc-plugin-imports.verify.md +0 -13
  219. package/tests/esbuild/esbuild-worker-plugin-node.verify.md +0 -12
  220. package/tests/esbuild/esbuild-worker-plugin.verify.md +0 -7
  221. package/tests/orchestrators/dist-delete-watcher.verify.md +0 -10
  222. package/tests/orchestrators/orchestrator-baseenv.verify.md +0 -10
  223. package/tests/orchestrators/orchestrator-diagnostic-formatting.verify.md +0 -10
  224. package/tests/orchestrators/orchestrator-initializemode-signature.verify.md +0 -9
  225. package/tests/orchestrators/slice1-stdout-to-consola.verify.md +0 -10
  226. package/tests/sd-cli-catch-all.verify.md +0 -7
  227. package/tests/sd-cli-log-tag.verify.md +0 -11
  228. package/tests/ts-compiler/SdTsCompiler-affected-files.verify.md +0 -8
  229. package/tests/ts-compiler/SdTsCompiler-crash-handling.verify.md +0 -24
  230. package/tests/ts-compiler/SdTsCompiler-diagnostics.verify.md +0 -12
  231. package/tests/ts-compiler/SdTsCompiler-emit.verify.md +0 -9
  232. package/tests/ts-compiler/SdTsCompiler.verify.md +0 -41
  233. package/tests/ts-compiler/scss-lint-integration.verify.md +0 -14
  234. package/tests/utils/copy-public-outdir.verify.md +0 -8
  235. package/tests/utils/dev-http-server.verify.md +0 -8
  236. package/tests/utils/engine-watch-events.verify.md +0 -17
  237. package/tests/utils/esbuild-client-config-integration.verify.md +0 -9
  238. package/tests/utils/esbuild-client-config-postcss.verify.md +0 -6
  239. package/tests/utils/esbuild-client-config.verify.md +0 -26
  240. package/tests/utils/esbuild-index-html.verify.md +0 -10
  241. package/tests/utils/esbuild-pwa.verify.md +0 -9
  242. package/tests/utils/esbuild-scss-plugin.verify.md +0 -8
  243. package/tests/utils/hmr-service.verify.md +0 -17
  244. package/tests/utils/lint-core-import-paths.verify.md +0 -10
  245. package/tests/utils/replace-deps-split.verify.md +0 -15
  246. package/tests/utils/replace-deps-watch.verify.md +0 -9
  247. package/tests/utils/server-production-files-import-paths.verify.md +0 -14
  248. package/tests/utils/vite-config-cleanup.verify.md +0 -7
  249. package/tests/workers/build-watch-paths-library.verify.md +0 -10
  250. package/tests/workers/build-watch-paths-ngtsc-server.verify.md +0 -12
  251. package/tests/workers/client-worker-browser-support.verify.md +0 -7
  252. package/tests/workers/client-worker-cleanup.verify.md +0 -8
  253. package/tests/workers/client-worker-initial-build-error.verify.md +0 -7
  254. package/tests/workers/client-worker-initial-build-warnings.verify.md +0 -7
  255. package/tests/workers/client-worker-mtime-incremental.verify.md +0 -10
  256. package/tests/workers/client-worker-onend-sync.verify.md +0 -7
  257. package/tests/workers/client-worker-refactor.verify.md +0 -22
  258. package/tests/workers/client-worker-ts-cache-invalidation.verify.md +0 -12
  259. package/tests/workers/dev-port-file.verify.md +0 -6
  260. package/tests/workers/ngtsc-build-rootnames-refresh.verify.md +0 -8
  261. package/tests/workers/server-build-context-dispose.verify.md +0 -8
  262. package/tests/workers/server-build-worker-plugin.verify.md +0 -9
  263. package/tests/workers/server-build-worker-refactoring.verify.md +0 -14
  264. package/tests/workers/server-esbuild-context-integration.verify.md +0 -10
  265. package/tests/workers/server-esbuild-context-tsc.verify.md +0 -7
@@ -1,608 +1,608 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import * as coreCommon from "@simplysm/core-common";
3
- import { fsx, cpx } from "@simplysm/core-node";
4
-
5
- // esbuild는 외부 npm으로 ESM namespace immutable이라 vi.mock 유지
6
- const mockEsbuildBuild = vi.fn().mockResolvedValue({});
7
- let mockEsbuildOnEndCallback: ((result: { errors: unknown[] }) => void | Promise<void>) | null =
8
- null;
9
- const mockEsbuildContext = vi.fn().mockImplementation((options: any) => {
10
- const plugin = options?.plugins?.find((p: any) => p.name === "electron-restart");
11
- if (plugin != null) {
12
- plugin.setup({
13
- onEnd: (cb: (result: { errors: unknown[] }) => void | Promise<void>) => {
14
- mockEsbuildOnEndCallback = cb;
15
- },
16
- });
17
- }
18
- return {
19
- watch: vi.fn().mockImplementation(async () => {
20
- if (mockEsbuildOnEndCallback != null) {
21
- await mockEsbuildOnEndCallback({ errors: [] });
22
- }
23
- }),
24
- dispose: vi.fn(),
25
- };
26
- });
27
- vi.mock("esbuild", () => ({
28
- build: mockEsbuildBuild,
29
- context: mockEsbuildContext,
30
- }));
31
-
32
- // @simplysm/core-node fsx, cpx는 spy로 전환
33
- const mockFsxExists = vi.spyOn(fsx, "exists");
34
- const mockFsxReadJson = vi.spyOn(fsx, "readJson");
35
- const mockFsxWriteJson = vi.spyOn(fsx, "writeJson").mockResolvedValue(undefined);
36
- vi.spyOn(fsx, "write").mockResolvedValue(undefined);
37
- vi.spyOn(fsx, "mkdir").mockResolvedValue(undefined);
38
- const mockFsxCopy = vi.spyOn(fsx, "copy").mockResolvedValue(undefined);
39
- const mockFsxReaddir = vi.spyOn(fsx, "readdir");
40
- const mockFsxGlob = vi.spyOn(fsx, "glob");
41
-
42
- const mockCpxSpawn = vi.spyOn(cpx, "spawn").mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 });
43
- vi.spyOn(cpx, "spawnSync").mockReturnValue({ stdout: "", stderr: "", exitCode: 0 });
44
-
45
- // createLogger spy
46
- const mockLoggerDebug = vi.fn();
47
- const mockLoggerWarn = vi.fn();
48
- const mockLoggerInfo = vi.fn();
49
- const mockLoggerStart = vi.fn();
50
- const mockLoggerSuccess = vi.fn();
51
- const mockLoggerError = vi.fn();
52
- vi.spyOn(coreCommon, "createLogger").mockReturnValue({ debug: mockLoggerDebug, warn: mockLoggerWarn, info: mockLoggerInfo, start: mockLoggerStart, success: mockLoggerSuccess, error: mockLoggerError } as any);
53
-
54
- //#endregion
55
-
56
- //#region Helpers
57
-
58
- const PKG_PATH = "/fake/pkg";
59
-
60
- function setupDefaultMocks() {
61
- mockFsxExists.mockResolvedValue(true);
62
- mockFsxReadJson.mockResolvedValue({
63
- name: "@myorg/my-app",
64
- version: "1.0.0",
65
- description: "My App",
66
- dependencies: {
67
- "better-sqlite3": "^11.0.0",
68
- "sharp": "^0.34.0",
69
- },
70
- devDependencies: {
71
- "electron": "^35.0.0",
72
- "@electron/rebuild": "^4.0.0",
73
- "electron-builder": "^26.0.0",
74
- },
75
- });
76
- mockFsxReaddir.mockResolvedValue(["index.html", "assets", "electron"]);
77
- // Default: glob returns one exe file matching the builder output
78
- mockFsxGlob.mockImplementation((pattern: string) => {
79
- const normalized = pattern.replace(/\\/g, "/");
80
- if (normalized.endsWith(".electron/dist/*.exe")) {
81
- const dir = normalized.replace("/*.exe", "");
82
- return Promise.resolve([dir + "/My App Setup 1.0.0.exe"]);
83
- }
84
- return Promise.resolve([]);
85
- });
86
- mockCpxSpawn.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 });
87
- mockEsbuildBuild.mockResolvedValue({});
88
- }
89
-
90
- function findWriteJson(pathFragment: string): Record<string, unknown> | undefined {
91
- const call = mockFsxWriteJson.mock.calls.find(
92
- (c) =>
93
- typeof c[0] === "string" &&
94
- c[0].replace(/\\/g, "/").includes(pathFragment),
95
- );
96
- return call ? (call[1] as Record<string, unknown>) : undefined;
97
- }
98
-
99
- function findElectronPackageJson(): Record<string, unknown> | undefined {
100
- return findWriteJson(".electron/src/package.json");
101
- }
102
-
103
- function findBuilderConfig(): Record<string, unknown> | undefined {
104
- return findWriteJson("builder-config.json");
105
- }
106
-
107
- function normalizedCopyCalls(): string[][] {
108
- return mockFsxCopy.mock.calls.map((c) => [
109
- c[0].replace(/\\/g, "/"),
110
- c[1].replace(/\\/g, "/"),
111
- ]);
112
- }
113
-
114
- //#endregion
115
-
116
- describe("Electron", () => {
117
- beforeEach(() => {
118
- vi.clearAllMocks();
119
- mockEsbuildOnEndCallback = null;
120
- setupDefaultMocks();
121
- });
122
-
123
- //#region Rule: 설정을 검증한다
124
-
125
- describe("Acceptance: appId가 없으면 에러가 발생한다", () => {
126
- it("appId가 빈 문자열이면 에러를 던진다", async () => {
127
- const { Electron } = await import("../../src/electron/electron.js");
128
-
129
- await expect(
130
- Electron.create(PKG_PATH, { appId: "" }),
131
- ).rejects.toThrow("appId");
132
- });
133
-
134
- it("appId가 공백만 있으면 에러를 던진다", async () => {
135
- const { Electron } = await import("../../src/electron/electron.js");
136
-
137
- await expect(
138
- Electron.create(PKG_PATH, { appId: " " }),
139
- ).rejects.toThrow("appId");
140
- });
141
- });
142
-
143
- //#endregion
144
-
145
- //#region Rule: Electron 프로젝트를 초기화한다
146
-
147
- describe("인수 테스트: 초기화", () => {
148
- it("package.json 생성 + pnpm install + electron-rebuild를 실행한다", async () => {
149
- const { Electron } = await import("../../src/electron/electron.js");
150
-
151
- const electron = await Electron.create(PKG_PATH, {
152
- appId: "com.test.app",
153
- reinstallDependencies: ["better-sqlite3"],
154
- });
155
-
156
- await electron.initialize();
157
-
158
- expect(findElectronPackageJson()).toBeDefined();
159
-
160
- const spawnCalls = mockCpxSpawn.mock.calls;
161
- const installCall = spawnCalls.find(
162
- (c) => c[0] === "pnpm" && c[1].includes("install"),
163
- );
164
- expect(installCall).toBeDefined();
165
- expect(installCall?.[2]).toEqual(expect.objectContaining({ shell: true }));
166
- expect(
167
- spawnCalls.find(
168
- (c) => c[0] === "pnpm" && c[1].includes("electron-rebuild"),
169
- ),
170
- ).toBeDefined();
171
- });
172
-
173
- it("reinstallDependencies가 비어있으면 electron-rebuild를 건너뛴다", async () => {
174
- const { Electron } = await import("../../src/electron/electron.js");
175
-
176
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
177
- await electron.initialize();
178
-
179
- const rebuildCall = mockCpxSpawn.mock.calls.find(
180
- (c) => c[0] === "pnpm" && c[1].includes("electron-rebuild"),
181
- );
182
- expect(rebuildCall).toBeUndefined();
183
- });
184
- });
185
-
186
- describe("단위: _setupPackageJson", () => {
187
- it("스코프 패키지명을 정규화한다", async () => {
188
- const { Electron } = await import("../../src/electron/electron.js");
189
-
190
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
191
- await electron.initialize();
192
-
193
- expect(findElectronPackageJson()!["name"]).toBe("myorg-my-app");
194
- });
195
-
196
- it("reinstallDependencies 패키지를 dependencies에 포함한다", async () => {
197
- const { Electron } = await import("../../src/electron/electron.js");
198
-
199
- const electron = await Electron.create(PKG_PATH, {
200
- appId: "com.test.app",
201
- reinstallDependencies: ["better-sqlite3"],
202
- });
203
- await electron.initialize();
204
-
205
- const deps = findElectronPackageJson()!["dependencies"] as Record<string, string>;
206
- expect(deps["better-sqlite3"]).toBe("^11.0.0");
207
- });
208
-
209
- it("exclude 패키지를 dependencies에 포함한다", async () => {
210
- const { Electron } = await import("../../src/electron/electron.js");
211
-
212
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" }, ["sharp"]);
213
- await electron.initialize();
214
-
215
- const deps = findElectronPackageJson()!["dependencies"] as Record<string, string>;
216
- expect(deps["sharp"]).toBe("^0.34.0");
217
- });
218
-
219
- it("package.json에 type: module이 설정된다", async () => {
220
- const { Electron } = await import("../../src/electron/electron.js");
221
-
222
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
223
- await electron.initialize();
224
-
225
- expect(findElectronPackageJson()!["type"]).toBe("module");
226
- });
227
-
228
- it("postInstallScript가 설정되면 scripts.postinstall에 포함한다", async () => {
229
- const { Electron } = await import("../../src/electron/electron.js");
230
-
231
- const electron = await Electron.create(PKG_PATH, {
232
- appId: "com.test.app",
233
- postInstallScript: "node rebuild.js",
234
- });
235
- await electron.initialize();
236
-
237
- const scripts = findElectronPackageJson()!["scripts"] as Record<string, string>;
238
- expect(scripts["postinstall"]).toBe("node rebuild.js");
239
- });
240
- });
241
-
242
- //#endregion
243
-
244
- //#region Rule: 메인 프로세스를 번들링한다
245
-
246
- describe("Acceptance: build — esbuild 번들링", () => {
247
- it("src/electron-main.ts를 esbuild로 번들링한다", async () => {
248
- const { Electron } = await import("../../src/electron/electron.js");
249
-
250
- const electron = await Electron.create(PKG_PATH, {
251
- appId: "com.test.app",
252
- reinstallDependencies: ["better-sqlite3"],
253
- });
254
- await electron.build("/fake/out");
255
-
256
- expect(mockEsbuildBuild).toHaveBeenCalledWith(
257
- expect.objectContaining({
258
- platform: "node",
259
- target: "node24",
260
- format: "esm",
261
- bundle: true,
262
- external: expect.arrayContaining(["electron", "better-sqlite3"]),
263
- }),
264
- );
265
- });
266
-
267
- it("electron-main.ts가 없으면 에러가 발생한다", async () => {
268
- mockFsxExists.mockImplementation((p: string) => {
269
- if (typeof p === "string" && p.includes("electron-main.ts")) return Promise.resolve(false);
270
- return Promise.resolve(true);
271
- });
272
-
273
- const { Electron } = await import("../../src/electron/electron.js");
274
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
275
-
276
- await expect(electron.build("/fake/out")).rejects.toThrow("electron-main.ts");
277
- });
278
-
279
- it("config.env를 esbuild banner로 주입한다 (ELECTRON_DEV_URL 미포함)", async () => {
280
- const { Electron } = await import("../../src/electron/electron.js");
281
-
282
- const electron = await Electron.create(PKG_PATH, {
283
- appId: "com.test.app",
284
- env: { API_URL: "https://api.example.com" },
285
- });
286
- await electron.build("/fake/out");
287
-
288
- const callArgs = mockEsbuildBuild.mock.calls[0][0];
289
- const banner = callArgs.banner?.js as string;
290
- expect(banner).toContain("process.env");
291
- expect(banner).toContain("??=");
292
- expect(banner).toContain("API_URL");
293
- expect(banner).toContain("https://api.example.com");
294
- expect(banner).not.toContain("ELECTRON_DEV_URL");
295
- expect(callArgs.define).toBeUndefined();
296
- });
297
-
298
- it("ESM 배너에 createRequire shim이 포함된다", async () => {
299
- const { Electron } = await import("../../src/electron/electron.js");
300
-
301
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
302
- await electron.build("/fake/out");
303
-
304
- const callArgs = mockEsbuildBuild.mock.calls[0][0];
305
- const banner = callArgs.banner?.js as string;
306
- expect(banner).toContain("createRequire");
307
- expect(banner).toContain("import.meta.url");
308
- });
309
- });
310
-
311
- //#endregion
312
-
313
- //#region Rule: electron-builder로 패키징한다
314
-
315
- describe("Unit: Web assets 복사", () => {
316
- it("dist 내 파일을 .electron/src에 복사하되 electron 디렉토리는 제외한다", async () => {
317
- const { Electron } = await import("../../src/electron/electron.js");
318
-
319
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
320
- await electron.build("/fake/out");
321
-
322
- const copies = normalizedCopyCalls();
323
- expect(copies.some((c) => c[0].includes("/fake/out/index.html"))).toBe(true);
324
- expect(copies.some((c) => c[0].includes("/fake/out/assets"))).toBe(true);
325
- expect(copies.some((c) => c[0].includes("/fake/out/electron"))).toBe(false);
326
- });
327
- });
328
-
329
- describe("Acceptance: electron-builder 설정 생성", () => {
330
- it("portable=true이면 portable 타겟으로 설정한다", async () => {
331
- const { Electron } = await import("../../src/electron/electron.js");
332
-
333
- const electron = await Electron.create(PKG_PATH, {
334
- appId: "com.test.app",
335
- portable: true,
336
- });
337
- await electron.build("/fake/out");
338
-
339
- const config = findBuilderConfig()!;
340
- expect(config["appId"]).toBe("com.test.app");
341
- expect((config["win"] as Record<string, unknown>)["target"]).toBe("portable");
342
- });
343
-
344
- it("portable 미설정이면 nsis 타겟으로 설정한다", async () => {
345
- const { Electron } = await import("../../src/electron/electron.js");
346
-
347
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
348
- await electron.build("/fake/out");
349
-
350
- const config = findBuilderConfig()!;
351
- expect((config["win"] as Record<string, unknown>)["target"]).toBe("nsis");
352
- });
353
-
354
- it("installerIcon이 설정되면 icon에 포함한다", async () => {
355
- const { Electron } = await import("../../src/electron/electron.js");
356
-
357
- const electron = await Electron.create(PKG_PATH, {
358
- appId: "com.test.app",
359
- installerIcon: "assets/icon.ico",
360
- });
361
- await electron.build("/fake/out");
362
-
363
- const config = findBuilderConfig()!;
364
- expect((config["icon"] as string).replace(/\\/g, "/")).toContain("assets/icon.ico");
365
- });
366
-
367
- it("nsisOptions가 설정되면 nsis에 반영한다", async () => {
368
- const { Electron } = await import("../../src/electron/electron.js");
369
-
370
- const electron = await Electron.create(PKG_PATH, {
371
- appId: "com.test.app",
372
- nsisOptions: { oneClick: false, allowToChangeInstallationDirectory: true },
373
- });
374
- await electron.build("/fake/out");
375
-
376
- const nsis = findBuilderConfig()!["nsis"] as Record<string, unknown>;
377
- expect(nsis["oneClick"]).toBe(false);
378
- expect(nsis["allowToChangeInstallationDirectory"]).toBe(true);
379
- });
380
- });
381
-
382
- //#endregion
383
-
384
- //#region Rule: 빌드 산출물을 관리한다
385
-
386
- describe("Unit: 빌드 산출물 복사", () => {
387
- it("portable 빌드 시 {description}-portable-latest.exe로 복사한다", async () => {
388
- const { Electron } = await import("../../src/electron/electron.js");
389
-
390
- const electron = await Electron.create(PKG_PATH, {
391
- appId: "com.test.app",
392
- portable: true,
393
- });
394
- await electron.build("/fake/out");
395
-
396
- const latestCopy = normalizedCopyCalls().find(
397
- (c) => c[1].includes("electron") && c[1].includes("latest"),
398
- );
399
- expect(latestCopy).toBeDefined();
400
- expect(latestCopy![1]).toContain("-portable-latest.exe");
401
- });
402
-
403
- it("NSIS 빌드 시 {description}-latest.exe로 복사한다", async () => {
404
- const { Electron } = await import("../../src/electron/electron.js");
405
-
406
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
407
- await electron.build("/fake/out");
408
-
409
- const latestCopy = normalizedCopyCalls().find(
410
- (c) => c[1].includes("electron") && c[1].includes("latest"),
411
- );
412
- expect(latestCopy).toBeDefined();
413
- expect(latestCopy![1]).not.toContain("-portable-");
414
- expect(latestCopy![1]).toContain("-latest.exe");
415
- });
416
-
417
- it("updates/{version}.exe에 버전별 복사를 한다", async () => {
418
- const { Electron } = await import("../../src/electron/electron.js");
419
-
420
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
421
- await electron.build("/fake/out");
422
-
423
- expect(
424
- normalizedCopyCalls().find((c) => c[1].includes("updates/1.0.0.exe")),
425
- ).toBeDefined();
426
- });
427
-
428
- it("빌드 산출물이 없으면 경고를 출력한다", async () => {
429
- mockFsxGlob.mockImplementation((pattern: string) => {
430
- const normalized = pattern.replace(/\\/g, "/");
431
- if (normalized.endsWith(".electron/dist/*.exe")) return Promise.resolve([]);
432
- return Promise.resolve([]);
433
- });
434
-
435
- const { Electron } = await import("../../src/electron/electron.js");
436
-
437
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
438
- await electron.build("/fake/out");
439
-
440
- expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining("빌드 산출물"));
441
- });
442
- });
443
-
444
- //#endregion
445
-
446
- //#region Rule: 개발 모드에서 Electron 앱을 실행한다
447
-
448
- describe("인수 테스트: run()", () => {
449
- // Helper: creates a deferred cpx mock where Electron process can be controlled
450
- function setupCpxForRun(): {
451
- electronKill: ReturnType<typeof vi.fn>;
452
- resolveElectron: () => void;
453
- } {
454
- const electronKill = vi.fn();
455
- let resolveElectron: () => void = () => {};
456
-
457
- mockCpxSpawn.mockImplementation((cmd: string, args: string[]) => {
458
- // pnpm exec electron . → Electron 프로세스
459
- if (cmd === "pnpm" && args[0] === "exec" && args[1] === "electron" && args[2] === ".") {
460
- const p = new Promise<void>((resolve) => {
461
- resolveElectron = resolve;
462
- }) as any;
463
- p.kill = electronKill;
464
- return p;
465
- }
466
- return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 });
467
- });
468
-
469
- return { electronKill, resolveElectron: () => resolveElectron() };
470
- }
471
-
472
- it("creates esbuild context with banner for env and spawns Electron", async () => {
473
- const { resolveElectron } = setupCpxForRun();
474
-
475
- const { Electron } = await import("../../src/electron/electron.js");
476
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
477
-
478
- const runPromise = electron.run("http://localhost:4200");
479
-
480
- // Give event loop time to set up
481
- await new Promise((r) => setTimeout(r, 10));
482
-
483
- // Resolve Electron process (simulating Electron exit)
484
- resolveElectron();
485
- await runPromise;
486
-
487
- // esbuild.context was called with banner containing ELECTRON_DEV_URL
488
- const callArgs = mockEsbuildContext.mock.calls[0][0];
489
- const banner = callArgs.banner?.js as string;
490
- expect(banner).toContain("process.env");
491
- expect(banner).toContain("??=");
492
- expect(banner).toContain("ELECTRON_DEV_URL");
493
- expect(banner).toContain("http://localhost:4200");
494
- expect(callArgs.define).toBeUndefined();
495
-
496
- expect(callArgs.platform).toBe("node");
497
- expect(callArgs.target).toBe("node24");
498
- expect(callArgs.format).toBe("esm");
499
- expect(callArgs.bundle).toBe(true);
500
- expect(callArgs.external).toContain("electron");
501
- const electronCall = mockCpxSpawn.mock.calls.find(
502
- (c) => c[0] === "pnpm" && c[1].includes("electron"),
503
- );
504
- expect(electronCall?.[2]).toEqual(expect.objectContaining({ shell: true, reject: false }));
505
-
506
- // ESM 배너에 createRequire shim 포함
507
- expect(banner).toContain("createRequire");
508
- expect(banner).toContain("import.meta.url");
509
- }, 10_000);
510
-
511
- it("throws when electron-main.ts entry point is missing", async () => {
512
- mockFsxExists.mockImplementation((p: string) => {
513
- if (typeof p === "string" && p.includes("electron-main.ts")) return Promise.resolve(false);
514
- return Promise.resolve(true);
515
- });
516
-
517
- const { Electron } = await import("../../src/electron/electron.js");
518
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
519
-
520
- await expect(electron.run("http://localhost:4200")).rejects.toThrow("electron-main.ts");
521
- });
522
-
523
- it("resolves on SIGINT signal", async () => {
524
- const { electronKill } = setupCpxForRun();
525
-
526
- const { Electron } = await import("../../src/electron/electron.js");
527
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
528
-
529
- const runPromise = electron.run("http://localhost:4200");
530
-
531
- // Give the event loop time to set up signal handlers
532
- await new Promise((r) => setTimeout(r, 10));
533
-
534
- // Emit SIGINT to trigger cleanup
535
- process.emit("SIGINT" as any);
536
-
537
- // run() should resolve after signal
538
- await runPromise;
539
-
540
- expect(electronKill).toHaveBeenCalled();
541
- }, 10_000);
542
- });
543
-
544
- describe("단위: run() 플러그인 동작", () => {
545
- it("passes custom env and ELECTRON_DEV_URL via esbuild banner", async () => {
546
- let resolveElectron: () => void = () => {};
547
- mockCpxSpawn.mockImplementation((cmd: string, args: string[]) => {
548
- if (cmd === "pnpm" && args[0] === "exec" && args[1] === "electron" && args[2] === ".") {
549
- const p = new Promise<void>((resolve) => {
550
- resolveElectron = resolve;
551
- }) as any;
552
- p.kill = vi.fn();
553
- return p;
554
- }
555
- return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 });
556
- });
557
-
558
- const { Electron } = await import("../../src/electron/electron.js");
559
- const electron = await Electron.create(PKG_PATH, {
560
- appId: "com.test.app",
561
- env: { CUSTOM_VAR: "test-value" },
562
- });
563
-
564
- const runPromise = electron.run("http://localhost:5555");
565
- await new Promise((r) => setTimeout(r, 10));
566
- resolveElectron();
567
- await runPromise;
568
-
569
- const callArgs = mockEsbuildContext.mock.calls[0][0];
570
- const banner = callArgs.banner?.js as string;
571
- expect(banner).toContain("ELECTRON_DEV_URL");
572
- expect(banner).toContain("http://localhost:5555");
573
- expect(banner).toContain("CUSTOM_VAR");
574
- expect(banner).toContain("test-value");
575
- expect(callArgs.define).toBeUndefined();
576
- }, 10_000);
577
-
578
- it("calls initialize() before starting esbuild context", async () => {
579
- let resolveElectron: () => void = () => {};
580
- mockCpxSpawn.mockImplementation((cmd: string, args: string[]) => {
581
- if (cmd === "pnpm" && args[0] === "exec" && args[1] === "electron" && args[2] === ".") {
582
- const p = new Promise<void>((resolve) => {
583
- resolveElectron = resolve;
584
- }) as any;
585
- p.kill = vi.fn();
586
- return p;
587
- }
588
- return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 });
589
- });
590
-
591
- const { Electron } = await import("../../src/electron/electron.js");
592
- const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
593
-
594
- const runPromise = electron.run("http://localhost:4200");
595
- await new Promise((r) => setTimeout(r, 10));
596
- resolveElectron();
597
- await runPromise;
598
-
599
- // initialize calls pnpm install
600
- const pnpmInstallCall = mockCpxSpawn.mock.calls.find(
601
- (c: any[]) => c[0] === "pnpm" && (c[1] as string[]).includes("install"),
602
- );
603
- expect(pnpmInstallCall).toBeDefined();
604
- }, 10_000);
605
- });
606
-
607
- //#endregion
608
- });
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import * as coreCommon from "@simplysm/core-common";
3
+ import { fsx, cpx } from "@simplysm/core-node";
4
+
5
+ // esbuild는 외부 npm으로 ESM namespace immutable이라 vi.mock 유지
6
+ const mockEsbuildBuild = vi.fn().mockResolvedValue({});
7
+ let mockEsbuildOnEndCallback: ((result: { errors: unknown[] }) => void | Promise<void>) | null =
8
+ null;
9
+ const mockEsbuildContext = vi.fn().mockImplementation((options: any) => {
10
+ const plugin = options?.plugins?.find((p: any) => p.name === "electron-restart");
11
+ if (plugin != null) {
12
+ plugin.setup({
13
+ onEnd: (cb: (result: { errors: unknown[] }) => void | Promise<void>) => {
14
+ mockEsbuildOnEndCallback = cb;
15
+ },
16
+ });
17
+ }
18
+ return {
19
+ watch: vi.fn().mockImplementation(async () => {
20
+ if (mockEsbuildOnEndCallback != null) {
21
+ await mockEsbuildOnEndCallback({ errors: [] });
22
+ }
23
+ }),
24
+ dispose: vi.fn(),
25
+ };
26
+ });
27
+ vi.mock("esbuild", () => ({
28
+ build: mockEsbuildBuild,
29
+ context: mockEsbuildContext,
30
+ }));
31
+
32
+ // @simplysm/core-node fsx, cpx는 spy로 전환
33
+ const mockFsxExists = vi.spyOn(fsx, "exists");
34
+ const mockFsxReadJson = vi.spyOn(fsx, "readJson");
35
+ const mockFsxWriteJson = vi.spyOn(fsx, "writeJson").mockResolvedValue(undefined);
36
+ vi.spyOn(fsx, "write").mockResolvedValue(undefined);
37
+ vi.spyOn(fsx, "mkdir").mockResolvedValue(undefined);
38
+ const mockFsxCopy = vi.spyOn(fsx, "copy").mockResolvedValue(undefined);
39
+ const mockFsxReaddir = vi.spyOn(fsx, "readdir");
40
+ const mockFsxGlob = vi.spyOn(fsx, "glob");
41
+
42
+ const mockCpxSpawn = vi.spyOn(cpx, "spawn").mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 });
43
+ vi.spyOn(cpx, "spawnSync").mockReturnValue({ stdout: "", stderr: "", exitCode: 0 });
44
+
45
+ // createLogger spy
46
+ const mockLoggerDebug = vi.fn();
47
+ const mockLoggerWarn = vi.fn();
48
+ const mockLoggerInfo = vi.fn();
49
+ const mockLoggerStart = vi.fn();
50
+ const mockLoggerSuccess = vi.fn();
51
+ const mockLoggerError = vi.fn();
52
+ vi.spyOn(coreCommon, "createLogger").mockReturnValue({ debug: mockLoggerDebug, warn: mockLoggerWarn, info: mockLoggerInfo, start: mockLoggerStart, success: mockLoggerSuccess, error: mockLoggerError } as any);
53
+
54
+ //#endregion
55
+
56
+ //#region Helpers
57
+
58
+ const PKG_PATH = "/fake/pkg";
59
+
60
+ function setupDefaultMocks() {
61
+ mockFsxExists.mockResolvedValue(true);
62
+ mockFsxReadJson.mockResolvedValue({
63
+ name: "@myorg/my-app",
64
+ version: "1.0.0",
65
+ description: "My App",
66
+ dependencies: {
67
+ "better-sqlite3": "^11.0.0",
68
+ "sharp": "^0.34.0",
69
+ },
70
+ devDependencies: {
71
+ "electron": "^35.0.0",
72
+ "@electron/rebuild": "^4.0.0",
73
+ "electron-builder": "^26.0.0",
74
+ },
75
+ });
76
+ mockFsxReaddir.mockResolvedValue(["index.html", "assets", "electron"]);
77
+ // Default: glob returns one exe file matching the builder output
78
+ mockFsxGlob.mockImplementation((pattern: string) => {
79
+ const normalized = pattern.replace(/\\/g, "/");
80
+ if (normalized.endsWith(".electron/dist/*.exe")) {
81
+ const dir = normalized.replace("/*.exe", "");
82
+ return Promise.resolve([dir + "/My App Setup 1.0.0.exe"]);
83
+ }
84
+ return Promise.resolve([]);
85
+ });
86
+ mockCpxSpawn.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 });
87
+ mockEsbuildBuild.mockResolvedValue({});
88
+ }
89
+
90
+ function findWriteJson(pathFragment: string): Record<string, unknown> | undefined {
91
+ const call = mockFsxWriteJson.mock.calls.find(
92
+ (c) =>
93
+ typeof c[0] === "string" &&
94
+ c[0].replace(/\\/g, "/").includes(pathFragment),
95
+ );
96
+ return call ? (call[1] as Record<string, unknown>) : undefined;
97
+ }
98
+
99
+ function findElectronPackageJson(): Record<string, unknown> | undefined {
100
+ return findWriteJson(".electron/src/package.json");
101
+ }
102
+
103
+ function findBuilderConfig(): Record<string, unknown> | undefined {
104
+ return findWriteJson("builder-config.json");
105
+ }
106
+
107
+ function normalizedCopyCalls(): string[][] {
108
+ return mockFsxCopy.mock.calls.map((c) => [
109
+ c[0].replace(/\\/g, "/"),
110
+ c[1].replace(/\\/g, "/"),
111
+ ]);
112
+ }
113
+
114
+ //#endregion
115
+
116
+ describe("Electron", () => {
117
+ beforeEach(() => {
118
+ vi.clearAllMocks();
119
+ mockEsbuildOnEndCallback = null;
120
+ setupDefaultMocks();
121
+ });
122
+
123
+ //#region Rule: 설정을 검증한다
124
+
125
+ describe("Acceptance: appId가 없으면 에러가 발생한다", () => {
126
+ it("appId가 빈 문자열이면 에러를 던진다", async () => {
127
+ const { Electron } = await import("../../src/electron/electron.js");
128
+
129
+ await expect(
130
+ Electron.create(PKG_PATH, { appId: "" }),
131
+ ).rejects.toThrow("appId");
132
+ });
133
+
134
+ it("appId가 공백만 있으면 에러를 던진다", async () => {
135
+ const { Electron } = await import("../../src/electron/electron.js");
136
+
137
+ await expect(
138
+ Electron.create(PKG_PATH, { appId: " " }),
139
+ ).rejects.toThrow("appId");
140
+ });
141
+ });
142
+
143
+ //#endregion
144
+
145
+ //#region Rule: Electron 프로젝트를 초기화한다
146
+
147
+ describe("인수 테스트: 초기화", () => {
148
+ it("package.json 생성 + bun install + electron-rebuild를 실행한다", async () => {
149
+ const { Electron } = await import("../../src/electron/electron.js");
150
+
151
+ const electron = await Electron.create(PKG_PATH, {
152
+ appId: "com.test.app",
153
+ reinstallDependencies: ["better-sqlite3"],
154
+ });
155
+
156
+ await electron.initialize();
157
+
158
+ expect(findElectronPackageJson()).toBeDefined();
159
+
160
+ const spawnCalls = mockCpxSpawn.mock.calls;
161
+ const installCall = spawnCalls.find(
162
+ (c) => c[0] === "bun" && c[1].includes("install"),
163
+ );
164
+ expect(installCall).toBeDefined();
165
+ expect(installCall?.[2]).toEqual(expect.objectContaining({ shell: true }));
166
+ expect(
167
+ spawnCalls.find(
168
+ (c) => c[0] === "bun" && c[1].includes("electron-rebuild"),
169
+ ),
170
+ ).toBeDefined();
171
+ });
172
+
173
+ it("reinstallDependencies가 비어있으면 electron-rebuild를 건너뛴다", async () => {
174
+ const { Electron } = await import("../../src/electron/electron.js");
175
+
176
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
177
+ await electron.initialize();
178
+
179
+ const rebuildCall = mockCpxSpawn.mock.calls.find(
180
+ (c) => c[0] === "bun" && c[1].includes("electron-rebuild"),
181
+ );
182
+ expect(rebuildCall).toBeUndefined();
183
+ });
184
+ });
185
+
186
+ describe("단위: _setupPackageJson", () => {
187
+ it("스코프 패키지명을 정규화한다", async () => {
188
+ const { Electron } = await import("../../src/electron/electron.js");
189
+
190
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
191
+ await electron.initialize();
192
+
193
+ expect(findElectronPackageJson()!["name"]).toBe("myorg-my-app");
194
+ });
195
+
196
+ it("reinstallDependencies 패키지를 dependencies에 포함한다", async () => {
197
+ const { Electron } = await import("../../src/electron/electron.js");
198
+
199
+ const electron = await Electron.create(PKG_PATH, {
200
+ appId: "com.test.app",
201
+ reinstallDependencies: ["better-sqlite3"],
202
+ });
203
+ await electron.initialize();
204
+
205
+ const deps = findElectronPackageJson()!["dependencies"] as Record<string, string>;
206
+ expect(deps["better-sqlite3"]).toBe("^11.0.0");
207
+ });
208
+
209
+ it("exclude 패키지를 dependencies에 포함한다", async () => {
210
+ const { Electron } = await import("../../src/electron/electron.js");
211
+
212
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" }, ["sharp"]);
213
+ await electron.initialize();
214
+
215
+ const deps = findElectronPackageJson()!["dependencies"] as Record<string, string>;
216
+ expect(deps["sharp"]).toBe("^0.34.0");
217
+ });
218
+
219
+ it("package.json에 type: module이 설정된다", async () => {
220
+ const { Electron } = await import("../../src/electron/electron.js");
221
+
222
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
223
+ await electron.initialize();
224
+
225
+ expect(findElectronPackageJson()!["type"]).toBe("module");
226
+ });
227
+
228
+ it("postInstallScript가 설정되면 scripts.postinstall에 포함한다", async () => {
229
+ const { Electron } = await import("../../src/electron/electron.js");
230
+
231
+ const electron = await Electron.create(PKG_PATH, {
232
+ appId: "com.test.app",
233
+ postInstallScript: "node rebuild.js",
234
+ });
235
+ await electron.initialize();
236
+
237
+ const scripts = findElectronPackageJson()!["scripts"] as Record<string, string>;
238
+ expect(scripts["postinstall"]).toBe("node rebuild.js");
239
+ });
240
+ });
241
+
242
+ //#endregion
243
+
244
+ //#region Rule: 메인 프로세스를 번들링한다
245
+
246
+ describe("Acceptance: build — esbuild 번들링", () => {
247
+ it("src/electron-main.ts를 esbuild로 번들링한다", async () => {
248
+ const { Electron } = await import("../../src/electron/electron.js");
249
+
250
+ const electron = await Electron.create(PKG_PATH, {
251
+ appId: "com.test.app",
252
+ reinstallDependencies: ["better-sqlite3"],
253
+ });
254
+ await electron.build("/fake/out");
255
+
256
+ expect(mockEsbuildBuild).toHaveBeenCalledWith(
257
+ expect.objectContaining({
258
+ platform: "node",
259
+ target: "node24",
260
+ format: "esm",
261
+ bundle: true,
262
+ external: expect.arrayContaining(["electron", "better-sqlite3"]),
263
+ }),
264
+ );
265
+ });
266
+
267
+ it("electron-main.ts가 없으면 에러가 발생한다", async () => {
268
+ mockFsxExists.mockImplementation((p: string) => {
269
+ if (typeof p === "string" && p.includes("electron-main.ts")) return Promise.resolve(false);
270
+ return Promise.resolve(true);
271
+ });
272
+
273
+ const { Electron } = await import("../../src/electron/electron.js");
274
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
275
+
276
+ await expect(electron.build("/fake/out")).rejects.toThrow("electron-main.ts");
277
+ });
278
+
279
+ it("config.env를 esbuild banner로 주입한다 (ELECTRON_DEV_URL 미포함)", async () => {
280
+ const { Electron } = await import("../../src/electron/electron.js");
281
+
282
+ const electron = await Electron.create(PKG_PATH, {
283
+ appId: "com.test.app",
284
+ env: { API_URL: "https://api.example.com" },
285
+ });
286
+ await electron.build("/fake/out");
287
+
288
+ const callArgs = mockEsbuildBuild.mock.calls[0][0];
289
+ const banner = callArgs.banner?.js as string;
290
+ expect(banner).toContain("process.env");
291
+ expect(banner).toContain("??=");
292
+ expect(banner).toContain("API_URL");
293
+ expect(banner).toContain("https://api.example.com");
294
+ expect(banner).not.toContain("ELECTRON_DEV_URL");
295
+ expect(callArgs.define).toBeUndefined();
296
+ });
297
+
298
+ it("ESM 배너에 createRequire shim이 포함된다", async () => {
299
+ const { Electron } = await import("../../src/electron/electron.js");
300
+
301
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
302
+ await electron.build("/fake/out");
303
+
304
+ const callArgs = mockEsbuildBuild.mock.calls[0][0];
305
+ const banner = callArgs.banner?.js as string;
306
+ expect(banner).toContain("createRequire");
307
+ expect(banner).toContain("import.meta.url");
308
+ });
309
+ });
310
+
311
+ //#endregion
312
+
313
+ //#region Rule: electron-builder로 패키징한다
314
+
315
+ describe("Unit: Web assets 복사", () => {
316
+ it("dist 내 파일을 .electron/src에 복사하되 electron 디렉토리는 제외한다", async () => {
317
+ const { Electron } = await import("../../src/electron/electron.js");
318
+
319
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
320
+ await electron.build("/fake/out");
321
+
322
+ const copies = normalizedCopyCalls();
323
+ expect(copies.some((c) => c[0].includes("/fake/out/index.html"))).toBe(true);
324
+ expect(copies.some((c) => c[0].includes("/fake/out/assets"))).toBe(true);
325
+ expect(copies.some((c) => c[0].includes("/fake/out/electron"))).toBe(false);
326
+ });
327
+ });
328
+
329
+ describe("Acceptance: electron-builder 설정 생성", () => {
330
+ it("portable=true이면 portable 타겟으로 설정한다", async () => {
331
+ const { Electron } = await import("../../src/electron/electron.js");
332
+
333
+ const electron = await Electron.create(PKG_PATH, {
334
+ appId: "com.test.app",
335
+ portable: true,
336
+ });
337
+ await electron.build("/fake/out");
338
+
339
+ const config = findBuilderConfig()!;
340
+ expect(config["appId"]).toBe("com.test.app");
341
+ expect((config["win"] as Record<string, unknown>)["target"]).toBe("portable");
342
+ });
343
+
344
+ it("portable 미설정이면 nsis 타겟으로 설정한다", async () => {
345
+ const { Electron } = await import("../../src/electron/electron.js");
346
+
347
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
348
+ await electron.build("/fake/out");
349
+
350
+ const config = findBuilderConfig()!;
351
+ expect((config["win"] as Record<string, unknown>)["target"]).toBe("nsis");
352
+ });
353
+
354
+ it("installerIcon이 설정되면 icon에 포함한다", async () => {
355
+ const { Electron } = await import("../../src/electron/electron.js");
356
+
357
+ const electron = await Electron.create(PKG_PATH, {
358
+ appId: "com.test.app",
359
+ installerIcon: "assets/icon.ico",
360
+ });
361
+ await electron.build("/fake/out");
362
+
363
+ const config = findBuilderConfig()!;
364
+ expect((config["icon"] as string).replace(/\\/g, "/")).toContain("assets/icon.ico");
365
+ });
366
+
367
+ it("nsisOptions가 설정되면 nsis에 반영한다", async () => {
368
+ const { Electron } = await import("../../src/electron/electron.js");
369
+
370
+ const electron = await Electron.create(PKG_PATH, {
371
+ appId: "com.test.app",
372
+ nsisOptions: { oneClick: false, allowToChangeInstallationDirectory: true },
373
+ });
374
+ await electron.build("/fake/out");
375
+
376
+ const nsis = findBuilderConfig()!["nsis"] as Record<string, unknown>;
377
+ expect(nsis["oneClick"]).toBe(false);
378
+ expect(nsis["allowToChangeInstallationDirectory"]).toBe(true);
379
+ });
380
+ });
381
+
382
+ //#endregion
383
+
384
+ //#region Rule: 빌드 산출물을 관리한다
385
+
386
+ describe("Unit: 빌드 산출물 복사", () => {
387
+ it("portable 빌드 시 {description}-portable-latest.exe로 복사한다", async () => {
388
+ const { Electron } = await import("../../src/electron/electron.js");
389
+
390
+ const electron = await Electron.create(PKG_PATH, {
391
+ appId: "com.test.app",
392
+ portable: true,
393
+ });
394
+ await electron.build("/fake/out");
395
+
396
+ const latestCopy = normalizedCopyCalls().find(
397
+ (c) => c[1].includes("electron") && c[1].includes("latest"),
398
+ );
399
+ expect(latestCopy).toBeDefined();
400
+ expect(latestCopy![1]).toContain("-portable-latest.exe");
401
+ });
402
+
403
+ it("NSIS 빌드 시 {description}-latest.exe로 복사한다", async () => {
404
+ const { Electron } = await import("../../src/electron/electron.js");
405
+
406
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
407
+ await electron.build("/fake/out");
408
+
409
+ const latestCopy = normalizedCopyCalls().find(
410
+ (c) => c[1].includes("electron") && c[1].includes("latest"),
411
+ );
412
+ expect(latestCopy).toBeDefined();
413
+ expect(latestCopy![1]).not.toContain("-portable-");
414
+ expect(latestCopy![1]).toContain("-latest.exe");
415
+ });
416
+
417
+ it("updates/{version}.exe에 버전별 복사를 한다", async () => {
418
+ const { Electron } = await import("../../src/electron/electron.js");
419
+
420
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
421
+ await electron.build("/fake/out");
422
+
423
+ expect(
424
+ normalizedCopyCalls().find((c) => c[1].includes("updates/1.0.0.exe")),
425
+ ).toBeDefined();
426
+ });
427
+
428
+ it("빌드 산출물이 없으면 경고를 출력한다", async () => {
429
+ mockFsxGlob.mockImplementation((pattern: string) => {
430
+ const normalized = pattern.replace(/\\/g, "/");
431
+ if (normalized.endsWith(".electron/dist/*.exe")) return Promise.resolve([]);
432
+ return Promise.resolve([]);
433
+ });
434
+
435
+ const { Electron } = await import("../../src/electron/electron.js");
436
+
437
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
438
+ await electron.build("/fake/out");
439
+
440
+ expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining("빌드 산출물"));
441
+ });
442
+ });
443
+
444
+ //#endregion
445
+
446
+ //#region Rule: 개발 모드에서 Electron 앱을 실행한다
447
+
448
+ describe("인수 테스트: run()", () => {
449
+ // Helper: creates a deferred cpx mock where Electron process can be controlled
450
+ function setupCpxForRun(): {
451
+ electronKill: ReturnType<typeof vi.fn>;
452
+ resolveElectron: () => void;
453
+ } {
454
+ const electronKill = vi.fn();
455
+ let resolveElectron: () => void = () => {};
456
+
457
+ mockCpxSpawn.mockImplementation((cmd: string, args: string[]) => {
458
+ // bun run electron . → Electron 프로세스
459
+ if (cmd === "bun" && args[0] === "run" && args[1] === "electron" && args[2] === ".") {
460
+ const p = new Promise<void>((resolve) => {
461
+ resolveElectron = resolve;
462
+ }) as any;
463
+ p.kill = electronKill;
464
+ return p;
465
+ }
466
+ return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 });
467
+ });
468
+
469
+ return { electronKill, resolveElectron: () => resolveElectron() };
470
+ }
471
+
472
+ it("creates esbuild context with banner for env and spawns Electron", async () => {
473
+ const { resolveElectron } = setupCpxForRun();
474
+
475
+ const { Electron } = await import("../../src/electron/electron.js");
476
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
477
+
478
+ const runPromise = electron.run("http://localhost:4200");
479
+
480
+ // Give event loop time to set up
481
+ await new Promise((r) => setTimeout(r, 10));
482
+
483
+ // Resolve Electron process (simulating Electron exit)
484
+ resolveElectron();
485
+ await runPromise;
486
+
487
+ // esbuild.context was called with banner containing ELECTRON_DEV_URL
488
+ const callArgs = mockEsbuildContext.mock.calls[0][0];
489
+ const banner = callArgs.banner?.js as string;
490
+ expect(banner).toContain("process.env");
491
+ expect(banner).toContain("??=");
492
+ expect(banner).toContain("ELECTRON_DEV_URL");
493
+ expect(banner).toContain("http://localhost:4200");
494
+ expect(callArgs.define).toBeUndefined();
495
+
496
+ expect(callArgs.platform).toBe("node");
497
+ expect(callArgs.target).toBe("node24");
498
+ expect(callArgs.format).toBe("esm");
499
+ expect(callArgs.bundle).toBe(true);
500
+ expect(callArgs.external).toContain("electron");
501
+ const electronCall = mockCpxSpawn.mock.calls.find(
502
+ (c) => c[0] === "bun" && c[1].includes("electron"),
503
+ );
504
+ expect(electronCall?.[2]).toEqual(expect.objectContaining({ shell: true, reject: false }));
505
+
506
+ // ESM 배너에 createRequire shim 포함
507
+ expect(banner).toContain("createRequire");
508
+ expect(banner).toContain("import.meta.url");
509
+ }, 10_000);
510
+
511
+ it("throws when electron-main.ts entry point is missing", async () => {
512
+ mockFsxExists.mockImplementation((p: string) => {
513
+ if (typeof p === "string" && p.includes("electron-main.ts")) return Promise.resolve(false);
514
+ return Promise.resolve(true);
515
+ });
516
+
517
+ const { Electron } = await import("../../src/electron/electron.js");
518
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
519
+
520
+ await expect(electron.run("http://localhost:4200")).rejects.toThrow("electron-main.ts");
521
+ });
522
+
523
+ it("resolves on SIGINT signal", async () => {
524
+ const { electronKill } = setupCpxForRun();
525
+
526
+ const { Electron } = await import("../../src/electron/electron.js");
527
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
528
+
529
+ const runPromise = electron.run("http://localhost:4200");
530
+
531
+ // Give the event loop time to set up signal handlers
532
+ await new Promise((r) => setTimeout(r, 10));
533
+
534
+ // Emit SIGINT to trigger cleanup
535
+ process.emit("SIGINT" as any);
536
+
537
+ // run() should resolve after signal
538
+ await runPromise;
539
+
540
+ expect(electronKill).toHaveBeenCalled();
541
+ }, 10_000);
542
+ });
543
+
544
+ describe("단위: run() 플러그인 동작", () => {
545
+ it("passes custom env and ELECTRON_DEV_URL via esbuild banner", async () => {
546
+ let resolveElectron: () => void = () => {};
547
+ mockCpxSpawn.mockImplementation((cmd: string, args: string[]) => {
548
+ if (cmd === "bun" && args[0] === "run" && args[1] === "electron" && args[2] === ".") {
549
+ const p = new Promise<void>((resolve) => {
550
+ resolveElectron = resolve;
551
+ }) as any;
552
+ p.kill = vi.fn();
553
+ return p;
554
+ }
555
+ return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 });
556
+ });
557
+
558
+ const { Electron } = await import("../../src/electron/electron.js");
559
+ const electron = await Electron.create(PKG_PATH, {
560
+ appId: "com.test.app",
561
+ env: { CUSTOM_VAR: "test-value" },
562
+ });
563
+
564
+ const runPromise = electron.run("http://localhost:5555");
565
+ await new Promise((r) => setTimeout(r, 10));
566
+ resolveElectron();
567
+ await runPromise;
568
+
569
+ const callArgs = mockEsbuildContext.mock.calls[0][0];
570
+ const banner = callArgs.banner?.js as string;
571
+ expect(banner).toContain("ELECTRON_DEV_URL");
572
+ expect(banner).toContain("http://localhost:5555");
573
+ expect(banner).toContain("CUSTOM_VAR");
574
+ expect(banner).toContain("test-value");
575
+ expect(callArgs.define).toBeUndefined();
576
+ }, 10_000);
577
+
578
+ it("calls initialize() before starting esbuild context", async () => {
579
+ let resolveElectron: () => void = () => {};
580
+ mockCpxSpawn.mockImplementation((cmd: string, args: string[]) => {
581
+ if (cmd === "bun" && args[0] === "run" && args[1] === "electron" && args[2] === ".") {
582
+ const p = new Promise<void>((resolve) => {
583
+ resolveElectron = resolve;
584
+ }) as any;
585
+ p.kill = vi.fn();
586
+ return p;
587
+ }
588
+ return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 });
589
+ });
590
+
591
+ const { Electron } = await import("../../src/electron/electron.js");
592
+ const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
593
+
594
+ const runPromise = electron.run("http://localhost:4200");
595
+ await new Promise((r) => setTimeout(r, 10));
596
+ resolveElectron();
597
+ await runPromise;
598
+
599
+ // initialize calls bun install
600
+ const bunInstallCall = mockCpxSpawn.mock.calls.find(
601
+ (c: any[]) => c[0] === "bun" && (c[1] as string[]).includes("install"),
602
+ );
603
+ expect(bunInstallCall).toBeDefined();
604
+ }, 10_000);
605
+ });
606
+
607
+ //#endregion
608
+ });