@simplysm/sd-cli 14.0.19 → 14.0.21

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 (89) hide show
  1. package/dist/angular/vite-postcss-inline-plugin.d.ts.map +1 -1
  2. package/dist/angular/vite-postcss-inline-plugin.js +4 -1
  3. package/dist/angular/vite-postcss-inline-plugin.js.map +1 -1
  4. package/dist/capacitor/capacitor-android.d.ts +16 -0
  5. package/dist/capacitor/capacitor-android.d.ts.map +1 -0
  6. package/dist/capacitor/capacitor-android.js +289 -0
  7. package/dist/capacitor/capacitor-android.js.map +1 -0
  8. package/dist/capacitor/capacitor.d.ts +0 -49
  9. package/dist/capacitor/capacitor.d.ts.map +1 -1
  10. package/dist/capacitor/capacitor.js +4 -244
  11. package/dist/capacitor/capacitor.js.map +1 -1
  12. package/dist/commands/check.js +2 -2
  13. package/dist/commands/check.js.map +1 -1
  14. package/dist/commands/lint.d.ts +1 -42
  15. package/dist/commands/lint.d.ts.map +1 -1
  16. package/dist/commands/lint.js +1 -151
  17. package/dist/commands/lint.js.map +1 -1
  18. package/dist/commands/publish.d.ts.map +1 -1
  19. package/dist/commands/publish.js +2 -1
  20. package/dist/commands/publish.js.map +1 -1
  21. package/dist/commands/typecheck.d.ts +3 -40
  22. package/dist/commands/typecheck.d.ts.map +1 -1
  23. package/dist/commands/typecheck.js +3 -232
  24. package/dist/commands/typecheck.js.map +1 -1
  25. package/dist/electron/electron.js +11 -4
  26. package/dist/electron/electron.js.map +1 -1
  27. package/dist/orchestrators/DevWatchOrchestrator.d.ts +1 -0
  28. package/dist/orchestrators/DevWatchOrchestrator.d.ts.map +1 -1
  29. package/dist/orchestrators/DevWatchOrchestrator.js +10 -6
  30. package/dist/orchestrators/DevWatchOrchestrator.js.map +1 -1
  31. package/dist/orchestrators/TypecheckOrchestrator.d.ts +74 -0
  32. package/dist/orchestrators/TypecheckOrchestrator.d.ts.map +1 -0
  33. package/dist/orchestrators/TypecheckOrchestrator.js +285 -0
  34. package/dist/orchestrators/TypecheckOrchestrator.js.map +1 -0
  35. package/dist/sd-cli.js +6 -1
  36. package/dist/sd-cli.js.map +1 -1
  37. package/dist/utils/lint-core.d.ts +43 -0
  38. package/dist/utils/lint-core.d.ts.map +1 -0
  39. package/dist/utils/lint-core.js +154 -0
  40. package/dist/utils/lint-core.js.map +1 -0
  41. package/dist/utils/lint-utils.d.ts +1 -1
  42. package/dist/utils/lint-utils.d.ts.map +1 -1
  43. package/dist/utils/server-production-files.d.ts +22 -0
  44. package/dist/utils/server-production-files.d.ts.map +1 -0
  45. package/dist/utils/server-production-files.js +162 -0
  46. package/dist/utils/server-production-files.js.map +1 -0
  47. package/dist/workers/lint.worker.d.ts +1 -1
  48. package/dist/workers/lint.worker.d.ts.map +1 -1
  49. package/dist/workers/lint.worker.js +1 -1
  50. package/dist/workers/lint.worker.js.map +1 -1
  51. package/dist/workers/server-build.worker.d.ts.map +1 -1
  52. package/dist/workers/server-build.worker.js +11 -161
  53. package/dist/workers/server-build.worker.js.map +1 -1
  54. package/package.json +4 -4
  55. package/src/angular/vite-postcss-inline-plugin.ts +5 -1
  56. package/src/capacitor/capacitor-android.ts +368 -0
  57. package/src/capacitor/capacitor.ts +4 -317
  58. package/src/commands/check.ts +2 -2
  59. package/src/commands/lint.ts +1 -201
  60. package/src/commands/publish.ts +2 -1
  61. package/src/commands/typecheck.ts +7 -292
  62. package/src/electron/electron.ts +4 -4
  63. package/src/orchestrators/DevWatchOrchestrator.ts +10 -6
  64. package/src/orchestrators/TypecheckOrchestrator.ts +364 -0
  65. package/src/sd-cli.ts +6 -1
  66. package/src/utils/lint-core.ts +205 -0
  67. package/src/utils/lint-utils.ts +1 -1
  68. package/src/utils/server-production-files.ts +186 -0
  69. package/src/workers/lint.worker.ts +1 -1
  70. package/src/workers/server-build.worker.ts +10 -185
  71. package/tests/angular/vite-postcss-inline-plugin.spec.ts +10 -0
  72. package/tests/capacitor/capacitor-android-exports.verify.md +11 -0
  73. package/tests/capacitor/capacitor-android.spec.ts +219 -0
  74. package/tests/capacitor/capacitor-build.spec.ts +17 -21
  75. package/tests/capacitor/capacitor-icon.spec.ts +17 -19
  76. package/tests/capacitor/capacitor-init.spec.ts +18 -14
  77. package/tests/capacitor/capacitor-run.spec.ts +10 -24
  78. package/tests/capacitor/capacitor-workspace.spec.ts +10 -15
  79. package/tests/commands/check.spec.ts +2 -2
  80. package/tests/commands/lint.spec.ts +33 -194
  81. package/tests/commands/publish-set.verify.md +7 -0
  82. package/tests/electron/electron-symlink-cleanup.verify.md +8 -0
  83. package/tests/orchestrators/dist-delete-watcher.verify.md +10 -0
  84. package/tests/orchestrators/typecheck-orchestrator.spec.ts +180 -0
  85. package/tests/sd-cli-catch-all.verify.md +7 -0
  86. package/tests/utils/lint-core-import-paths.verify.md +10 -0
  87. package/tests/utils/lint-core.spec.ts +188 -0
  88. package/tests/utils/server-production-files-import-paths.verify.md +14 -0
  89. package/tests/workers/server-build-context-dispose.verify.md +8 -0
