@simplysm/sd-cli 14.0.12 → 14.0.13

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 (41) hide show
  1. package/dist/angular/vite-angular-plugin.d.ts +3 -1
  2. package/dist/angular/vite-angular-plugin.d.ts.map +1 -1
  3. package/dist/angular/vite-angular-plugin.js +15 -4
  4. package/dist/angular/vite-angular-plugin.js.map +1 -1
  5. package/dist/capacitor/capacitor.d.ts +10 -4
  6. package/dist/capacitor/capacitor.d.ts.map +1 -1
  7. package/dist/capacitor/capacitor.js +34 -21
  8. package/dist/capacitor/capacitor.js.map +1 -1
  9. package/dist/commands/publish.d.ts.map +1 -1
  10. package/dist/commands/publish.js +7 -1
  11. package/dist/commands/publish.js.map +1 -1
  12. package/dist/engines/ViteEngine.d.ts +6 -0
  13. package/dist/engines/ViteEngine.d.ts.map +1 -1
  14. package/dist/engines/ViteEngine.js +6 -0
  15. package/dist/engines/ViteEngine.js.map +1 -1
  16. package/dist/engines/index.d.ts +4 -0
  17. package/dist/engines/index.d.ts.map +1 -1
  18. package/dist/engines/index.js +2 -0
  19. package/dist/engines/index.js.map +1 -1
  20. package/dist/orchestrators/BuildOrchestrator.d.ts.map +1 -1
  21. package/dist/orchestrators/BuildOrchestrator.js +5 -1
  22. package/dist/orchestrators/BuildOrchestrator.js.map +1 -1
  23. package/dist/utils/vite-config.d.ts +4 -0
  24. package/dist/utils/vite-config.d.ts.map +1 -1
  25. package/dist/utils/vite-config.js +65 -18
  26. package/dist/utils/vite-config.js.map +1 -1
  27. package/dist/workers/client.worker.d.ts +4 -0
  28. package/dist/workers/client.worker.d.ts.map +1 -1
  29. package/dist/workers/client.worker.js +6 -5
  30. package/dist/workers/client.worker.js.map +1 -1
  31. package/package.json +4 -4
  32. package/src/angular/vite-angular-plugin.ts +22 -5
  33. package/src/capacitor/capacitor.ts +39 -28
  34. package/src/engines/ViteEngine.ts +10 -0
  35. package/src/engines/index.ts +6 -0
  36. package/src/orchestrators/BuildOrchestrator.ts +5 -1
  37. package/src/utils/vite-config.ts +70 -18
  38. package/src/workers/client.worker.ts +10 -5
  39. package/tests/capacitor/capacitor-build.spec.ts +87 -7
  40. package/tests/capacitor/capacitor-init.spec.ts +9 -5
  41. package/tests/utils/vite-config.spec.ts +21 -3
@@ -299,15 +299,6 @@ export class Capacitor {
299
299
  Capacitor._logger.debug("cap init 완료");
300
300
  }
301
301
 
302
- // 기본 www/index.html 생성
303
- const wwwPath = pathx.posixResolve(this._capPath, "www");
304
- await fsx.mkdir(wwwPath);
305
- await fsx.write(
306
- pathx.posixResolve(wwwPath, "index.html"),
307
- "<!DOCTYPE html><html><head></head><body></body></html>",
308
- );
309
- Capacitor._logger.debug("www/index.html 생성 완료");
310
-
311
302
  return true;
312
303
  }
313
304
 
@@ -798,11 +789,17 @@ export default config;
798
789
  }
799
790
 
