@simplysm/sd-cli 14.0.6 → 14.0.8

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 (58) hide show
  1. package/dist/capacitor/capacitor.d.ts +1 -1
  2. package/dist/capacitor/capacitor.d.ts.map +1 -1
  3. package/dist/capacitor/capacitor.js +3 -4
  4. package/dist/capacitor/capacitor.js.map +1 -1
  5. package/dist/commands/check.js +2 -2
  6. package/dist/commands/check.js.map +1 -1
  7. package/dist/commands/publish.d.ts.map +1 -1
  8. package/dist/commands/publish.js +12 -13
  9. package/dist/commands/publish.js.map +1 -1
  10. package/dist/electron/electron.d.ts.map +1 -1
  11. package/dist/electron/electron.js +24 -19
  12. package/dist/electron/electron.js.map +1 -1
  13. package/dist/engines/BaseEngine.d.ts.map +1 -1
  14. package/dist/engines/BaseEngine.js +1 -0
  15. package/dist/engines/BaseEngine.js.map +1 -1
  16. package/dist/orchestrators/DevWatchOrchestrator.d.ts.map +1 -1
  17. package/dist/orchestrators/DevWatchOrchestrator.js +9 -3
  18. package/dist/orchestrators/DevWatchOrchestrator.js.map +1 -1
  19. package/dist/sd-cli.js +6 -6
  20. package/dist/sd-cli.js.map +1 -1
  21. package/dist/utils/esbuild-config.d.ts +7 -2
  22. package/dist/utils/esbuild-config.d.ts.map +1 -1
  23. package/dist/utils/esbuild-config.js +15 -12
  24. package/dist/utils/esbuild-config.js.map +1 -1
  25. package/dist/utils/vite-config.d.ts.map +1 -1
  26. package/dist/utils/vite-config.js +1 -2
  27. package/dist/utils/vite-config.js.map +1 -1
  28. package/dist/vitest-plugin.d.ts.map +1 -1
  29. package/dist/vitest-plugin.js +2 -0
  30. package/dist/vitest-plugin.js.map +1 -1
  31. package/dist/workers/server-build.worker.d.ts.map +1 -1
  32. package/dist/workers/server-build.worker.js +2 -3
  33. package/dist/workers/server-build.worker.js.map +1 -1
  34. package/package.json +4 -6
  35. package/src/capacitor/capacitor.ts +3 -4
  36. package/src/commands/check.ts +2 -2
  37. package/src/commands/publish.ts +12 -13
  38. package/src/electron/electron.ts +28 -21
  39. package/src/engines/BaseEngine.ts +1 -0
  40. package/src/orchestrators/DevWatchOrchestrator.ts +10 -3
  41. package/src/sd-cli.ts +9 -6
  42. package/src/utils/esbuild-config.ts +16 -12
  43. package/src/utils/vite-config.ts +1 -2
  44. package/src/vitest-plugin.ts +5 -0
  45. package/src/workers/server-build.worker.ts +2 -3
  46. package/tests/capacitor/capacitor-build.spec.ts +9 -7
  47. package/tests/capacitor/capacitor-icon.spec.ts +9 -7
  48. package/tests/capacitor/capacitor-init.spec.ts +8 -6
  49. package/tests/capacitor/capacitor-run.spec.ts +13 -11
  50. package/tests/capacitor/capacitor-workspace.spec.ts +8 -6
  51. package/tests/commands/check.spec.ts +16 -28
  52. package/tests/commands/publish.spec.ts +4 -4
  53. package/tests/electron/electron.spec.ts +69 -58
  54. package/tests/orchestrators/build-orchestrator.spec.ts +4 -9
  55. package/tests/orchestrators/dev-watch-orchestrator.spec.ts +3 -5
  56. package/tests/utils/esbuild-config.spec.ts +38 -8
  57. package/tests/utils/vite-config.spec.ts +13 -0
  58. package/tests/workers/server-build-worker.spec.ts +6 -3
@@ -35,8 +35,11 @@ vi.mock("../../src/utils/sd-config", () => ({
35
35
  loadSdConfig: mocks.loadSdConfig,
36
36
  }));
37
37
 