@@ -0,0 +1,186 @@
1
+ import type { ServerBuildInfo } from "../workers/server-build.worker";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import { cpx } from "@simplysm/core-node";
5
+ import { consola } from "consola";
6
+ import { collectAllDependencyExternals } from "./esbuild-config";
7
+
8
+ const logger = consola.withTag("sd:cli:server-production-files");
9
+
10
+ /**
11
+ * 세 가지 소스에서 외부 모듈을 수집하고 병합한다.
12
+ * collectAllDependencyExternals를 통한 단일 패스 의존성 트리 순회를 사용한다.
13
+ */
14
+ export function collectAllExternals(pkgDir: string, manualExternals?: string[]): string[] {
15
+ logger.debug("의존성 트리 스캔 중...");
16
+ const { optionalPeerDeps, nativeModules } = collectAllDependencyExternals(pkgDir);
17
+
18
+ const manual = manualExternals ?? [];
19
+ return [...new Set([...optionalPeerDeps, ...nativeModules, ...manual])];
20
+ }
21
+
22
+ /**
23
+ * pnpm-lock.yaml의 packages 섹션을 파싱하여 name→version 맵을 생성한다.
24
+ * Lockfile v9 형식: `packages:` 섹션의 `'name@version':` 키를 파싱한다.
25
+ * YAML 파서 의존성을 피하기 위해 단순 라인 기반 파싱을 사용한다.
26
+ */
27
+ export function parseLockfileVersions(cwd: string): Map<string, string> {
28
+ const lockfilePath = path.join(cwd, "pnpm-lock.yaml");
29
+ if (!fs.existsSync(lockfilePath)) {
30
+ throw new Error(`pnpm-lock.yaml not found in ${cwd}. Run "pnpm install" first.`);
31
+ }
32
+
33
+ const content = fs.readFileSync(lockfilePath, "utf-8");
34
+ const map = new Map<string, string>();
35
+
36
+ // "packages:" 섹션을 찾고 "'@scope/name@1.2.3':" 또는 "'name@1.2.3':" 형태의 항목을 파싱
37
+ const lines = content.split("\n");
38
+ let inPackages = false;
39
+ for (const line of lines) {
40
+ if (line === "packages:") {
41
+ inPackages = true;
42
+ continue;
43
+ }
44
+ if (inPackages && line.length > 0 && !line.startsWith(" ") && !line.startsWith("'")) {
45
+ break; // 다음 최상위 섹션
46
+ }
47
+ if (!inPackages) continue;
48
+
49
+ // "'@scope/name@version':" 또는 "'name@version':" 매칭
50
+ const match = /^\s{2}'(.+)@(\d[^']*)':\s*$/.exec(line);
51
+ if (match != null) {
52
+ const name = match[1];
53
+ const version = match[2];
54
+ // 첫 번째 항목 유지 (lockfile은 각 버전을 한 번만 기록)
55
+ if (!map.has(name)) {
56
+ map.set(name, version);
57
+ }
58
+ }
59
+ }
60
+
61
+ return map;
62
+ }
63
+
64
+ /**
65
+ * pnpm-lock.yaml에서 주어진 모든 패키지의 잠긴 버전을 확인한다.
66
+ * lockfile에서 패키지를 찾을 수 없으면 에러를 던진다.
67
+ */
68
+ export function resolveLockedVersions(cwd: string, pkgNames: string[]): Record<string, string> {
69
+ const versionMap = parseLockfileVersions(cwd);
70
+ const result: Record<string, string> = {};
71
+ for (const name of pkgNames) {
72
+ const version = versionMap.get(name);
73
+ if (version == null) {
74
+ throw new Error(
75
+ `External dependency "${name}" not found in pnpm-lock.yaml. ` +
76
+ `Run "pnpm install" and try again.`,
77
+ );
78
+ }
79
+ result[name] = version;
80
+ }
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * 프로덕션 배포용 파일을 생성한다
86
+ */
87
+ export function generateProductionFiles(
88
+ info: ServerBuildInfo,
89
+ externals: string[],
90
+ ): void {
91
+ const distDir = path.join(info.pkgDir, "dist");
92
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(info.pkgDir, "package.json"), "utf-8"));
93
+
94
+ // dist/package.json
95
+ const distPkgJson: Record<string, unknown> = {
96
+ name: pkgJson.name,
97
+ version: pkgJson.version,
98
+ type: pkgJson.type,
99
+ };
100
+ if (externals.length > 0) {
101
+ distPkgJson["dependencies"] = resolveLockedVersions(info.cwd, externals);
102
+ }
103
+ if (info.packageManager === "volta") {
104
+ const nodeVersion = cpx.spawnSync("node", ["-v"]).stdout.trim();
105
+ distPkgJson["volta"] = { node: nodeVersion };
106
+ }
107
+ fs.writeFileSync(path.join(distDir, "package.json"), JSON.stringify(distPkgJson, undefined, 2));
108
+
109
+ // dist/mise.toml
110
+ if (info.packageManager === "mise") {
111
+ const rootMiseTomlPath = path.join(info.cwd, "mise.toml");
112
+ let nodeVersion = "20";
113
+ if (fs.existsSync(rootMiseTomlPath)) {
114
+ const miseContent = fs.readFileSync(rootMiseTomlPath, "utf-8");
115
+ const match = /node\s*=\s*"([^"]+)"/.exec(miseContent);
116
+ if (match != null) {
117
+ nodeVersion = match[1];
118
+ }
119
+ }
120
+ fs.writeFileSync(path.join(distDir, "mise.toml"), `[tools]\nnode = "${nodeVersion}"\n`);
121
+ }
122
+
123
+ // dist/openssl.cnf
124
+ fs.writeFileSync(
125
+ path.join(distDir, "openssl.cnf"),
126
+ [
127
+ "nodejs_conf = openssl_init",
128
+ "",
129
+ "[openssl_init]",
130
+ "providers = provider_sect",
131
+ "ssl_conf = ssl_sect",
132
+ "",
133
+ "[provider_sect]",
134
+ "default = default_sect",
135
+ "legacy = legacy_sect",
136
+ "",
137
+ "[default_sect]",
138
+ "activate = 1",
139
+ "",
140
+ "[legacy_sect]",
141
+ "activate = 1",
142
+ "",
143
+ "[ssl_sect]",
144
+ "system_default = system_default_sect",
145
+ "",
146
+ "[system_default_sect]",
147
+ "Options = UnsafeLegacyRenegotiation",
148
+ ].join("\n"),
149
+ );
150
+
151
+ // dist/pm2.config.cjs
152
+ if (info.pm2 != null) {
153
+ const pm2Name = info.pm2.name ?? pkgJson.name.replace(/@/g, "").replace(/[/\\]/g, "-");
154
+ const ignoreWatch = JSON.stringify([
155
+ "node_modules",
156
+ "www",
157
+ ...(info.pm2.ignoreWatchPaths ?? []),
158
+ ]);
159
+ const envObj: Record<string, string> = {
160
+ NODE_ENV: "production",
161
+ TZ: "Asia/Seoul",
162
+ ...(info.env ?? {}),
163
+ };
164
+ const envStr = JSON.stringify(envObj, undefined, 4);
165
+
166
+ const useInterpreter = info.packageManager !== "volta";
167
+
168
+ const pm2Config = [
169
+ ...(useInterpreter ? [`const cp = require("child_process");`, ``] : []),
170
+ `module.exports = {`,
171
+ ` name: ${JSON.stringify(pm2Name)},`,
172
+ ` script: "main.js",`,
173
+ ` watch: true,`,
174
+ ` watch_delay: 2000,`,
175
+ ` ignore_watch: ${ignoreWatch},`,
176
+ ...(useInterpreter ? [` interpreter: cp.execSync("mise which node").toString().trim(),`] : []),
177
+ ` interpreter_args: "--openssl-config=openssl.cnf",`,
178
+ ` env: ${envStr.replace(/\n/g, "\n ")},`,
179
+ ` arrayProcess: "concat",`,
180
+ ` useDelTargetNull: true,`,
181
+ `};`,
182
+ ].join("\n");
183
+
184
+ fs.writeFileSync(path.join(distDir, "pm2.config.cjs"), pm2Config);
185
+ }
186
+ }
@@ -1,5 +1,5 @@
1
1
  import { createWorker } from "@simplysm/core-node";