800
791
  /**
801
- * styles.xml의 스플래시 테마 parent 변경
792
+ * styles.xml의 스플래시 테마 수정
802
793
  *
803
- * Theme.SplashScreen android:windowBackground에 compat_splash_screen을 설정하여
804
- * android:background(@drawable/splash)와 이중 표시를 발생시킨다.
805
- * installSplashScreen() 호출하지 않으므로 Theme.SplashScreen 기능이 불필요하다.
794
+ * 1. Theme.SplashScreen parent Theme.AppCompat.DayNight.NoActionBar
795
+ * Theme.SplashScreen은 android:windowBackground에 compat_splash_screen을 설정하여
796
+ * android:background(@drawable/splash) 이중 표시를 발생시킨다.
797
+ * installSplashScreen()을 호출하지 않으므로 Theme.SplashScreen 기능이 불필요하다.
798
+ *
799
+ * 2. android:background → android:windowBackground
800
+ * android:background는 View 레벨 속성으로 AppCompat 뷰 계층의 여러 View에 상속되어
801
+ * 동일한 splash 로고가 다중 레이어에 중복 렌더링된다.
802
+ * android:windowBackground는 Window의 DecorView에만 적용되어 단일 렌더링을 보장한다.
806
803
  */
807
804
  private async _configureAndroidStyles(androidPath: string): Promise<void> {
808
805
  const stylesPath = pathx.posixResolve(androidPath, "app/src/main/res/values/styles.xml");
@@ -813,17 +810,27 @@ export default config;
813
810
  }
814
811
 
815
812
  let content = await fsx.read(stylesPath);
813
+ let changed = false;
816
814
 
817
- if (!content.includes('parent="Theme.SplashScreen"')) {
818
- return;
815
+ if (content.includes('parent="Theme.SplashScreen"')) {
816
+ content = content.replace(
817
+ 'parent="Theme.SplashScreen"',
818
+ 'parent="Theme.AppCompat.DayNight.NoActionBar"',
819
+ );
820
+ changed = true;
819
821
  }
820
822
 
821
- content = content.replace(
822
- 'parent="Theme.SplashScreen"',
823
- 'parent="Theme.AppCompat.DayNight.NoActionBar"',
824
- );
823
+ if (content.includes('"android:background">@drawable/splash')) {
824
+ content = content.replace(
825
+ '"android:background">@drawable/splash',
826
+ '"android:windowBackground">@drawable/splash',
827
+ );
828
+ changed = true;
829
+ }
825
830
 
826
- await fsx.write(stylesPath, content);
831
+ if (changed) {
832
+ await fsx.write(stylesPath, content);
833
+ }
827
834
  }
828
835
 
829
836
  //#endregion
@@ -966,8 +973,9 @@ export default config;
966
973
  if (content.includes("signingConfigs")) return;
967
974
 
968
975
  const storeType = sign.keystoreType ?? "jks";