38
- vi.mock("execa", () => ({
39
- execa: mocks.execa,
38
+ vi.mock("@simplysm/core-node", () => ({
39
+ cpx: {
40
+ exec: mocks.execa,
41
+ execSync: vi.fn().mockReturnValue({ stdout: "", stderr: "", exitCode: 0 }),
42
+ },
40
43
  }));
41
44
 
42
45
  vi.mock("../../src/utils/package-utils", async (importOriginal) => {
@@ -117,21 +120,20 @@ describe("runCheck", () => {
117
120
  writeSpy.mockRestore();
118
121
  });
119
122
 
120
- it("runs typecheck+lint and test, outputs ALL PASSED", async () => {
123
+ it("runs typecheck+lint and test", async () => {
121
124
  await runCheck({ targets: [], types: ["typecheck", "lint", "test"], fix: false });
122
125
 
123
126
  expect(mocks.executeTypecheck).toHaveBeenCalled();
124
127
  expect(mocks.execa).toHaveBeenCalled();
125
- expect(stdoutOutput).toContain("ALL PASSED");
126
128
  });
127
129
 
128
- it("outputs results in TYPECHECK → LINT → TEST → SUMMARY order", async () => {
130
+ it("outputs results in TYPECHECK → LINT → TEST → 요약 order", async () => {
129
131
  await runCheck({ targets: [], types: ["typecheck", "lint", "test"], fix: false });
130
132
 
131
133
  const tcIdx = stdoutOutput.indexOf("TYPECHECK");
132
134
  const lintIdx = stdoutOutput.indexOf("LINT");
133
135
  const testIdx = stdoutOutput.indexOf("TEST");
134
- const summaryIdx = stdoutOutput.indexOf("SUMMARY");
136
+ const summaryIdx = stdoutOutput.indexOf("요약");
135
137
 
136
138
  expect(tcIdx).toBeLessThan(lintIdx);
137
139
  expect(lintIdx).toBeLessThan(testIdx);
@@ -146,7 +148,7 @@ describe("runCheck", () => {
146
148
  expect(mocks.execa).toHaveBeenCalled();
147
149
  });
148
150
 
149
- it("outputs FAILED with failing check names when some fail", async () => {
151
+ it("sets exitCode 1 when typecheck fails", async () => {
150
152
  mocks.executeTypecheck.mockResolvedValue({
151
153
  success: false, errorCount: 2, warningCount: 0, formattedOutput: "type errors",
152
154
  lint: { success: true, errorCount: 0, warningCount: 0, formattedOutput: "" },
@@ -155,8 +157,6 @@ describe("runCheck", () => {
155
157
 
156
158
  await runCheck({ targets: [], types: ["typecheck", "lint", "test"], fix: false });
157
159
 
158
- expect(stdoutOutput).toContain("FAILED");
159
- expect(stdoutOutput).toContain("typecheck");
160
160
  expect(process.exitCode).toBe(1);
161
161
  });
162
162
 
@@ -170,14 +170,13 @@ describe("runCheck", () => {
170
170
  expect(process.exitCode).toBe(1);
171
171
  });
172
172
 
173
- it("parses test failure count from vitest output", async () => {
173
+ it("sets exitCode 1 when test fails", async () => {
174
174
  mocks.execa.mockResolvedValue({
175
175
  stdout: "3 tests failed", stderr: "", exitCode: 1,
176
176
  });
177
177
 
178
178
  await runCheck({ targets: [], types: ["test"], fix: false });
179
179
 
180
- expect(stdoutOutput).toContain("3 failed");
181
180
  expect(process.exitCode).toBe(1);
182
181
  });
183
182
 
@@ -271,8 +270,8 @@ describe("runCheck", () => {
271
270
  expect(mocks.runLintInWorker).not.toHaveBeenCalled();
272
271
  });
273
272
 
274
- // Scenario: check 결과 출력에 LINT 섹션이 포함된다
275
- it("outputs separate TYPECHECK and LINT sections from engine results", async () => {
273
+ // Scenario: lint 실패 exitCode 설정
274
+ it("sets exitCode 1 when engine lint fails", async () => {
276
275
  mocks.executeTypecheck.mockResolvedValue({
277
276
  success: true, errorCount: 0, warningCount: 0, formattedOutput: "",
278
277
  lint: { success: false, errorCount: 3, warningCount: 1, formattedOutput: "some lint output" },
@@ -281,10 +280,7 @@ describe("runCheck", () => {
281
280
 
282
281
  await runCheck({ targets: [], types: ["typecheck", "lint"], fix: false });
283
282
 
284
- expect(stdoutOutput).toContain("TYPECHECK");
285
- expect(stdoutOutput).toContain("LINT");
286
- expect(stdoutOutput).toContain("3 errors");
287
- expect(stdoutOutput).toContain("1 warnings");
283
+ expect(process.exitCode).toBe(1);
288
284
  });
289
285
 
290
286
  // Scenario: check에서 scripts 패키지의 lint가 별도 실행된다
@@ -391,43 +387,35 @@ describe("runCheck", () => {
391
387
  });
392
388
 
393
389
  // Scenario: lint 에러 없음
394
- it("outputs ALL PASSED when lint has no errors", async () => {
390
+ it("does not set exitCode when lint passes", async () => {
395
391
  mocks.executeLint.mockResolvedValue({
396
392
  success: true, errorCount: 0, warningCount: 0, formattedOutput: "",
397
393
  });
398
394
 
399
395
  await runCheck({ targets: [], types: ["lint"], fix: false });
400
396
 
401
- expect(stdoutOutput).toContain("LINT");
402
- expect(stdoutOutput).toContain("✔ 0 errors, 0 warnings");
403
- expect(stdoutOutput).toContain("✔ ALL PASSED");
404
397
  expect(process.exitCode).toBeUndefined();
405
398
  });
406
399
 
407
400
  // Scenario: lint 에러 발생
408
- it("outputs FAILED when lint has errors", async () => {
401
+ it("sets exitCode 1 when lint has errors", async () => {
409
402
  mocks.executeLint.mockResolvedValue({
410
403
  success: false, errorCount: 3, warningCount: 2, formattedOutput: "lint errors",
411
404
  });
412
405
 
413
406
  await runCheck({ targets: [], types: ["lint"], fix: false });
414
407
 
415
- expect(stdoutOutput).toContain("LINT");
416
- expect(stdoutOutput).toContain("✖ 3 errors, 2 warnings");
417
- expect(stdoutOutput).toContain("✖ 1/1 FAILED (lint)");
418
408
  expect(process.exitCode).toBe(1);
419
409
  });
420
410
 
421
411
  // Scenario: lint 대상 파일 없음
422
- it("outputs ALL PASSED when no files to lint", async () => {
412
+ it("does not set exitCode when no files to lint", async () => {
423
413
  mocks.executeLint.mockResolvedValue({
424
414
  success: true, errorCount: 0, warningCount: 0, formattedOutput: "",
425
415
  });
426
416
 
427
417
  await runCheck({ targets: [], types: ["lint"], fix: false });
428
418
 
429
- expect(stdoutOutput).toContain("✔ 0 errors, 0 warnings");
430
- expect(stdoutOutput).toContain("✔ ALL PASSED");
431
419
  expect(process.exitCode).toBeUndefined();
432
420
  });
433
421
 
@@ -48,12 +48,12 @@ vi.mock("../../src/utils/replace-deps", () => ({
48
48
  parseWorkspaceGlobs: mocks.parseWorkspaceGlobs,
49
49
  }));
50
50
 
51
- vi.mock("execa", () => ({
52
- execa: mocks.execa,
53
- }));
54
-
55
51
  vi.mock("@simplysm/core-node", () => ({
56
52
  fsx: mocks.fsx,
53
+ cpx: {
54
+ exec: mocks.execa,
55
+ execSync: vi.fn().mockReturnValue({ stdout: "", stderr: "", exitCode: 0 }),
56
+ },
57
57
  }));
58
58
 
59
59
  vi.mock("@simplysm/core-common", () => {
@@ -21,13 +21,14 @@ vi.mock("@simplysm/core-node", () => ({
21
21
  readdir: mockFsxReaddir,
22
22
  glob: mockFsxGlob,
23
23
  },
24
+ cpx: {
25
+ exec: mockCpxExec,
26
+ execSync: vi.fn().mockReturnValue({ stdout: "", stderr: "", exitCode: 0 }),
27
+ },
24
28
  }));
25
29
 
26
- // execa mock
27
- const mockExeca = vi.fn().mockResolvedValue({ stdout: "" });
28
- vi.mock("execa", () => ({
29
- execa: mockExeca,
30
- }));
30
+ // cpx mock (was execa)
31
+ const mockCpxExec = vi.fn().mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 });
31
32
 
32
33
  // esbuild mock
33
34
  const mockEsbuildBuild = vi.fn().mockResolvedValue({});
@@ -91,13 +92,15 @@ function setupDefaultMocks() {
91
92
  });
92
93
  mockFsxReaddir.mockResolvedValue(["index.html", "assets", "electron"]);
93
94
  // Default: glob returns one exe file matching the builder output
94
- mockFsxGlob.mockImplementation((pattern: string, opts?: { cwd?: string }) => {
95
- if (pattern === "*.exe" && opts?.cwd != null) {
96
- return Promise.resolve([opts.cwd.replace(/\\/g, "/") + "/My App Setup 1.0.0.exe"]);
95
+ mockFsxGlob.mockImplementation((pattern: string) => {
96
+ const normalized = pattern.replace(/\\/g, "/");
97
+ if (normalized.endsWith(".electron/dist/*.exe")) {
98
+ const dir = normalized.replace("/*.exe", "");
99
+ return Promise.resolve([dir + "/My App Setup 1.0.0.exe"]);
97
100
  }
98
101
  return Promise.resolve([]);
99
102
  });
100
- mockExeca.mockResolvedValue({ stdout: "" });
103
+ mockCpxExec.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 });
101
104
  mockEsbuildBuild.mockResolvedValue({});
102
105
  }
103
106
 
@@ -171,7 +174,7 @@ describe("Electron", () => {
171
174
 
172
175
  expect(findElectronPackageJson()).toBeDefined();
173
176
 
174
- const execaCalls = mockExeca.mock.calls;
177
+ const execaCalls = mockCpxExec.mock.calls;
175
178
  expect(
176
179
  execaCalls.find((c) => c[0] === "npm" && (c[1] as string[]).includes("install")),
177
180
  ).toBeDefined();
@@ -188,7 +191,7 @@ describe("Electron", () => {
188
191
  const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
189
192
  await electron.initialize();
190
193
 
191
- const rebuildCall = mockExeca.mock.calls.find(
194
+ const rebuildCall = mockCpxExec.mock.calls.find(
192
195
  (c) => typeof c[0] === "string" && c[0].includes("electron-rebuild"),
193
196
  );
194
197
  expect(rebuildCall).toBeUndefined();
@@ -278,6 +281,25 @@ describe("Electron", () => {
278
281
 
279
282
  await expect(electron.build("/fake/out")).rejects.toThrow("electron-main.ts");
280
283
  });
284
+
285
+ it("config.env를 esbuild banner로 주입한다 (ELECTRON_DEV_URL 미포함)", async () => {
286
+ const { Electron } = await import("../../src/electron/electron.js");
287
+
288
+ const electron = await Electron.create(PKG_PATH, {
289
+ appId: "com.test.app",
290
+ env: { API_URL: "https://api.example.com" },
291
+ });
292
+ await electron.build("/fake/out");
293
+
294
+ const callArgs = mockEsbuildBuild.mock.calls[0][0];
295
+ const banner = callArgs.banner?.js as string;
296
+ expect(banner).toContain("process.env");
297
+ expect(banner).toContain("??=");
298
+ expect(banner).toContain("API_URL");
299
+ expect(banner).toContain("https://api.example.com");
300
+ expect(banner).not.toContain("ELECTRON_DEV_URL");
301
+ expect(callArgs.define).toBeUndefined();
302
+ });
281
303
  });
282
304
 
283
305
  //#endregion
@@ -399,7 +421,8 @@ describe("Electron", () => {
399
421
 
400
422
  it("빌드 산출물이 없으면 경고를 출력한다", async () => {
401
423
  mockFsxGlob.mockImplementation((pattern: string) => {
402
- if (pattern === "*.exe") return Promise.resolve([]);
424
+ const normalized = pattern.replace(/\\/g, "/");
425
+ if (normalized.endsWith(".electron/dist/*.exe")) return Promise.resolve([]);
403
426
  return Promise.resolve([]);
404
427
  });
405
428
 
@@ -417,15 +440,15 @@ describe("Electron", () => {
417
440
  //#region Rule: 개발 모드에서 Electron 앱을 실행한다
418
441
 
419
442
  describe("인수 테스트: run()", () => {
420
- // Helper: creates a deferred execa mock where Electron process can be controlled
421
- function setupExecaForRun(): {
443
+ // Helper: creates a deferred cpx mock where Electron process can be controlled
444
+ function setupCpxForRun(): {
422
445
  electronKill: ReturnType<typeof vi.fn>;
423
446
  resolveElectron: () => void;
424
447
  } {
425
448
  const electronKill = vi.fn();
426
449
  let resolveElectron: () => void = () => {};
427
450
 
428
- mockExeca.mockImplementation((cmd: string) => {
451
+ mockCpxExec.mockImplementation((cmd: string) => {
429
452
  if (typeof cmd === "string" && cmd.includes("electron")) {
430
453
  // Electron process: create a deferred promise we can resolve externally
431
454
  const p = new Promise<void>((resolve) => {
@@ -434,14 +457,14 @@ describe("Electron", () => {
434
457
  p.kill = electronKill;
435
458
  return p;
436
459
  }
437
- return Promise.resolve({ stdout: "" });
460
+ return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 });
438
461
  });
439
462
 
440
463
  return { electronKill, resolveElectron: () => resolveElectron() };
441
464
  }
442
465
 
443
- it("creates esbuild context and spawns Electron on build success", async () => {
444
- const { resolveElectron } = setupExecaForRun();
466
+ it("creates esbuild context with banner for env and spawns Electron", async () => {
467
+ const { resolveElectron } = setupCpxForRun();
445
468
 
446
469
  const { Electron } = await import("../../src/electron/electron.js");
447
470
  const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
@@ -455,28 +478,20 @@ describe("Electron", () => {
455
478
  resolveElectron();
456
479
  await runPromise;
457
480
 
458
- // esbuild.context was called
459
- expect(mockEsbuildContext).toHaveBeenCalledWith(
460
- expect.objectContaining({
461
- platform: "node",
462
- target: "node20",
463
- format: "cjs",
464
- bundle: true,
465
- external: expect.arrayContaining(["electron"]),
466
- }),
467
- );
468
-
469
- // Electron was spawned via execa
470
- const electronCall = mockExeca.mock.calls.find(
471
- (c: any[]) => typeof c[0] === "string" && c[0].includes("electron"),
472
- );
473
- expect(electronCall).toBeDefined();
474
- expect(electronCall![2].env).toEqual(
475
- expect.objectContaining({
476
- NODE_ENV: "development",
477
- ELECTRON_DEV_URL: "http://localhost:4200",
478
- }),
479
- );
481
+ // esbuild.context was called with banner containing ELECTRON_DEV_URL
482
+ const callArgs = mockEsbuildContext.mock.calls[0][0];
483
+ const banner = callArgs.banner?.js as string;
484
+ expect(banner).toContain("process.env");
485
+ expect(banner).toContain("??=");
486
+ expect(banner).toContain("ELECTRON_DEV_URL");
487
+ expect(banner).toContain("http://localhost:4200");
488
+ expect(callArgs.define).toBeUndefined();
489
+
490
+ expect(callArgs.platform).toBe("node");
491
+ expect(callArgs.target).toBe("node20");
492
+ expect(callArgs.format).toBe("cjs");
493
+ expect(callArgs.bundle).toBe(true);
494
+ expect(callArgs.external).toContain("electron");
480
495
  }, 10_000);
481
496
 
482
497
  it("throws when electron-main.ts entry point is missing", async () => {
@@ -492,7 +507,7 @@ describe("Electron", () => {
492
507
  });
493
508
 
494
509
  it("resolves on SIGINT signal", async () => {
495
- const { electronKill } = setupExecaForRun();
510
+ const { electronKill } = setupCpxForRun();
496
511
 
497
512
  const { Electron } = await import("../../src/electron/electron.js");
498
513
  const electron = await Electron.create(PKG_PATH, { appId: "com.test.app" });
@@ -513,9 +528,9 @@ describe("Electron", () => {
513
528
  });
514
529
 
515
530
  describe("단위: run() 플러그인 동작", () => {
516
- it("passes custom env and ELECTRON_DEV_URL to spawned Electron process", async () => {
531
+ it("passes custom env and ELECTRON_DEV_URL via esbuild banner", async () => {
517
532
  let resolveElectron: () => void = () => {};
518
- mockExeca.mockImplementation((cmd: string) => {
533
+ mockCpxExec.mockImplementation((cmd: string) => {
519
534
  if (typeof cmd === "string" && cmd.includes("electron")) {
520
535
  const p = new Promise<void>((resolve) => {
521
536
  resolveElectron = resolve;
@@ -523,7 +538,7 @@ describe("Electron", () => {
523
538
  p.kill = vi.fn();
524
539
  return p;
525
540
  }
526
- return Promise.resolve({ stdout: "" });
541
+ return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 });
527
542
  });
528
543
 
529
544
  const { Electron } = await import("../../src/electron/electron.js");
@@ -537,22 +552,18 @@ describe("Electron", () => {
537
552
  resolveElectron();
538
553
  await runPromise;
539
554
 
540
- const execaCall = mockExeca.mock.calls.find(
541
- (c: any[]) => typeof c[0] === "string" && c[0].includes("electron"),
542
- );
543
- expect(execaCall).toBeDefined();
544
- expect(execaCall![2].env).toEqual(
545
- expect.objectContaining({
546
- NODE_ENV: "development",
547
- ELECTRON_DEV_URL: "http://localhost:5555",
548
- CUSTOM_VAR: "test-value",
549
- }),
550
- );
555
+ const callArgs = mockEsbuildContext.mock.calls[0][0];
556
+ const banner = callArgs.banner?.js as string;
557
+ expect(banner).toContain("ELECTRON_DEV_URL");
558
+ expect(banner).toContain("http://localhost:5555");
559
+ expect(banner).toContain("CUSTOM_VAR");
560
+ expect(banner).toContain("test-value");
561
+ expect(callArgs.define).toBeUndefined();
551
562
  }, 10_000);
552
563
 
553
564
  it("calls initialize() before starting esbuild context", async () => {
554
565
  let resolveElectron: () => void = () => {};
555
- mockExeca.mockImplementation((cmd: string) => {
566
+ mockCpxExec.mockImplementation((cmd: string) => {
556
567
  if (typeof cmd === "string" && cmd.includes("electron")) {
557
568
  const p = new Promise<void>((resolve) => {
558
569
  resolveElectron = resolve;
@@ -560,7 +571,7 @@ describe("Electron", () => {
560
571
  p.kill = vi.fn();
561
572
  return p;
562
573
  }
563
- return Promise.resolve({ stdout: "" });
574
+ return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 });
564
575
  });
565
576
 
566
577
  const { Electron } = await import("../../src/electron/electron.js");
@@ -572,7 +583,7 @@ describe("Electron", () => {
572
583
  await runPromise;
573
584
 
574
585
  // initialize calls npm install -> execa should have been called with npm install
575
- const npmInstallCall = mockExeca.mock.calls.find(
586
+ const npmInstallCall = mockCpxExec.mock.calls.find(
576
587
  (c: any[]) => c[0] === "npm" && (c[1] as string[]).includes("install"),
577
588
  );
578
589
  expect(npmInstallCall).toBeDefined();
@@ -221,7 +221,7 @@ describe("BuildOrchestrator.initialize", () => {
221
221
  );
222
222
  });
223
223
 
224
- it("outputs message when no packages to build", async () => {
224
+ it("returns false when only scripts packages exist", async () => {
225
225
  setupDefaults({
226
226
  packages: {
227
227
  "sd-claude": { target: "scripts" } as any,
@@ -230,10 +230,9 @@ describe("BuildOrchestrator.initialize", () => {
230
230
 
231
231
  const orchestrator = new BuildOrchestrator({ targets: [], options: [] });
232
232
  await orchestrator.initialize();
233
+ const hasError = await orchestrator.start();
233
234
 
234
- expect(process.stdout.write).toHaveBeenCalledWith(
235
- expect.stringContaining("No packages to build"),
236
- );
235
+ expect(hasError).toBe(false);
237
236
  });
238
237
  });
239
238
 
@@ -403,10 +402,9 @@ describe("BuildOrchestrator.start", () => {
403
402
  const hasError = await orchestrator.start();
404
403
 
405
404
  expect(hasError).toBe(false);
406
- expect(mockLogger.info).toHaveBeenCalledWith("Build completed");
407
405
  });
408
406
 
409
- it("returns true and logs error when any build fails", async () => {
407
+ it("returns true when any build fails", async () => {
410
408
  setupDefaults({
411
409
  packages: {
412
410
  "core-common": { target: "neutral", publish: { type: "npm" } },
@@ -429,7 +427,6 @@ describe("BuildOrchestrator.start", () => {
429
427
  const hasError = await orchestrator.start();
430
428
 
431
429
  expect(hasError).toBe(true);
432
- expect(mockLogger.error).toHaveBeenCalledWith("Build failed");
433
430
  });
434
431
 
435
432
  it("returns false when no packages to build", async () => {
@@ -658,7 +655,6 @@ describe("BuildOrchestrator client build", () => {
658
655
  const hasError = await orchestrator.start();
659
656
 
660
657
  expect(hasError).toBe(true);
661
- expect(mockLogger.error).toHaveBeenCalledWith("Build failed");
662
658
  });
663
659
 
664
660
  // Acceptance: Scenario "BuildOrchestrator가 env를 ViteEngine에 전달" + "프로덕션 빌드의 baseEnv"
@@ -1049,7 +1045,6 @@ describe("BuildOrchestrator lint integration", () => {
1049
1045
  const hasError = await orchestrator.start();
1050
1046
 
1051
1047
  expect(hasError).toBe(true);
1052
- expect(mockLogger.error).toHaveBeenCalledWith("Build failed");
1053
1048
  });
1054
1049
 
1055
1050
  // Scenario: build에서 scripts 패키지는 제외된다
@@ -300,7 +300,6 @@ describe("DevWatchOrchestrator", () => {
300
300
  const orchestrator = new DevWatchOrchestrator({ mode: "watch", targets: [], options: [] });
301
301
  await orchestrator.initialize();
302
302
 
303
- expect(process.stdout.write).toHaveBeenCalledWith(expect.stringContaining("No packages"));
304
303
  expect(createBuildEngine).not.toHaveBeenCalled();
305
304
  });
306
305
 
@@ -872,7 +871,6 @@ describe("DevWatchOrchestrator", () => {
872
871
 
873
872
  expect(mockBuildEngines[0].stop).toHaveBeenCalledOnce();
874
873
  expect(mockRuntimeProxies[0].terminate).toHaveBeenCalled();
875
- expect(process.stdout.write).toHaveBeenCalledWith(expect.stringContaining("Shutting down"));
876
874
  });
877
875
 
878
876
  // --- Acceptance: dev에서 replaceDeps 감시 ---
@@ -890,8 +888,8 @@ describe("DevWatchOrchestrator", () => {
890
888
  expect(watchReplaceDeps).toHaveBeenCalledWith("/test-root", replaceDeps);
891
889
  });
892
890
 
893
- // Unit: outputs warning when no server packages
894
- it("outputs warning when no server packages in dev mode", async () => {
891
+ // Unit: no server packages in dev mode — does not create runtime proxies
892
+ it("does not create runtime proxies when no server packages in dev mode", async () => {
895
893
  setupDefaults(createConfig({
896
894
  packages: { "core-common": { target: "node" } },
897
895
  }));
@@ -899,7 +897,7 @@ describe("DevWatchOrchestrator", () => {
899
897
  const orchestrator = new DevWatchOrchestrator({ mode: "dev", targets: [], options: [] });
900
898
  await orchestrator.initialize();
901
899
 
902
- expect(process.stdout.write).toHaveBeenCalledWith(expect.stringContaining("No"));
900
+ expect(createBuildEngine).not.toHaveBeenCalled();
903
901
  });
904
902
 
905
903
  // Unit: multiple server packages
@@ -17,7 +17,7 @@ vi.mock("consola", () => ({
17
17
  },
18
18
  }));
19
19
 
20
- const { createServerEsbuildOptions, writeChangedOutputFiles } =
20
+ const { createServerEsbuildOptions, createEnvBanner, writeChangedOutputFiles } =
21
21
  await import("../../src/utils/esbuild-config");
22
22
 
23
23
  const { default: mockFs } = await import("fs/promises");
@@ -53,20 +53,25 @@ describe("createServerEsbuildOptions", () => {
53
53
  expect((result.banner as Record<string, string>)["js"]).toContain("import.meta.url");
54
54
  });
55
55
 
56
- it("substitutes env vars via define (process.env.KEY constant)", () => {
56
+ it("injects env vars via banner (process.env merge) instead of define", () => {
57
57
  const result = createServerEsbuildOptions({
58
58
  ...baseOptions,
59
59
  env: { API_URL: "https://api.example.com", NODE_ENV: "production" },
60
60
  });
61
- expect(result.define).toEqual({
62
- "process.env.API_URL": '"https://api.example.com"',
63
- "process.env.NODE_ENV": '"production"',
64
- });
61
+ const banner = (result.banner as Record<string, string>)["js"];
62
+ expect(banner).toContain("process.env");
63
+ expect(banner).toContain("??=");
64
+ expect(banner).toContain("API_URL");
65
+ expect(banner).toContain("https://api.example.com");
66
+ expect(result.define).toBeUndefined();
65
67
  });
66
68
 
67
- it("produces empty define when env is not provided", () => {
69
+ it("does not include env code in banner when env is not provided", () => {
68
70
  const result = createServerEsbuildOptions(baseOptions);
69
- expect(result.define).toEqual({});
71
+ const banner = (result.banner as Record<string, string>)["js"];
72
+ expect(banner).toContain("createRequire");
73
+ expect(banner).not.toContain("??=");
74
+ expect(result.define).toBeUndefined();
70
75
  });
71
76
 
72
77
  it("passes external modules to esbuild", () => {
@@ -88,6 +93,31 @@ describe("createServerEsbuildOptions", () => {
88
93
  });
89
94
  });
90
95
 
96
+ describe("createEnvBanner", () => {
97
+ it("generates process.env merge code with ??= for runtime override", () => {
98
+ const banner = createEnvBanner({ API_URL: "https://api.example.com", NODE_ENV: "production" });
99
+ expect(banner).toContain("process.env");
100
+ expect(banner).toContain("??=");
101
+ expect(banner).toContain('"API_URL"');
102
+ expect(banner).toContain('"https://api.example.com"');
103
+ expect(banner).toContain('"NODE_ENV"');
104
+ expect(banner).toContain('"production"');
105
+ });
106
+
107
+ it("returns empty string when env is undefined", () => {
108
+ expect(createEnvBanner()).toBe("");
109
+ });
110
+
111
+ it("returns empty string when env is empty object", () => {
112
+ expect(createEnvBanner({})).toBe("");
113
+ });
114
+
115
+ it("JSON-encodes special characters in values", () => {
116
+ const banner = createEnvBanner({ MSG: 'hello "world"' });
117
+ expect(banner).toContain('\\"world\\"');
118
+ });
119
+ });
120
+
91
121
  describe("writeChangedOutputFiles", () => {
92
122
  beforeEach(() => {
93
123
  vi.mocked(mockFs.readFile).mockReset();
@@ -67,6 +67,19 @@ afterEach(() => {
67
67
  });
68
68
 
69
69
  describe("createClientViteConfig", () => {
70
+ // Acceptance: Scenario "define['process.env'] 제거"
71
+ it("does not include process.env in define, only import.meta.env keys", async () => {
72
+ const config = await createClientViteConfig({
73
+ ...createDefaultOptions(),
74
+ env: { DEV: "true", VER: "1.0.0" },
75
+ });
76
+
77
+ const define = config.define as Record<string, string>;
78
+ expect(define).not.toHaveProperty("process.env");
79
+ expect(define["import.meta.env.DEV"]).toBe('"true"');
80
+ expect(define["import.meta.env.VER"]).toBe('"1.0.0"');
81
+ });
82
+
70
83
  // Acceptance: Scenario "browserslist 미설정 시 최신 브라우저 유지"
71
84
  it("uses es2022 esbuild target when no browserslist is provided", async () => {
72
85
  const config = await createClientViteConfig(createDefaultOptions());
@@ -35,6 +35,8 @@ const mockRunTscPackageBuild = vi.fn(() => ({
35
35
  warningCount: 0,
36
36
  }));
37
37
 
38
+ const mockCpxExecSync = vi.fn().mockReturnValue({ stdout: "v20.11.0", stderr: "", exitCode: 0 });
39
+
38
40
  vi.mock("@simplysm/core-node", () => ({
39
41
  createWorker: vi.fn((fns: Record<string, Function>) => {
40
42
  workerFns = fns as any;
@@ -50,6 +52,10 @@ vi.mock("@simplysm/core-node", () => ({
50
52
  pathx: {
51
53
  norm: vi.fn((...args: string[]) => path.resolve(...args).replace(/\\/g, "/")),
52
54
  },
55
+ cpx: {
56
+ exec: vi.fn().mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 }),
57
+ execSync: mockCpxExecSync,
58
+ },
53
59
  }));
54
60
 
55
61
  vi.mock("@simplysm/core-common", () => ({
@@ -95,9 +101,6 @@ vi.mock("fs", () => ({
95
101
  existsSync: (...args: unknown[]) => mockExistsSync(...(args as [string])),
96
102
  }));
97
103
 
98
- vi.mock("execa", () => ({
99
- execaSync: vi.fn(() => ({ stdout: "v20.11.0" })),
100
- }));
101
104
 
102
105
  // Mock lockfile content for resolveLockedVersion
103
106
  let mockLockfileContent = "";