2
- import { executeLint, type LintOptions, type LintResult } from "../commands/lint";
2
+ import { executeLint, type LintOptions, type LintResult } from "../utils/lint-core";
3
3
 
4
4
  //#region Worker
5
5
 
@@ -2,7 +2,7 @@ import type ts from "typescript";
2
2
  import path from "path";
3
3
  import fs from "fs";
4
4
  import esbuild from "esbuild";
5
- import { cpx, createWorker, FsWatcher, pathx } from "@simplysm/core-node";
5
+ import { createWorker, FsWatcher, pathx } from "@simplysm/core-node";
6
6
  import { err as errNs } from "@simplysm/core-common";
7
7
  import { consola } from "consola";
8
8
  import type { BuildOutput } from "../engines/types";
@@ -14,9 +14,9 @@ import {
14
14
  } from "../utils/tsconfig";
15
15
  import {
16
16
  createServerEsbuildOptions,
17
- collectAllDependencyExternals,
18
17
  writeChangedOutputFiles,
19
18
  } from "../utils/esbuild-config";
19
+ import { collectAllExternals, generateProductionFiles } from "../utils/server-production-files";
20
20
  import { runTscPackageBuild } from "../utils/tsc-build";
21
21
  import { LintWithProgramRunner } from "../utils/lint-with-program";