969
- const escapedStorePassword = sign.storePassword.replace(/'/g, "\\'");
970
- const escapedKeyPassword = sign.password.replace(/'/g, "\\'");
976
+ const escapeGroovy = (s: string) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
977
+ const escapedStorePassword = escapeGroovy(sign.storePassword);
978
+ const escapedKeyPassword = escapeGroovy(sign.password);
971
979
 
972
980
  const signingBlock = ` signingConfigs {
973
981
  release {
@@ -981,7 +989,7 @@ export default config;
981
989
  `;
982
990
 
983
991
  // signingConfigs 블록을 buildTypes 앞에 삽입
984
- content = content.replace(/(\s*buildTypes\s*\{)/, `${signingBlock}$1`);
992
+ content = content.replace(/(\s*buildTypes\s*\{)/, (match) => `\n${signingBlock}${match}`);
985
993
 
986
994
  // buildTypes.release에 signingConfig 추가
987
995
  content = content.replace(
@@ -1007,12 +1015,15 @@ export default config;
1007
1015
 
1008
1016
  const androidPath = pathx.posixResolve(this._capPath, "android");
1009
1017
  const isWindows = process.platform === "win32";
1010
- const gradlew = isWindows
1011
- ? pathx.posixResolve(androidPath, "gradlew.bat")
1012
- : pathx.posixResolve(androidPath, "gradlew");
1013
1018
 
1014
- Capacitor._logger.debug(`Gradle 실행: ${gradlew} ${gradleTask}`);
1015
- await this._exec(gradlew, [gradleTask, "--no-daemon"], androidPath);
1019
+ if (isWindows) {
1020
+ Capacitor._logger.debug(`Gradle 실행: cmd /c gradlew.bat ${gradleTask}`);
1021
+ await this._exec("cmd", ["/c", "gradlew.bat", gradleTask, "--no-daemon"], androidPath);
1022
+ } else {
1023
+ const gradlew = pathx.posixResolve(androidPath, "gradlew");
1024
+ Capacitor._logger.debug(`Gradle 실행: ${gradlew} ${gradleTask}`);
1025
+ await this._exec(gradlew, [gradleTask, "--no-daemon"], androidPath);
1026
+ }
1016
1027
  }
1017
1028
 
1018
1029
  /**
@@ -20,6 +20,10 @@ export interface ViteEngineOptions {
20
20
  rebuildManager?: RebuildManager;
21
21
  /** sdScopeWatchPlugin용 replaceDeps 항목 */
22
22
  replaceDeps?: Array<{ packageName: string; sourcePath: string }>;
23
+ /** 빌드 출력 경로 (미설정 시 pkgDir/dist) */
24
+ outDir?: string;
25
+ /** Vite base 경로 (미설정 시 /{pkgName}/) */
26
+ base?: string;
23
27
  }
24
28
 
25
29
  /**
@@ -34,6 +38,8 @@ export class ViteEngine implements BuildEngine {
34
38
  private readonly _resultCollector: ResultCollector | undefined;
35
39
  private readonly _rebuildManager: RebuildManager | undefined;
36
40
  private readonly _replaceDeps: Array<{ packageName: string; sourcePath: string }> | undefined;
41
+ private readonly _outDir: string | undefined;
42
+ private readonly _base: string | undefined;
37
43
 
38
44
  private _worker: WorkerProxy<typeof ClientWorkerModule> | undefined;
39
45
  private _isWatchMode = false;
@@ -47,6 +53,8 @@ export class ViteEngine implements BuildEngine {
47
53
  this._resultCollector = options.resultCollector;
48
54
  this._rebuildManager = options.rebuildManager;
49
55
  this._replaceDeps = options.replaceDeps;
56
+ this._outDir = options.outDir;
57
+ this._base = options.base;
50
58
  }
51
59
 
52
60
  /**
@@ -65,6 +73,8 @@ export class ViteEngine implements BuildEngine {
65
73
  browserSupport: this._pkg.config.browserSupport,
66
74
  enableLint: output.lint,
67
75
  exclude: this._pkg.config.exclude,
76
+ outDir: this._outDir,
77
+ base: this._base,
68
78
  });
69
79
 
70
80
  logger.debug(`[${this._pkg.name}] ViteEngine.run 완료 (success: ${result.success})`);
@@ -38,6 +38,10 @@ export function createBuildEngine(
38
38
  resolvedReplaceDeps?: Array<{ packageName: string; sourcePath: string }>;
39
39
  resultCollector?: ResultCollector;
40
40
  rebuildManager?: RebuildManager;
41
+ /** 클라이언트 빌드 출력 경로 (ViteEngine에만 적용) */
42
+ outDir?: string;
43
+ /** Vite base 경로 (ViteEngine에만 적용, 미설정 시 /{pkgName}/) */
44
+ base?: string;
41
45
  },
42
46
  ): BuildEngine {
43
47
  if (pkg.config.target === "client") {
@@ -48,6 +52,8 @@ export function createBuildEngine(
48
52
  resultCollector: options.resultCollector,
49
53
  rebuildManager: options.rebuildManager,
50
54
  replaceDeps: options.resolvedReplaceDeps,
55
+ outDir: options.outDir,
56
+ base: options.base,
51
57
  });
52
58
  }
53
59
 
@@ -328,9 +328,13 @@ export class BuildOrchestrator {
328
328
 
329
329
  buildTasks.push(async () => {
330
330
  this._logger.debug(`[${name}] (client) 빌드 시작`);
331
+ const isNativeBuild = config.capacitor != null || config.electron != null;
332
+ const outDir = config.capacitor != null
333
+ ? pathx.posixResolve(pkgDir, ".capacitor/www")
334
+ : undefined;
331
335
  const engine = createBuildEngine(
332
336
  { name, dir: pkgDir, config: { ...config, env: { ...baseEnv, ...config.env } } },
333
- { cwd: this._cwd },
337
+ { cwd: this._cwd, outDir, base: isNativeBuild ? "" : undefined },
334
338
  );
335
339
 
336
340
  try {
@@ -55,6 +55,10 @@ export interface CreateClientViteConfigOptions {
55
55
  exclude?: string[];
56
56
  /** watch 모드 (build.watch 활성화, emptyOutDir: false) */
57
57
  watch?: boolean;
58
+ /** 빌드 출력 경로 (미설정 시 pkgDir/dist) */
59
+ outDir?: string;
60
+ /** Vite base 경로 (미설정 시 /{pkgName}/) */
61
+ base?: string;
58
62
  }
59
63
 
60
64
  /**
@@ -99,6 +103,7 @@ export async function createClientViteConfig(
99
103
  sdAngularPlugin({
100
104
  tsconfig: options.tsconfigPath,
101
105
  dev: options.mode === "dev",
106
+ sourcemap: options.mode === "dev" || options.watch === true,
102
107
  onBuildStart: options.onBuildStart,
103
108
  onBuild: options.onBuild,
104
109
  enableLint: options.enableLint,
@@ -153,7 +158,7 @@ export async function createClientViteConfig(
153
158
 
154
159
  const config: InlineConfig = {
155
160
  root: options.pkgDir,
156
- base: `/${name}/`,
161
+ base: options.base ?? `/${name}/`,
157
162
  define: Object.keys(define).length > 0 ? define : undefined,
158
163
  plugins,
159
164
  server: serverConfig,
@@ -210,31 +215,57 @@ export async function createClientViteConfig(
210
215
  );
211
216
  }
212
217
 
213
- // polyfills plugin (transformIndexHtml)
218
+ // polyfills plugin
214
219
  if (options.polyfills != null && options.polyfills.length > 0) {
215
220
  const polyfillImports = options.polyfills;
216
- (config.plugins as PluginOption[]).push({
217
- name: "sd-polyfills",
218
- transformIndexHtml() {
219
- return [
220
- {
221
- tag: "script",
222
- attrs: { type: "module" },
223
- children: polyfillImports.map((p) => `import "${p}";`).join("\n"),
224
- injectTo: "head-prepend" as const,
225
- },
226
- ];
227
- },
228
- });
221
+ if (options.legacyModule === true) {
222
+ // legacyModule: 메인 엔트리의 transform에서 polyfill import를 상단에 주입한다.
223
+ // transformIndexHtml 인라인 <script type="module">은 Vite 빌드에서 번들링되지 않으므로,
224
+ // Rollup이 처리할 수 있도록 소스 코드 레벨에서 주입한다.
225
+ const mainEntryPath = path.resolve(options.pkgDir, "src/main.ts");
226
+ (config.plugins as PluginOption[]).push({
227
+ name: "sd-polyfills",
228
+ transform(code, id) {
229
+ if (path.normalize(id) !== path.normalize(mainEntryPath)) return null;
230
+ // polyfillImports는 pkgDir 기준 상대경로 (예: "./src/polyfills.ts")
231
+ // main.ts는 src/ 안에 있으므로, main.ts 기준 상대경로로 변환한다
232
+ const mainDir = path.dirname(mainEntryPath);
233
+ const polyfillCode = polyfillImports
234
+ .map((p) => {
235
+ const abs = path.resolve(options.pkgDir, p);
236
+ this.addWatchFile(abs);
237
+ const rel = path.relative(mainDir, abs);
238
+ const posixRel = rel.replace(/\\/g, "/");
239
+ return "import \"./" + posixRel + "\";";
240
+ })
241
+ .join("\n");
242
+ return { code: polyfillCode + "\n" + code, map: null };
243
+ },
244
+ });
245
+ } else {
246
+ // dev 모드: transformIndexHtml로 인라인 스크립트 주입 (Vite dev server가 처리)
247
+ (config.plugins as PluginOption[]).push({
248
+ name: "sd-polyfills",
249
+ transformIndexHtml() {
250
+ return [
251
+ {
252
+ tag: "script",
253
+ attrs: { type: "module" },
254
+ children: polyfillImports.map((p) => `import "${p}";`).join("\n"),
255
+ injectTo: "head-prepend" as const,
256
+ },
257
+ ];
258
+ },
259
+ });
260
+ }
229
261
  }
230
262
 
231
- // legacyModule: true → 코드 스플리팅 비활성화 + esbuild import.meta/import() 변환 활성화
263
+ // legacyModule: true → 코드 스플리팅 비활성화 + esbuild import.meta 변환 + 잔여 import() 제거
232
264
  if (options.legacyModule === true) {
233
265
  config.esbuild = {
234
266
  ...config.esbuild,
235
267
  supported: {
236
268
  "import-meta": false,
237
- "dynamic-import": false,
238
269
  },
239
270
  };
240
271
  config.build = {
@@ -245,17 +276,38 @@ export async function createClientViteConfig(
245
276
  },
246
277
  },
247
278
  };
279
+
280
+ // Rollup이 인라인하지 못한 잔여 dynamic import()를 제거한다.
281
+ // inlineDynamicImports가 정적 경로를 모두 인라인한 후에도,
282
+ // @vite-ignore나 런타임 계산 경로의 import()가 남을 수 있다.
283
+ // Chrome 61은 import() 구문을 파싱하지 못하므로 no-op 함수로 치환한다.
284
+ (config.plugins as PluginOption[]).push({
285
+ name: "sd-legacy-strip-dynamic-import",
286
+ enforce: "post",
287
+ renderChunk(code) {
288
+ if (!code.includes("import(")) return null;
289
+ return {
290
+ code: code.replace(
291
+ /\bimport\s*\(/g,
292
+ "(function(){return Promise.reject(new Error(\"Dynamic import not supported\"))})(",
293
+ ),
294
+ map: null,
295
+ };
296
+ },
297
+ });
248
298
  }
249
299
 
250
300
  // build 모드 설정
251
301
  if (options.mode === "build") {
252
302
  config.build = {
253
303
  ...config.build,
254
- outDir: path.join(options.pkgDir, "dist"),
304
+ outDir: options.outDir ?? path.join(options.pkgDir, "dist"),
255
305
  };
256
306
  if (options.watch === true) {
257
307
  config.build.watch = {};
258
308
  config.build.emptyOutDir = false;
309
+ config.build.minify = false;
310
+ config.build.sourcemap = true;
259
311
  } else {
260
312
  config.logLevel = "silent";
261
313
  config.build.emptyOutDir = true;
@@ -37,6 +37,10 @@ export interface ClientBuildInfo {
37
37
  enableLint?: boolean;
38
38
  /** Vite optimizeDeps.exclude에 전달할 패키지 목록 */
39
39
  exclude?: string[];
40
+ /** 빌드 출력 경로 (미설정 시 pkgDir/dist) */
41
+ outDir?: string;
42
+ /** Vite base 경로 (미설정 시 /{pkgName}/) */
43
+ base?: string;
40
44
  }
41
45
 
42
46
  /** Client 빌드 결과 */
@@ -416,12 +420,14 @@ async function build(info: ClientBuildInfo): Promise<ClientBuildResult> {
416
420
  polyfills,
417
421
  pwa: info.pwa,
418
422
  exclude: info.exclude,
423
+ outDir: info.outDir,
424
+ base: info.base,
419
425
  });
420
426
 
421
427
  await viteBuild(viteConfig);
422
428
 
423
- // .config.json 생성
424
- writeConfigJson(info.pkgDir, info.configs);
429
+ // .config.json 생성 (항상 dist/에 기록 — outDir과 무관)
430
+ writeConfigJson(path.join(info.pkgDir, "dist"), info.configs);
425
431
 
426
432
  logger.debug(`[${info.name}] client worker build 완료`);
427
433
  return { success: true, lint: lintResult };
@@ -432,12 +438,11 @@ async function build(info: ClientBuildInfo): Promise<ClientBuildResult> {
432
438
  }
433
439
  }
434
440
 
435
- /** dist/.config.json 생성 */
441
+ /** .config.json 생성 */
436
442
  function writeConfigJson(
437
- pkgDir: string,
443
+ distDir: string,
438
444
  configs?: Record<string, unknown>,
439
445
  ): void {
440
- const distDir = path.join(pkgDir, "dist");
441
446
  fs.mkdirSync(distDir, { recursive: true });
442
447
  fs.writeFileSync(
443
448
  path.join(distDir, ".config.json"),
@@ -93,6 +93,19 @@ vi.mock("consola", () => ({
93
93
 
94
94
  const PKG_PATH = "/fake/pkg";
95
95
 
96
+ /** Gradle 실행 명령을 찾는다 (Windows: cmd /c gradlew.bat, 그 외: gradlew) */
97
+ function findGradleCall(calls: { command: string; args: string[] }[]) {
98
+ return calls.find(
99
+ (c) => c.command.includes("gradlew") || (c.command === "cmd" && c.args.includes("gradlew.bat")),
100
+ );
101
+ }
102
+
103
+ function findGradleCallIndex(calls: { command: string; args: string[] }[]) {
104
+ return calls.findIndex(
105
+ (c) => c.command.includes("gradlew") || (c.command === "cmd" && c.args.includes("gradlew.bat")),
106
+ );
107
+ }
108
+
96
109
  function setupDefaultMocks() {
97
110
  mockFsxExists.mockResolvedValue(true);
98
111
 
@@ -181,7 +194,7 @@ describe("Capacitor 빌드", () => {
181
194
 
182
195
  await cap.build("/fake/out");
183
196
 
184
- const gradleCmd = execaCalls.find((c) => c.command.includes("gradlew"));
197
+ const gradleCmd = findGradleCall(execaCalls);
185
198
  expect(gradleCmd).toBeDefined();
186
199
  expect(gradleCmd!.args).toContain("bundleRelease");
187
200
  });
@@ -197,7 +210,7 @@ describe("Capacitor 빌드", () => {
197
210
 
198
211
  await cap.build("/fake/out");
199
212
 
200
- const gradleCmd = execaCalls.find((c) => c.command.includes("gradlew"));
213
+ const gradleCmd = findGradleCall(execaCalls);
201
214
  expect(gradleCmd).toBeDefined();
202
215
  expect(gradleCmd!.args).toContain("assembleRelease");
203
216
  });
@@ -214,7 +227,7 @@ describe("Capacitor 빌드", () => {
214
227
 
215
228
  await cap.build("/fake/out");
216
229
 
217
- const gradleCmd = execaCalls.find((c) => c.command.includes("gradlew"));
230
+ const gradleCmd = findGradleCall(execaCalls);
218
231
  expect(gradleCmd).toBeDefined();
219
232
  expect(gradleCmd!.args).toContain("assembleDebug");
220
233
  });
@@ -274,12 +287,12 @@ describe("Capacitor 빌드", () => {
274
287
  const capCopyIndex = execaCalls.findIndex(
275
288
  (c) => c.command === "pnpm" && c.args.includes("cap") && c.args.includes("copy"),
276
289
  );
277
- const gradlewIndex = execaCalls.findIndex((c) => c.command.includes("gradlew"));
290
+ const gradlewIndex = findGradleCallIndex(execaCalls);
278
291
  expect(capCopyIndex).toBeGreaterThanOrEqual(0);
279
292
  expect(gradlewIndex).toBeGreaterThan(capCopyIndex);
280
293
  });
281
294
 
282
- it("Windows에서 gradlew.bat을 사용한다", async () => {
295
+ it("Windows에서 cmd /c gradlew.bat으로 Gradle실행한다", async () => {
283
296
  const originalPlatform = process.platform;
284
297
  Object.defineProperty(process, "platform", { value: "win32" });
285
298
 
@@ -294,8 +307,37 @@ describe("Capacitor 빌드", () => {
294
307
 
295
308
  await cap.build("/fake/out");
296
309
 
297
- const gradleCmd = execaCalls.find((c) => c.command.includes("gradlew"));
298
- expect(gradleCmd!.command).toContain("gradlew.bat");
310
+ const gradleCmd = execaCalls.find((c) => c.command === "cmd");
311
+ expect(gradleCmd).toBeDefined();
312
+ expect(gradleCmd!.args[0]).toBe("/c");
313
+ expect(gradleCmd!.args[1]).toBe("gradlew.bat");
314
+ expect(gradleCmd!.args).toContain("assembleRelease");
315
+ expect(gradleCmd!.args).toContain("--no-daemon");
316
+ } finally {
317
+ Object.defineProperty(process, "platform", { value: originalPlatform });
318
+ }
319
+ });
320
+
321
+ it("Linux/macOS에서 gradlew를 직접 실행한다", async () => {
322
+ const originalPlatform = process.platform;
323
+ Object.defineProperty(process, "platform", { value: "linux" });
324
+
325
+ try {
326
+ const { Capacitor } = await import("../../src/capacitor/capacitor.js");
327
+
328
+ const cap = await Capacitor.create(PKG_PATH, {
329
+ appId: "com.test.app",
330
+ appName: "Test App",
331
+ platform: { android: {} },
332
+ });
333
+
334
+ await cap.build("/fake/out");
335
+
336
+ const gradleCmd = findGradleCall(execaCalls);
337
+ expect(gradleCmd).toBeDefined();
338
+ expect(gradleCmd!.command).toContain("gradlew");
339
+ expect(gradleCmd!.command).not.toContain("gradlew.bat");
340
+ expect(gradleCmd!.args).toContain("assembleRelease");
299
341
  } finally {
300
342
  Object.defineProperty(process, "platform", { value: originalPlatform });
301
343
  }
@@ -377,6 +419,44 @@ describe("Capacitor 빌드", () => {
377
419
  await expect(cap.build("/fake/out")).rejects.toThrow("keystore");
378
420
  });
379
421
 
422
+ it("비밀번호에 $, \\, ' 등 특수문자가 있으면 Groovy 이스케이프하여 build.gradle에 기록한다", async () => {
423
+ const { Capacitor } = await import("../../src/capacitor/capacitor.js");
424
+ mockFsxExists.mockResolvedValue(true);
425
+
426
+ const cap = await Capacitor.create(PKG_PATH, {
427
+ appId: "com.test.app",
428
+ appName: "Test App",
429
+ platform: {
430
+ android: {
431
+ sign: {
432
+ keystore: "my-release.keystore",
433
+ storePassword: "12tlavmf#$",
434
+ alias: "my-key",
435
+ password: "pass\\'word",
436
+ },
437
+ },
438
+ },
439
+ });
440
+
441
+ await cap.build("/fake/out");
442
+
443
+ const writeCalls = mockFsxWrite.mock.calls;
444
+ const gradleWrite = writeCalls.find(
445
+ (call) =>
446
+ typeof call[0] === "string" &&
447
+ call[0].includes("build.gradle") &&
448
+ typeof call[1] === "string" &&
449
+ call[1].includes("signingConfigs"),
450
+ );
451
+ expect(gradleWrite).toBeDefined();
452
+
453
+ const gradleContent = gradleWrite![1] as string;
454
+ // $ 는 Groovy single-quoted string에서 그대로 유지
455
+ expect(gradleContent).toContain("storePassword '12tlavmf#$'");
456
+ // \ → \\, ' → \' 이스케이프
457
+ expect(gradleContent).toContain("keyPassword 'pass\\\\\\'word'");
458
+ });
459
+
380
460
  it("signed 빌드 산출물이 unsigned 접미사 없이 복사된다", async () => {
381
461
  const { Capacitor } = await import("../../src/capacitor/capacitor.js");
382
462
 
@@ -549,7 +549,7 @@ describe("Android 네이티브 설정", () => {
549
549
  expect(manifestWrite).toBeDefined();
550
550
  });
551
551
 
552
- it("styles.xml의 Theme.SplashScreen parent를 Theme.AppCompat.DayNight.NoActionBar로 변경한다", async () => {
552
+ it("styles.xml의 Theme.SplashScreen parent를 변경하고 android:background를 android:windowBackground로 변경한다", async () => {
553
553
  const { Capacitor } = await import("../../src/capacitor/capacitor.js");
554
554
  const cap = await Capacitor.create(PKG_PATH, {
555
555
  appId: "com.test.app",
@@ -567,10 +567,14 @@ describe("Android 네이티브 설정", () => {
567
567
  call[1].includes('parent="Theme.AppCompat.DayNight.NoActionBar"'),
568
568
  );
569
569
  expect(stylesWrite).toBeDefined();
570
- // android:background는 유지
571
- expect((stylesWrite![1] as string)).toContain("@drawable/splash");
570
+ const content = stylesWrite![1] as string;
571
+ // @drawable/splash는 유지
572
+ expect(content).toContain("@drawable/splash");
572
573
  // Theme.SplashScreen은 제거됨
573
- expect((stylesWrite![1] as string)).not.toContain('parent="Theme.SplashScreen"');
574
+ expect(content).not.toContain('parent="Theme.SplashScreen"');
575
+ // android:background → android:windowBackground로 변경됨
576
+ expect(content).toContain('"android:windowBackground">@drawable/splash');
577
+ expect(content).not.toContain('"android:background">@drawable/splash');
574
578
  });
575
579
 
576
580
  it("이미 변경된 styles.xml은 재변경하지 않는다", async () => {
@@ -579,7 +583,7 @@ describe("Android 네이티브 설정", () => {
579
583
  return `<?xml version="1.0" encoding="utf-8"?>
580
584
  <resources>
581
585
  <style name="AppTheme.NoActionBarLaunch" parent="Theme.AppCompat.DayNight.NoActionBar">
582
- <item name="android:background">@drawable/splash</item>
586
+ <item name="android:windowBackground">@drawable/splash</item>
583
587
  </style>
584
588
  </resources>`;
585
589
  }
@@ -257,8 +257,8 @@ describe("createClientViteConfig", () => {
257
257
 
258
258
  // --- legacyModule esbuild.supported override (Feature 1.4) ---
259
259
 
260
- // Acceptance: Scenario "legacyModule: true일 때 esbuild.supported에 import-meta/dynamic-import false 설정"
261
- it("sets esbuild.supported to disable import-meta and dynamic-import when legacyModule is true", async () => {
260
+ // Acceptance: Scenario "legacyModule: true일 때 esbuild.supported에 import-meta false 설정"
261
+ it("sets esbuild.supported to disable import-meta when legacyModule is true", async () => {
262
262
  const config = await createClientViteConfig({
263
263
  ...createDefaultOptions(),
264
264
  legacyModule: true,
@@ -268,7 +268,6 @@ describe("createClientViteConfig", () => {
268
268
  expect(esbuildOpts?.["supported"]).toEqual(
269
269
  expect.objectContaining({
270
270
  "import-meta": false,
271
- "dynamic-import": false,
272
271
  }),
273
272
  );
274
273
  });
@@ -504,6 +503,25 @@ describe("createClientViteConfig", () => {
504
503
  expect(config.build?.outDir).toMatch(/dist$/);
505
504
  });
506
505
 
506
+ // --- outDir override ---
507
+
508
+ // Acceptance: Scenario "outDir 설정 시 해당 경로로 빌드 출력"
509
+ it("uses custom outDir when provided", async () => {
510
+ const config = await createClientViteConfig({
511
+ ...createDefaultOptions(),
512
+ outDir: "/packages/my-client/.capacitor/www",
513
+ });
514
+
515
+ expect(config.build?.outDir).toBe("/packages/my-client/.capacitor/www");
516
+ });
517
+
518
+ // Acceptance: Scenario "outDir 미설정 시 pkgDir/dist 사용"
519
+ it("defaults outDir to pkgDir/dist when not provided", async () => {
520
+ const config = await createClientViteConfig(createDefaultOptions());
521
+
522
+ expect(config.build?.outDir).toMatch(/my-client[\\/]dist$/);
523
+ });
524
+
507
525
  // --- exclude (Feature 1.1: vite-exclude-passthrough) ---
508
526
 
509
527
  // Acceptance: Scenario "exclude에 패키지를 지정하면 pre-bundling에서 제외된다"