22
22
  import { registerCleanupHandlers, createOnceGuard, setupWorkerConsola } from "../utils/worker-utils";
@@ -136,184 +136,6 @@ async function cleanup(): Promise<void> {
136
136
  }
137
137
  }
138
138
 
139
- /**
140
- * 세 가지 소스에서 외부 모듈을 수집하고 병합한다.
141
- * collectAllDependencyExternals를 통한 단일 패스 의존성 트리 순회를 사용한다.
142
- */
143
- function collectAllExternals(pkgDir: string, manualExternals?: string[]): string[] {
144
- logger.debug("의존성 트리 스캔 중...");
145
- const { optionalPeerDeps, nativeModules } = collectAllDependencyExternals(pkgDir);
146
-
147
- const manual = manualExternals ?? [];
148
- return [...new Set([...optionalPeerDeps, ...nativeModules, ...manual])];
149
- }
150
-
151
- /**
152
- * pnpm-lock.yaml의 packages 섹션을 파싱하여 name→version 맵을 생성한다.
153
- * Lockfile v9 형식: `packages:` 섹션의 `'name@version':` 키를 파싱한다.
154
- * YAML 파서 의존성을 피하기 위해 단순 라인 기반 파싱을 사용한다.
155
- */
156
- function parseLockfileVersions(cwd: string): Map<string, string> {
157
- const lockfilePath = path.join(cwd, "pnpm-lock.yaml");
158
- if (!fs.existsSync(lockfilePath)) {
159
- throw new Error(`pnpm-lock.yaml not found in ${cwd}. Run "pnpm install" first.`);
160
- }
161
-
162
- const content = fs.readFileSync(lockfilePath, "utf-8");
163
- const map = new Map<string, string>();
164
-
165
- // "packages:" 섹션을 찾고 "'@scope/name@1.2.3':" 또는 "'name@1.2.3':" 형태의 항목을 파싱
166
- const lines = content.split("\n");
167
- let inPackages = false;
168
- for (const line of lines) {
169
- if (line === "packages:") {
170
- inPackages = true;
171
- continue;
172
- }
173
- if (inPackages && line.length > 0 && !line.startsWith(" ") && !line.startsWith("'")) {
174
- break; // 다음 최상위 섹션
175
- }
176
- if (!inPackages) continue;
177
-
178
- // "'@scope/name@version':" 또는 "'name@version':" 매칭
179
- const match = /^\s{2}'(.+)@(\d[^']*)':\s*$/.exec(line);
180
- if (match != null) {
181
- const name = match[1];
182
- const version = match[2];
183
- // 첫 번째 항목 유지 (lockfile은 각 버전을 한 번만 기록)
184
- if (!map.has(name)) {
185
- map.set(name, version);
186
- }
187
- }
188
- }
189
-
190
- return map;
191
- }
192
-
193
- /**
194
- * pnpm-lock.yaml에서 주어진 모든 패키지의 잠긴 버전을 확인한다.
195
- * lockfile에서 패키지를 찾을 수 없으면 에러를 던진다.
196
- */
197
- function resolveLockedVersions(cwd: string, pkgNames: string[]): Record<string, string> {
198
- const versionMap = parseLockfileVersions(cwd);
199
- const result: Record<string, string> = {};
200
- for (const name of pkgNames) {
201
- const version = versionMap.get(name);
202
- if (version == null) {
203
- throw new Error(
204
- `External dependency "${name}" not found in pnpm-lock.yaml. ` +
205
- `Run "pnpm install" and try again.`,
206
- );
207
- }
208
- result[name] = version;
209
- }
210
- return result;
211
- }
212
-
213
- /**
214
- * 프로덕션 배포용 파일을 생성한다
215
- */
216
- function generateProductionFiles(
217
- info: ServerBuildInfo,
218
- externals: string[],
219
- ): void {
220
- const distDir = path.join(info.pkgDir, "dist");
221
- const pkgJson = JSON.parse(fs.readFileSync(path.join(info.pkgDir, "package.json"), "utf-8"));
222
-
223
- // dist/package.json
224
- const distPkgJson: Record<string, unknown> = {
225
- name: pkgJson.name,
226
- version: pkgJson.version,
227
- type: pkgJson.type,
228
- };
229
- if (externals.length > 0) {
230
- distPkgJson["dependencies"] = resolveLockedVersions(info.cwd, externals);
231
- }
232
- if (info.packageManager === "volta") {
233
- const nodeVersion = cpx.spawnSync("node", ["-v"]).stdout.trim();
234
- distPkgJson["volta"] = { node: nodeVersion };
235
- }
236
- fs.writeFileSync(path.join(distDir, "package.json"), JSON.stringify(distPkgJson, undefined, 2));
237
-
238
- // dist/mise.toml
239
- if (info.packageManager === "mise") {
240
- const rootMiseTomlPath = path.join(info.cwd, "mise.toml");
241
- let nodeVersion = "20";
242
- if (fs.existsSync(rootMiseTomlPath)) {
243
- const miseContent = fs.readFileSync(rootMiseTomlPath, "utf-8");
244
- const match = /node\s*=\s*"([^"]+)"/.exec(miseContent);
245
- if (match != null) {
246
- nodeVersion = match[1];
247
- }
248
- }
249
- fs.writeFileSync(path.join(distDir, "mise.toml"), `[tools]\nnode = "${nodeVersion}"\n`);
250
- }
251
-
252
- // dist/openssl.cnf
253
- fs.writeFileSync(
254
- path.join(distDir, "openssl.cnf"),
255
- [
256
- "nodejs_conf = openssl_init",
257
- "",
258
- "[openssl_init]",
259
- "providers = provider_sect",
260
- "ssl_conf = ssl_sect",
261
- "",
262
- "[provider_sect]",
263
- "default = default_sect",
264
- "legacy = legacy_sect",
265
- "",
266
- "[default_sect]",
267
- "activate = 1",
268
- "",
269
- "[legacy_sect]",
270
- "activate = 1",
271
- "",
272
- "[ssl_sect]",
273
- "system_default = system_default_sect",
274
- "",
275
- "[system_default_sect]",
276
- "Options = UnsafeLegacyRenegotiation",
277
- ].join("\n"),
278
- );
279
-
280
- // dist/pm2.config.cjs
281
- if (info.pm2 != null) {
282
- const pm2Name = info.pm2.name ?? pkgJson.name.replace(/@/g, "").replace(/[/\\]/g, "-");
283
- const ignoreWatch = JSON.stringify([
284
- "node_modules",
285
- "www",
286
- ...(info.pm2.ignoreWatchPaths ?? []),
287
- ]);
288
- const envObj: Record<string, string> = {
289
- NODE_ENV: "production",
290
- TZ: "Asia/Seoul",
291
- ...(info.env ?? {}),
292
- };
293
- const envStr = JSON.stringify(envObj, undefined, 4);
294
-
295
- const useInterpreter = info.packageManager !== "volta";
296
-
297
- const pm2Config = [
298
- ...(useInterpreter ? [`const cp = require("child_process");`, ``] : []),
299
- `module.exports = {`,
300
- ` name: ${JSON.stringify(pm2Name)},`,
301
- ` script: "main.js",`,
302
- ` watch: true,`,
303
- ` watch_delay: 2000,`,
304
- ` ignore_watch: ${ignoreWatch},`,
305
- ...(useInterpreter ? [` interpreter: cp.execSync("mise which node").toString().trim(),`] : []),
306
- ` interpreter_args: "--openssl-config=openssl.cnf",`,
307
- ` env: ${envStr.replace(/\n/g, "\n ")},`,
308
- ` arrayProcess: "concat",`,
309
- ` useDelTargetNull: true,`,
310
- `};`,
311
- ].join("\n");
312
-
313
- fs.writeFileSync(path.join(distDir, "pm2.config.cjs"), pm2Config);
314
- }
315
- }
316
-
317
139
  registerCleanupHandlers(cleanup, logger);
318
140
 
319
141
  //#endregion
@@ -610,11 +432,14 @@ async function startWatch(info: ServerWatchInfo): Promise<void> {
610
432
  const newExternal = cachedExternal;
611
433
 
612
434
  const oldContext = esbuildContext;
613
- if (info.output.js) {
614
- esbuildContext = await createEsbuildWatchContext(info, newEntryPoints, newExternal);
615
- }
616
- if (oldContext != null) {
617
- await oldContext.dispose();
435
+ try {
436
+ if (info.output.js) {
437
+ esbuildContext = await createEsbuildWatchContext(info, newEntryPoints, newExternal);
438
+ }
439
+ } finally {
440
+ if (oldContext != null) {
441
+ await oldContext.dispose();
442
+ }
618
443
  }
619
444
 
620
445
  const result = await rebuildAll();
@@ -39,6 +39,16 @@ describe("processPostCssInline", () => {
39
39
  expect(result).toBe(input);
40
40
  });
41
41
 
42
+ // Acceptance: double-quote 문자열의 인용부호가 보존된다
43
+ it("preserves double-quote style when processing inline CSS", async () => {
44
+ const input = `Component({ styles: [".host { color: blue; }"] })`;
45
+ const result = await processPostCssInline(input, "test.js", [testPlugin]);
46
+
47
+ expect(result).toContain("color: red");
48
+ // double-quote가 보존되어야 한다
49
+ expect(result).toMatch(/".host \{ color: red; \}"/);
50
+ });
51
+
42
52
  // Unit: multiple styles in array
43
53
  it("processes multiple styles in the array", async () => {
44
54
  const input = `Component({ styles: ['.a { color: blue; }', '.b { color: green; }'] })`;
@@ -0,0 +1,11 @@
1
+ # Capacitor Android 설정 분리 — LLM 검증
2
+
3
+ ## 검증 항목
4
+
5
+ - [x] configureAndroid이 capacitor-android.ts에서 export된다: line 13 `export async function configureAndroid`
6
+ - [x] findJava21이 capacitor-android.ts에서 export된다: line 53 `export async function findJava21`
7
+ - [x] findAndroidSdk가 capacitor-android.ts에서 export된다: line 76 `export async function findAndroidSdk`
8
+ - [x] 내부 configure 함수들(_configureJavaHomePath 등)은 export되지 않는다: `^export` 검색 결과 3개만 확인
9
+ - [x] Capacitor 클래스의 _validateTools가 findAndroidSdk/findJava21을 import하여 사용한다: line 8 import, line 217/229 호출
10
+ - [x] Capacitor 클래스에서 9개 Android 설정 private 메서드가 삭제되었다: `_configureAndroid` 등 검색 결과 0건
11
+ - [x] capacitor.ts에서 env import가 제거되었다: `import.*env.*from` 검색 결과 0건
@@ -0,0 +1,219 @@
1
+ import path from "path";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+
4
+ //#region Mocks
5
+
6
+ const mockFsxExists = vi.fn();
7
+ const mockFsxRead = vi.fn();
8
+ const mockFsxWrite = vi.fn().mockResolvedValue(undefined);
9
+ const mockFsxGlob = vi.fn();
10
+
11
+ vi.mock("@simplysm/core-node", () => ({
12
+ fsx: {
13
+ exists: mockFsxExists,
14
+ read: mockFsxRead,
15
+ write: mockFsxWrite,
16
+ glob: mockFsxGlob,
17
+ },
18
+ pathx: {
19
+ posixResolve: (...args: string[]) => path.resolve(...args).replace(/\\/g, "/"),
20
+ posix: (p: string) => p.replace(/\\/g, "/"),
21
+ },
22
+ }));
23
+
24
+ //#endregion
25
+
26
+ describe("findJava21", () => {
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ it("여러 패턴 중 첫 매치를 사용하고 마지막 정렬 결과를 반환한다", async () => {
32
+ mockFsxGlob.mockImplementation((pattern: string) => {
33
+ if (pattern.includes("Amazon Corretto")) {
34
+ return ["C:/Program Files/Amazon Corretto/jdk21.0.1", "C:/Program Files/Amazon Corretto/jdk21.0.3"];
35
+ }
36
+ return [];
37
+ });
38
+
39
+ const { findJava21 } = await import("../../src/capacitor/capacitor-android.js");
40
+ const result = await findJava21();
41
+ expect(result).toBe("C:/Program Files/Amazon Corretto/jdk21.0.3");
42
+ });
43
+
44
+ it("모든 패턴에 매치가 없으면 undefined를 반환한다", async () => {
45
+ mockFsxGlob.mockResolvedValue([]);
46
+
47
+ const { findJava21 } = await import("../../src/capacitor/capacitor-android.js");
48
+ const result = await findJava21();
49
+ expect(result).toBeUndefined();
50
+ });
51
+ });
52
+
53
+ describe("findAndroidSdk", () => {
54
+ let savedEnv: Record<string, string | undefined>;
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ savedEnv = { ...process.env };
58
+ });
59
+ afterEach(() => {
60
+ process.env = savedEnv;
61
+ });
62
+
63
+ it("ANDROID_SDK_ROOT로 SDK를 감지한다", async () => {
64
+ process.env["ANDROID_SDK_ROOT"] = "D:/Android/Sdk";
65
+ mockFsxExists.mockImplementation((p: string) => p === "D:/Android/Sdk");
66
+
67
+ const { findAndroidSdk } = await import("../../src/capacitor/capacitor-android.js");
68
+ const result = await findAndroidSdk();
69
+ expect(result).toBe("D:/Android/Sdk");
70
+ });
71
+
72
+ it("환경변수와 후보 경로 모두 없으면 undefined를 반환한다", async () => {
73
+ delete process.env["ANDROID_SDK_ROOT"];
74
+ delete process.env["ANDROID_HOME"];
75
+ mockFsxExists.mockResolvedValue(false);
76
+
77
+ const { findAndroidSdk } = await import("../../src/capacitor/capacitor-android.js");
78
+ const result = await findAndroidSdk();
79
+ expect(result).toBeUndefined();
80
+ });
81
+ });
82
+
83
+ describe("configureAndroid", () => {
84
+ let savedEnv: Record<string, string | undefined>;
85
+ beforeEach(() => {
86
+ vi.clearAllMocks();
87
+ savedEnv = { ...process.env };
88
+ process.env["ANDROID_HOME"] = "C:/Android/Sdk";
89
+ });
90
+ afterEach(() => {
91
+ process.env = savedEnv;
92
+ });
93
+
94
+ it("Android 디렉토리가 없으면 에러를 던진다", async () => {
95
+ mockFsxExists.mockResolvedValue(false);
96
+
97
+ const { configureAndroid } = await import("../../src/capacitor/capacitor-android.js");
98
+ await expect(
99
+ configureAndroid("/fake/cap", { appId: "com.test.app", appName: "Test" }, { name: "test", version: "1.0.0" }),
100
+ ).rejects.toThrow("Android 프로젝트 디렉토리를 찾을 수 없습니다");
101
+ });
102
+
103
+ it("모든 Android 설정을 순서대로 수행한다", async () => {
104
+ mockFsxExists.mockResolvedValue(true);
105
+ mockFsxGlob.mockResolvedValue(["C:/Program Files/Amazon Corretto/jdk21.0.1"]);
106
+ mockFsxRead.mockImplementation((p: string) => {
107
+ if (p.includes("gradle.properties")) return "org.gradle.jvmargs=-Xmx2048m";
108
+ if (p.includes("local.properties")) return "";
109
+ if (p.includes("AndroidManifest.xml")) {
110
+ return '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n<application>\n<activity android:name=".MainActivity">\n</activity>\n</application>\n</manifest>';
111
+ }
112
+ if (p.includes("app/build.gradle")) {
113
+ return `android {
114
+ defaultConfig {
115
+ versionCode 1
116
+ versionName "1.0"
117
+ minSdkVersion rootProject.ext.minSdkVersion
118
+ targetSdkVersion rootProject.ext.targetSdkVersion
119
+ }
120
+ buildTypes { release { } }
121
+ }`;
122
+ }
123
+ if (p.includes("build.gradle")) {
124
+ return "buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.2.1' } }";
125
+ }
126
+ if (p.includes("styles.xml")) {
127
+ return `<resources>
128
+ <style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
129
+ <item name="android:background">@drawable/splash</item>
130
+ </style>
131
+ </resources>`;
132
+ }
133
+ return "";
134
+ });
135
+
136
+ const { configureAndroid } = await import("../../src/capacitor/capacitor-android.js");
137
+ await configureAndroid(
138
+ "/fake/cap",
139
+ { appId: "com.test.app", appName: "Test App", platform: { android: {} } },
140
+ { name: "test-pkg", version: "2.1.0" },
141
+ );
142
+
143
+ const writeCalls = mockFsxWrite.mock.calls;
144
+
145
+ // JAVA_HOME 설정
146
+ expect(writeCalls.some((c) => typeof c[0] === "string" && c[0].includes("gradle.properties"))).toBe(true);
147
+
148
+ // SDK 경로 설정
149
+ expect(writeCalls.some((c) => typeof c[0] === "string" && c[0].includes("local.properties"))).toBe(true);
150
+
151
+ // AndroidManifest.xml usesCleartextTraffic
152
+ const manifestWrite = writeCalls.find(
153
+ (c) => typeof c[0] === "string" && c[0].includes("AndroidManifest.xml"),
154
+ );
155
+ expect(manifestWrite).toBeDefined();
156
+ expect(manifestWrite![1]).toContain("usesCleartextTraffic");
157
+
158
+ // build.gradle versionCode (2.1.0 → 2001000)
159
+ const gradleWrite = writeCalls.find(
160
+ (c) => typeof c[0] === "string" && c[0].includes("app/build.gradle"),
161
+ );
162
+ expect(gradleWrite).toBeDefined();
163
+ expect(gradleWrite![1]).toContain("versionCode 2001000");
164
+ expect(gradleWrite![1]).toContain('versionName "2.1.0"');
165
+
166
+ // styles.xml Theme 변경
167
+ const stylesWrite = writeCalls.find(
168
+ (c) => typeof c[0] === "string" && c[0].includes("styles.xml"),
169
+ );
170
+ expect(stylesWrite).toBeDefined();
171
+ expect(stylesWrite![1]).toContain('parent="Theme.AppCompat.DayNight.NoActionBar"');
172
+ expect(stylesWrite![1]).toContain('"android:windowBackground">@drawable/splash');
173
+ });
174
+
175
+ it("minor/patch >= 100인 버전에서 versionCode가 충돌하지 않는다", async () => {
176
+ mockFsxExists.mockResolvedValue(true);
177
+ mockFsxGlob.mockResolvedValue(["C:/Program Files/Amazon Corretto/jdk21.0.1"]);
178
+ mockFsxRead.mockImplementation((p: string) => {
179
+ if (p.includes("gradle.properties")) return "";
180
+ if (p.includes("local.properties")) return "";
181
+ if (p.includes("AndroidManifest.xml")) {
182
+ return '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n<application>\n<activity android:name=".MainActivity">\n</activity>\n</application>\n</manifest>';
183
+ }
184
+ if (p.includes("app/build.gradle")) {
185
+ return "android { defaultConfig { versionCode 1\nversionName \"1.0\"\nminSdkVersion rootProject.ext.minSdkVersion\ntargetSdkVersion rootProject.ext.targetSdkVersion } }";
186
+ }
187
+ if (p.includes("build.gradle")) return "";
188
+ if (p.includes("styles.xml")) {
189
+ return '<resources><style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen"></style></resources>';
190
+ }
191
+ return "";
192
+ });
193
+
194
+ const { configureAndroid } = await import("../../src/capacitor/capacitor-android.js");
195
+
196
+ // "1.0.100"과 "1.1.0"은 서로 다른 versionCode를 가져야 한다
197
+ await configureAndroid(
198
+ "/fake/cap",
199
+ { appId: "com.test.app", appName: "Test" },
200
+ { name: "test", version: "1.0.100" },
201
+ );
202
+ const call1 = mockFsxWrite.mock.calls.find(
203
+ (c) => typeof c[0] === "string" && c[0].includes("app/build.gradle"),
204
+ );
205
+ expect(call1![1]).toContain("versionCode 1000100");
206
+
207
+ mockFsxWrite.mockClear();
208
+
209
+ await configureAndroid(
210
+ "/fake/cap",
211
+ { appId: "com.test.app", appName: "Test" },
212
+ { name: "test", version: "1.1.0" },
213
+ );
214
+ const call2 = mockFsxWrite.mock.calls.find(
215
+ (c) => typeof c[0] === "string" && c[0].includes("app/build.gradle"),
216
+ );
217
+ expect(call2![1]).toContain("versionCode 1001000");
218
+ });
219
+ });