@simplysm/sd-cli 14.0.47 → 14.0.48

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 (126) hide show
  1. package/README.md +782 -0
  2. package/dist/angular/ngtsc-build-core.js +2 -2
  3. package/dist/angular/ngtsc-build-core.js.map +1 -1
  4. package/dist/angular/vite-angular-plugin.d.ts.map +1 -1
  5. package/dist/angular/vite-angular-plugin.js +3 -2
  6. package/dist/angular/vite-angular-plugin.js.map +1 -1
  7. package/dist/capacitor/capacitor-android.js +2 -2
  8. package/dist/capacitor/capacitor-android.js.map +1 -1
  9. package/dist/capacitor/capacitor-build.d.ts.map +1 -1
  10. package/dist/capacitor/capacitor-build.js +2 -1
  11. package/dist/capacitor/capacitor-build.js.map +1 -1
  12. package/dist/capacitor/capacitor-icon.d.ts.map +1 -1
  13. package/dist/capacitor/capacitor-icon.js +2 -1
  14. package/dist/capacitor/capacitor-icon.js.map +1 -1
  15. package/dist/capacitor/capacitor-npm-config.d.ts.map +1 -1
  16. package/dist/capacitor/capacitor-npm-config.js +2 -1
  17. package/dist/capacitor/capacitor-npm-config.js.map +1 -1
  18. package/dist/capacitor/capacitor.d.ts.map +1 -1
  19. package/dist/capacitor/capacitor.js +2 -1
  20. package/dist/capacitor/capacitor.js.map +1 -1
  21. package/dist/commands/device.js +2 -2
  22. package/dist/commands/device.js.map +1 -1
  23. package/dist/commands/replace-deps.js +2 -2
  24. package/dist/commands/replace-deps.js.map +1 -1
  25. package/dist/deps/replace-deps/collect-deps.js +2 -2
  26. package/dist/deps/replace-deps/collect-deps.js.map +1 -1
  27. package/dist/deps/replace-deps/replace-deps.d.ts.map +1 -1
  28. package/dist/deps/replace-deps/replace-deps.js +108 -81
  29. package/dist/deps/replace-deps/replace-deps.js.map +1 -1
  30. package/dist/deps/server-externals/server-production-files.js +2 -2
  31. package/dist/deps/server-externals/server-production-files.js.map +1 -1
  32. package/dist/electron/electron.d.ts.map +1 -1
  33. package/dist/electron/electron.js +2 -1
  34. package/dist/electron/electron.js.map +1 -1
  35. package/dist/engines/BaseEngine.d.ts.map +1 -1
  36. package/dist/engines/BaseEngine.js +2 -2
  37. package/dist/engines/BaseEngine.js.map +1 -1
  38. package/dist/engines/EsbuildClientEngine.d.ts.map +1 -1
  39. package/dist/engines/EsbuildClientEngine.js +2 -2
  40. package/dist/engines/EsbuildClientEngine.js.map +1 -1
  41. package/dist/engines/NgtscEngine.js +2 -2
  42. package/dist/engines/NgtscEngine.js.map +1 -1
  43. package/dist/engines/ServerEsbuildEngine.js +2 -2
  44. package/dist/engines/ServerEsbuildEngine.js.map +1 -1
  45. package/dist/engines/TscEngine.js +2 -2
  46. package/dist/engines/TscEngine.js.map +1 -1
  47. package/dist/engines/engine-factory.d.ts.map +1 -1
  48. package/dist/engines/engine-factory.js +2 -2
  49. package/dist/engines/engine-factory.js.map +1 -1
  50. package/dist/esbuild/esbuild-angular-compiler-plugin.d.ts.map +1 -1
  51. package/dist/esbuild/esbuild-angular-compiler-plugin.js +46 -18
  52. package/dist/esbuild/esbuild-angular-compiler-plugin.js.map +1 -1
  53. package/dist/esbuild/esbuild-config.js +2 -2
  54. package/dist/esbuild/esbuild-config.js.map +1 -1
  55. package/dist/lint/lint-with-program.js +2 -2
  56. package/dist/lint/lint-with-program.js.map +1 -1
  57. package/dist/runtime/lazy-logger.d.ts +14 -0
  58. package/dist/runtime/lazy-logger.d.ts.map +1 -0
  59. package/dist/runtime/lazy-logger.js +23 -0
  60. package/dist/runtime/lazy-logger.js.map +1 -0
  61. package/dist/sd-cli-entry.js +2 -2
  62. package/dist/sd-cli-entry.js.map +1 -1
  63. package/dist/sd-cli.js +2 -2
  64. package/dist/sd-cli.js.map +1 -1
  65. package/dist/ts-compiler/SdTsCompiler.d.ts +11 -0
  66. package/dist/ts-compiler/SdTsCompiler.d.ts.map +1 -1
  67. package/dist/ts-compiler/SdTsCompiler.js +223 -116
  68. package/dist/ts-compiler/SdTsCompiler.js.map +1 -1
  69. package/dist/typecheck/typecheck-non-package.js +2 -2
  70. package/dist/typecheck/typecheck-non-package.js.map +1 -1
  71. package/dist/typecheck/typecheck-serialization.d.ts +31 -9
  72. package/dist/typecheck/typecheck-serialization.d.ts.map +1 -1
  73. package/dist/typecheck/typecheck-serialization.js +62 -22
  74. package/dist/typecheck/typecheck-serialization.js.map +1 -1
  75. package/dist/utils/output-utils.js +2 -2
  76. package/dist/utils/output-utils.js.map +1 -1
  77. package/dist/utils/package-classify.js +2 -2
  78. package/dist/utils/package-classify.js.map +1 -1
  79. package/dist/utils/package-utils.js +2 -2
  80. package/dist/utils/package-utils.js.map +1 -1
  81. package/dist/utils/sd-config.js +2 -2
  82. package/dist/utils/sd-config.js.map +1 -1
  83. package/dist/utils/tsconfig.d.ts.map +1 -1
  84. package/dist/utils/tsconfig.js +3 -5
  85. package/dist/utils/tsconfig.js.map +1 -1
  86. package/package.json +4 -4
  87. package/src/angular/ngtsc-build-core.ts +3 -3
  88. package/src/angular/vite-angular-plugin.ts +3 -2
  89. package/src/capacitor/capacitor-android.ts +2 -2
  90. package/src/capacitor/capacitor-build.ts +2 -1
  91. package/src/capacitor/capacitor-icon.ts +2 -1
  92. package/src/capacitor/capacitor-npm-config.ts +2 -1
  93. package/src/capacitor/capacitor.ts +2 -1
  94. package/src/commands/device.ts +2 -2
  95. package/src/commands/replace-deps.ts +2 -2
  96. package/src/deps/replace-deps/collect-deps.ts +2 -2
  97. package/src/deps/replace-deps/replace-deps.ts +119 -85
  98. package/src/deps/server-externals/server-production-files.ts +2 -2
  99. package/src/electron/electron.ts +2 -1
  100. package/src/engines/BaseEngine.ts +2 -2
  101. package/src/engines/EsbuildClientEngine.ts +2 -2
  102. package/src/engines/NgtscEngine.ts +2 -2
  103. package/src/engines/ServerEsbuildEngine.ts +2 -2
  104. package/src/engines/TscEngine.ts +2 -2
  105. package/src/engines/engine-factory.ts +2 -2
  106. package/src/esbuild/esbuild-angular-compiler-plugin.ts +60 -19
  107. package/src/esbuild/esbuild-config.ts +2 -2
  108. package/src/lint/lint-with-program.ts +2 -2
  109. package/src/runtime/lazy-logger.ts +23 -0
  110. package/src/sd-cli-entry.ts +2 -2
  111. package/src/sd-cli.ts +2 -2
  112. package/src/ts-compiler/SdTsCompiler.ts +280 -138
  113. package/src/typecheck/typecheck-non-package.ts +2 -2
  114. package/src/typecheck/typecheck-serialization.ts +100 -26
  115. package/src/utils/output-utils.ts +2 -2
  116. package/src/utils/package-classify.ts +2 -2
  117. package/src/utils/package-utils.ts +2 -2
  118. package/src/utils/sd-config.ts +2 -2
  119. package/src/utils/tsconfig.ts +3 -4
  120. package/tests/esbuild/esbuild-worker-plugin.spec.ts +8 -0
  121. package/tests/utils/replace-deps-watch.acc.spec.ts +85 -0
  122. package/tests/utils/replace-deps-watch.spec.ts +198 -1
  123. package/dist/sd-cli/tests/angular/fixtures/packages/basic-app/tests/test.fixture.d.ts +0 -3
  124. package/dist/sd-cli/tests/angular/fixtures/packages/basic-app/tests/test.fixture.d.ts.map +0 -1
  125. package/dist/sd-cli/tests/angular/fixtures/packages/basic-app/tests/test.fixture.js +0 -9
  126. package/dist/sd-cli/tests/angular/fixtures/packages/basic-app/tests/test.fixture.js.map +0 -1
@@ -2,38 +2,87 @@ import ts from "typescript";
2
2
  import { fsx } from "@simplysm/core-node";
3
3
 
4
4
  /**
5
- * Worker로 전달할 있는 직렬화된 Diagnostic
5
+ * DiagnosticMessageChain을 worker 경계로 넘기기 위한 직렬화 구조.
6
+ * (chain 구조를 그대로 보존하여 formatter가 원본 들여쓰기/순서대로 출력하게 함)
7
+ */
8
+ export interface SerializedMessageChain {
9
+ messageText: string;
10
+ category: number;
11
+ code: number;
12
+ next?: SerializedMessageChain[];
13
+ }
14
+
15
+ /**
16
+ * Worker로 전달할 수 있는 직렬화된 Diagnostic.
17
+ * ts.Diagnostic의 모든 사용자 가시 필드(detail/relatedInformation/flag)를 보존한다.
6
18
  */
7
19
  export interface SerializedDiagnostic {
8
20
  category: number;
9
21
  code: number;
10
- messageText: string;
22
+ /** messageText chain(overload 에러 등)이거나 단순 문자열. chain이면 구조 그대로 보존 */
23
+ messageText: string | SerializedMessageChain;
11
24
  file?: {
12
25
  fileName: string;
13
26
  };
14
27
  start?: number;
15
28
  length?: number;
29
+ relatedInformation?: SerializedDiagnosticRelatedInformation[];
30
+ /** true 또는 ts가 넘기는 (빈) 객체. formatter가 인식. */
31
+ reportsUnnecessary?: boolean;
32
+ reportsDeprecated?: boolean;
33
+ source?: string;
34
+ }
35
+
36
+ /** ts.DiagnosticRelatedInformation에 대응. file/start/length와 messageText만 가진 축약 구조. */
37
+ export interface SerializedDiagnosticRelatedInformation {
38
+ category: number;
39
+ code: number;
40
+ messageText: string | SerializedMessageChain;
41
+ file?: { fileName: string };
42
+ start?: number;
43
+ length?: number;
44
+ }
45
+
46
+ function serializeMessageChain(chain: ts.DiagnosticMessageChain): SerializedMessageChain {
47
+ return {
48
+ messageText: chain.messageText,
49
+ category: chain.category,
50
+ code: chain.code,
51
+ next: chain.next?.map(serializeMessageChain),
52
+ };
53
+ }
54
+
55
+ function serializeMessageText(
56
+ messageText: string | ts.DiagnosticMessageChain,
57
+ ): string | SerializedMessageChain {
58
+ if (typeof messageText === "string") return messageText;
59
+ return serializeMessageChain(messageText);
16
60
  }
17
61
 
18
62
  /**
19
63
  * Diagnostic을 직렬화 가능한 형태로 변환한다.
20
64
  * (Worker 스레드 간 structured clone 통신을 위해 순환 참조/함수를 제거)
65
+ * messageText chain, relatedInformation, reportsUnnecessary/Deprecated, source 등 모든 detail 보존.
21
66
  */
22
67
  export function serializeDiagnostic(diagnostic: ts.Diagnostic): SerializedDiagnostic {
23
- // DiagnosticMessageChain인 경우 모든 컨텍스트 정보를 보존하기 위해 전체 체인을 평탄화
24
- const messageText = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
25
-
26
68
  return {
27
69
  category: diagnostic.category,
28
70
  code: diagnostic.code,
29
- messageText,
30
- file: diagnostic.file
31
- ? {
32
- fileName: diagnostic.file.fileName,
33
- }
34
- : undefined,
71
+ messageText: serializeMessageText(diagnostic.messageText),
72
+ file: diagnostic.file != null ? { fileName: diagnostic.file.fileName } : undefined,
35
73
  start: diagnostic.start,
36
74
  length: diagnostic.length,
75
+ relatedInformation: diagnostic.relatedInformation?.map((ri) => ({
76
+ category: ri.category,
77
+ code: ri.code,
78
+ messageText: serializeMessageText(ri.messageText),
79
+ file: ri.file != null ? { fileName: ri.file.fileName } : undefined,
80
+ start: ri.start,
81
+ length: ri.length,
82
+ })),
83
+ reportsUnnecessary: diagnostic.reportsUnnecessary != null ? true : undefined,
84
+ reportsDeprecated: diagnostic.reportsDeprecated != null ? true : undefined,
85
+ source: diagnostic.source,
37
86
  };
38
87
  }
39
88
 
@@ -53,32 +102,57 @@ function getScriptKind(fileName: string): ts.ScriptKind {
53
102
  * @param fileCache - 파일 내용 캐시 (같은 파일의 중복 읽기 방지)
54
103
  * @returns 복원된 ts.Diagnostic 객체
55
104
  */
105
+ function deserializeMessageChain(chain: SerializedMessageChain): ts.DiagnosticMessageChain {
106
+ return {
107
+ messageText: chain.messageText,
108
+ category: chain.category,
109
+ code: chain.code,
110
+ next: chain.next?.map(deserializeMessageChain),
111
+ };
112
+ }
113
+
114
+ function deserializeMessageText(
115
+ messageText: string | SerializedMessageChain,
116
+ ): string | ts.DiagnosticMessageChain {
117
+ if (typeof messageText === "string") return messageText;
118
+ return deserializeMessageChain(messageText);
119
+ }
120
+
121
+ function loadFile(fileName: string, fileCache: Map<string, string>): ts.SourceFile {
122
+ if (!fileCache.has(fileName)) {
123
+ fileCache.set(fileName, fsx.existsSync(fileName) ? fsx.readSync(fileName) : "");
124
+ }
125
+ const content = fileCache.get(fileName)!;
126
+ const scriptKind = getScriptKind(fileName);
127
+ return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, false, scriptKind);
128
+ }
129
+
56
130
  export function deserializeDiagnostic(
57
131
  serialized: SerializedDiagnostic,
58
132
  fileCache: Map<string, string>,
59
133
  ): ts.Diagnostic {
60
- let file: ts.SourceFile | undefined;
61
- if (serialized.file != null) {
62
- const fileName = serialized.file.fileName;
134
+ const file = serialized.file != null ? loadFile(serialized.file.fileName, fileCache) : undefined;
63
135
 
64
- // 캐시된 파일 내용 가져오기 (없으면 읽어서 캐시)
65
- // 파일이 삭제되었거나 접근 불가능하면 빈 내용으로 처리
66
- // (소스 코드 컨텍스트는 표시되지 않지만 진단 메시지는 정상 표시됨)
67
- if (!fileCache.has(fileName)) {
68
- fileCache.set(fileName, fsx.existsSync(fileName) ? fsx.readSync(fileName) : "");
69
- }
70
- const content = fileCache.get(fileName)!;
71
-
72
- const scriptKind = getScriptKind(fileName);
73
- file = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, false, scriptKind);
74
- }
136
+ const relatedInformation: ts.DiagnosticRelatedInformation[] | undefined =
137
+ serialized.relatedInformation?.map((ri) => ({
138
+ category: ri.category,
139
+ code: ri.code,
140
+ messageText: deserializeMessageText(ri.messageText),
141
+ file: ri.file != null ? loadFile(ri.file.fileName, fileCache) : undefined,
142
+ start: ri.start,
143
+ length: ri.length,
144
+ }));
75
145
 
76
146
  return {
77
147
  category: serialized.category,
78
148
  code: serialized.code,
79
- messageText: serialized.messageText,
149
+ messageText: deserializeMessageText(serialized.messageText),
80
150
  file,
81
151
  start: serialized.start,
82
152
  length: serialized.length,
153
+ relatedInformation,
154
+ reportsUnnecessary: serialized.reportsUnnecessary === true ? {} : undefined,
155
+ reportsDeprecated: serialized.reportsDeprecated === true ? {} : undefined,
156
+ source: serialized.source,
83
157
  };
84
158
  }
@@ -1,8 +1,8 @@
1
1
  import { formatMessagesSync, type PartialMessage } from "esbuild";
2
- import { consola } from "consola";
2
+ import { createLazyLogger } from "../runtime/lazy-logger";
3
3
  import type { BuildResult } from "../runtime/ResultCollector";
4
4
 
5
- const logger = consola.withTag("sd:cli:output");
5
+ const logger = createLazyLogger("sd:cli:output");
6
6
 
7
7
  /**
8
8
  * esbuild Message 배열을 포맷된 문자열 배열로 변환한다.
@@ -1,6 +1,6 @@
1
1
  import path from "path";
2
- import { consola } from "consola";
3
2
  import { pathx } from "@simplysm/core-node";
3
+ import { createLazyLogger } from "../runtime/lazy-logger";
4
4
  import type {
5
5
  BuildTarget,
6
6
  SdBuildPackageConfig,
@@ -10,7 +10,7 @@ import type {
10
10
  SdServerPackageConfig,
11
11
  } from "../sd-config.types";
12
12
 
13
- const logger = consola.withTag("sd:cli:package-classify");
13
+ const logger = createLazyLogger("sd:cli:package-classify");
14
14
 
15
15
  /**
16
16
  * 패키지 config를 순회하며 null 필터링 + target 필터링을 수행한다.
@@ -1,14 +1,14 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
- import { consola } from "consola";
4
3
  import { SdError } from "@simplysm/core-common";
5
4
  import { pathx } from "@simplysm/core-node";
5
+ import { createLazyLogger } from "../runtime/lazy-logger";
6
6
  import type {
7
7
  SdBuildPackageConfig,
8
8
  SdPackageConfig,
9
9
  } from "../sd-config.types";
10
10
 
11
- const logger = consola.withTag("sd:cli:package-utils");
11
+ const logger = createLazyLogger("sd:cli:package-utils");
12
12
 
13
13
  /**
14
14
  * import.meta.dirname에서 위로 탐색하여 package.json을 찾고 패키지 루트를 반환한다.
@@ -1,10 +1,10 @@
1
1
  import { createJiti } from "jiti";
2
2
  import { SdError } from "@simplysm/core-common";
3
3
  import { fsx, pathx } from "@simplysm/core-node";
4
- import { consola } from "consola";
4
+ import { createLazyLogger } from "../runtime/lazy-logger";
5
5
  import type { SdConfig, SdConfigParams } from "../sd-config.types";
6
6
 
7
- const logger = consola.withTag("sd:cli:sd-config");
7
+ const logger = createLazyLogger("sd:cli:sd-config");
8
8
 
9
9
  /**
10
10
  * sd.config.ts를 로드한다.
@@ -2,9 +2,9 @@ import path from "path";
2
2
  import fs from "fs";
3
3
  import ts from "typescript";
4
4
  import { pathx } from "@simplysm/core-node";
5
- import { consola } from "consola";
5
+ import { createLazyLogger } from "../runtime/lazy-logger";
6
6
 
7
- const logger = consola.withTag("sd:cli:tsconfig");
7
+ const logger = createLazyLogger("sd:cli:tsconfig");
8
8
 
9
9
  //#region TypecheckEnv
10
10
 
@@ -110,10 +110,9 @@ export function getPackageSourceFiles(
110
110
  const srcDir = path.join(pkgDir, "src");
111
111
  const files = parsedConfig.fileNames.filter((f) => {
112
112
  if (pathx.isChildPath(f, srcDir)) return true;
113
- if (f.endsWith(".fixture.ts")) return true;
114
113
  return false;
115
114
  });
116
- logger.debug(`소스 파일 필터링: ${parsedConfig.fileNames.length}개 중 ${files.length}개 (src/ + fixtures)`);
115
+ logger.debug(`소스 파일 필터링: ${parsedConfig.fileNames.length}개 중 ${files.length}개 (src/ only)`);
117
116
  return files;
118
117
  }
119
118
 
@@ -476,6 +476,14 @@ const x = 1;`,
476
476
 
477
477
  expect(matches).toHaveLength(0);
478
478
  });
479
+
480
+ it("래퍼 함수를 통한 Worker 생성은 탐지하지 않는다", () => {
481
+ const matches = findWorkerPatterns(
482
+ `const w = createBrowserWorker(new URL("./worker.js", import.meta.url), { type: "module" });`,
483
+ );
484
+
485
+ expect(matches).toHaveLength(0);
486
+ });
479
487
  });
480
488
 
481
489
  describe("transformWorkerPatterns — TypeScript 파일 처리", () => {
@@ -134,4 +134,89 @@ describe("watchReplaceDeps onChanged 콜백", () => {
134
134
  // 콜백 호출 대기
135
135
  await changedPromise;
136
136
  }, 10_000);
137
+
138
+ it("동일 source를 참조하는 복수 entry가 있으면 모든 target에 복사된다", async () => {
139
+ // 추가 target 디렉토리 생성 (@test/pkgB)
140
+ const targetDirB = path.join(tmpDir, "project", "node_modules", "@test", "pkgB", "src");
141
+ await fs.promises.mkdir(targetDirB, { recursive: true });
142
+ await fs.promises.writeFile(path.join(targetDirB, "index.ts"), "export const a = 1;");
143
+
144
+ const projectRoot = path.join(tmpDir, "project");
145
+ const sourcePath = path.join(tmpDir, "source-pkg");
146
+
147
+ // 두 패턴이 동일 sourcePath를 참조
148
+ const replaceDeps = {
149
+ "@test/pkg": sourcePath,
150
+ "@test/pkgB": sourcePath,
151
+ };
152
+
153
+ let callCount = 0;
154
+ watchResult = await watchReplaceDeps(projectRoot, replaceDeps, {
155
+ onChanged: () => {
156
+ callCount++;
157
+ },
158
+ });
159
+
160
+ // 소스 파일 변경
161
+ await fs.promises.writeFile(
162
+ path.join(sourcePath, "src", "index.ts"),
163
+ "export const a = 9;",
164
+ );
165
+
166
+ // 배칭 + 복사 완료 대기
167
+ await new Promise((r) => setTimeout(r, 1500));
168
+
169
+ // 두 target 모두 업데이트
170
+ const targetAContent = await fs.promises.readFile(
171
+ path.join(tmpDir, "project", "node_modules", "@test", "pkg", "src", "index.ts"),
172
+ "utf-8",
173
+ );
174
+ const targetBContent = await fs.promises.readFile(
175
+ path.join(targetDirB, "index.ts"),
176
+ "utf-8",
177
+ );
178
+ expect(targetAContent).toBe("export const a = 9;");
179
+ expect(targetBContent).toBe("export const a = 9;");
180
+
181
+ // 동일 source의 중복 watchPath는 하나로 통합되므로 이벤트가 1회만 배칭됨 → onChanged 1회
182
+ expect(callCount).toBe(1);
183
+ }, 10_000);
184
+
185
+ it("소스 파일이 삭제되면 대상 파일도 삭제된다", async () => {
186
+ const projectRoot = path.join(tmpDir, "project");
187
+ const sourcePath = path.join(tmpDir, "source-pkg");
188
+ const replaceDeps = { "@test/pkg": sourcePath };
189
+
190
+ let resolveChanged: () => void;
191
+ const changedPromise = new Promise<void>((resolve) => {
192
+ resolveChanged = resolve;
193
+ });
194
+
195
+ watchResult = await watchReplaceDeps(projectRoot, replaceDeps, {
196
+ onChanged: () => {
197
+ resolveChanged();
198
+ },
199
+ });
200
+
201
+ const destPath = path.join(
202
+ tmpDir, "project", "node_modules", "@test", "pkg", "src", "index.ts",
203
+ );
204
+
205
+ // 삭제 전 target 파일 존재 확인
206
+ await expect(fs.promises.access(destPath)).resolves.toBeUndefined();
207
+
208
+ // 소스 삭제
209
+ await fs.promises.unlink(path.join(sourcePath, "src", "index.ts"));
210
+
211
+ await changedPromise;
212
+
213
+ // 삭제 반영까지 여유 대기
214
+ await new Promise((r) => setTimeout(r, 500));
215
+
216
+ // target도 삭제됨
217
+ const targetExists = await fs.promises
218
+ .access(destPath)
219
+ .then(() => true, () => false);
220
+ expect(targetExists).toBe(false);
221
+ }, 10_000);
137
222
  });
@@ -1,7 +1,8 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import os from "os";
5
+ import { consola } from "consola";
5
6
  import { watchReplaceDeps } from "../../src/deps/replace-deps/replace-deps";
6
7
  import type { WatchReplaceDepResult } from "../../src/deps/replace-deps/replace-deps";
7
8
 
@@ -12,10 +13,28 @@ import type { WatchReplaceDepResult } from "../../src/deps/replace-deps/replace-
12
13
  describe("watchReplaceDeps onChanged", () => {
13
14
  let tmpDir: string;
14
15
  let watchResult: WatchReplaceDepResult | undefined;
16
+ let mockLogger: {
17
+ warn: ReturnType<typeof vi.fn>;
18
+ error: ReturnType<typeof vi.fn>;
19
+ info: ReturnType<typeof vi.fn>;
20
+ start: ReturnType<typeof vi.fn>;
21
+ success: ReturnType<typeof vi.fn>;
22
+ debug: ReturnType<typeof vi.fn>;
23
+ };
15
24
 
16
25
  beforeEach(async () => {
17
26
  tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "sd-replace-deps-unit-"));
18
27
 
28
+ mockLogger = {
29
+ warn: vi.fn(),
30
+ error: vi.fn(),
31
+ info: vi.fn(),
32
+ start: vi.fn(),
33
+ success: vi.fn(),
34
+ debug: vi.fn(),
35
+ };
36
+ vi.spyOn(consola, "withTag").mockReturnValue(mockLogger as any);
37
+
19
38
  // 소스 패키지 생성
20
39
  const sourcePkg = path.join(tmpDir, "source-pkg");
21
40
  await fs.promises.mkdir(sourcePkg, { recursive: true });
@@ -43,6 +62,7 @@ describe("watchReplaceDeps onChanged", () => {
43
62
  watchResult?.dispose();
44
63
  watchResult = undefined;
45
64
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
65
+ vi.restoreAllMocks();
46
66
  });
47
67
 
48
68
  it("복수 파일 변경 시 300ms 배칭 후 onChanged가 한 번 호출된다", async () => {
@@ -131,6 +151,58 @@ describe("watchReplaceDeps onChanged", () => {
131
151
  await changedPromise;
132
152
  }, 10_000);
133
153
 
154
+ it("nested source 경로는 longest-prefix로 소속이 결정된다", async () => {
155
+ // sourceOuter: tmpDir/outer-pkg
156
+ // sourceInner: tmpDir/outer-pkg/inner-pkg (outer의 하위)
157
+ const outerPkg = path.join(tmpDir, "outer-pkg");
158
+ await fs.promises.mkdir(path.join(outerPkg, "src"), { recursive: true });
159
+ await fs.promises.writeFile(
160
+ path.join(outerPkg, "package.json"),
161
+ JSON.stringify({ name: "@test/outer", files: ["src"] }),
162
+ );
163
+ await fs.promises.writeFile(path.join(outerPkg, "src", "o.ts"), "o");
164
+
165
+ const innerPkg = path.join(outerPkg, "inner-pkg");
166
+ await fs.promises.mkdir(path.join(innerPkg, "src"), { recursive: true });
167
+ await fs.promises.writeFile(
168
+ path.join(innerPkg, "package.json"),
169
+ JSON.stringify({ name: "@test/inner", files: ["src"] }),
170
+ );
171
+ await fs.promises.writeFile(path.join(innerPkg, "src", "i.ts"), "i");
172
+
173
+ // target들
174
+ const outerTarget = path.join(
175
+ tmpDir, "project", "node_modules", "@test", "outer", "src",
176
+ );
177
+ const innerTarget = path.join(
178
+ tmpDir, "project", "node_modules", "@test", "inner", "src",
179
+ );
180
+ await fs.promises.mkdir(outerTarget, { recursive: true });
181
+ await fs.promises.mkdir(innerTarget, { recursive: true });
182
+ await fs.promises.writeFile(path.join(outerTarget, "o.ts"), "o");
183
+ await fs.promises.writeFile(path.join(innerTarget, "i.ts"), "i");
184
+
185
+ const projectRoot = path.join(tmpDir, "project");
186
+
187
+ watchResult = await watchReplaceDeps(
188
+ projectRoot,
189
+ { "@test/outer": outerPkg, "@test/inner": innerPkg },
190
+ );
191
+
192
+ // inner 파일 변경 — longest-prefix로 inner source에만 매칭되어야 함
193
+ await fs.promises.writeFile(path.join(innerPkg, "src", "i.ts"), "i2");
194
+ await new Promise((r) => setTimeout(r, 1500));
195
+
196
+ const innerCopied = await fs.promises.readFile(path.join(innerTarget, "i.ts"), "utf-8");
197
+ expect(innerCopied).toBe("i2");
198
+
199
+ // outer target에는 i.ts가 복사되지 않아야 함 (잘못된 매칭 방지)
200
+ const outerHasI = await fs.promises
201
+ .access(path.join(outerTarget, "inner-pkg", "src", "i.ts"))
202
+ .then(() => true, () => false);
203
+ expect(outerHasI).toBe(false);
204
+ }, 10_000);
205
+
134
206
  it("options 파라미터가 undefined일 때 에러가 발생하지 않는다", async () => {
135
207
  const projectRoot = path.join(tmpDir, "project");
136
208
  const sourcePath = path.join(tmpDir, "source-pkg");
@@ -144,4 +216,129 @@ describe("watchReplaceDeps onChanged", () => {
144
216
  await new Promise((r) => setTimeout(r, 1500));
145
217
  // 에러 없이 완료됨
146
218
  }, 10_000);
219
+
220
+ it("source의 files 필드가 없으면 경고를 출력하고 감시에서 제외한다", async () => {
221
+ const projectRoot = path.join(tmpDir, "project");
222
+ const sourcePath = path.join(tmpDir, "source-pkg");
223
+
224
+ // files 필드 제거
225
+ await fs.promises.writeFile(
226
+ path.join(sourcePath, "package.json"),
227
+ JSON.stringify({ name: "@test/pkg" }),
228
+ );
229
+
230
+ let callCount = 0;
231
+ watchResult = await watchReplaceDeps(projectRoot, { "@test/pkg": sourcePath }, {
232
+ onChanged: () => {
233
+ callCount++;
234
+ },
235
+ });
236
+
237
+ expect(mockLogger.warn).toHaveBeenCalledWith(
238
+ expect.stringContaining("package.json에 files 필드가 없어 감시 건너뜀"),
239
+ );
240
+
241
+ // 파일을 변경해도 감지되지 않음
242
+ await fs.promises.writeFile(
243
+ path.join(sourcePath, "src", "index.ts"),
244
+ "export const v = 2;",
245
+ );
246
+ await new Promise((r) => setTimeout(r, 1500));
247
+
248
+ expect(callCount).toBe(0);
249
+ }, 10_000);
250
+
251
+ it("동일 내용 재저장 시 isFileContentSame 스킵으로 onChanged가 호출되지 않는다", async () => {
252
+ const projectRoot = path.join(tmpDir, "project");
253
+ const sourcePath = path.join(tmpDir, "source-pkg");
254
+
255
+ // beforeEach에서 source와 target 모두 "export const v = 1;"로 설정된 상태
256
+ let callCount = 0;
257
+ watchResult = await watchReplaceDeps(projectRoot, { "@test/pkg": sourcePath }, {
258
+ onChanged: () => {
259
+ callCount++;
260
+ },
261
+ });
262
+
263
+ // 동일 내용으로 재저장 (mtime은 변경되지만 내용은 같음)
264
+ await fs.promises.writeFile(
265
+ path.join(sourcePath, "src", "index.ts"),
266
+ "export const v = 1;",
267
+ );
268
+
269
+ await new Promise((r) => setTimeout(r, 1500));
270
+
271
+ // isFileContentSame이 true를 반환하여 복사 스킵 → hasActualCopy=false → onChanged 미호출
272
+ expect(callCount).toBe(0);
273
+ }, 10_000);
274
+
275
+ it("dispose 이후의 파일 변경은 감지되지 않는다", async () => {
276
+ const projectRoot = path.join(tmpDir, "project");
277
+ const sourcePath = path.join(tmpDir, "source-pkg");
278
+
279
+ let callCount = 0;
280
+ const result = await watchReplaceDeps(projectRoot, { "@test/pkg": sourcePath }, {
281
+ onChanged: () => {
282
+ callCount++;
283
+ },
284
+ });
285
+
286
+ result.dispose();
287
+ // afterEach에서 중복 dispose 방지
288
+ watchResult = undefined;
289
+
290
+ // close 완료 대기
291
+ await new Promise((r) => setTimeout(r, 500));
292
+
293
+ await fs.promises.writeFile(
294
+ path.join(sourcePath, "src", "index.ts"),
295
+ "export const v = 99;",
296
+ );
297
+
298
+ await new Promise((r) => setTimeout(r, 1500));
299
+
300
+ expect(callCount).toBe(0);
301
+ }, 10_000);
302
+
303
+ it("모든 source의 files 필드가 없으면 감시 대상이 없음 경고를 출력한다", async () => {
304
+ const projectRoot = path.join(tmpDir, "project");
305
+ const sourcePath = path.join(tmpDir, "source-pkg");
306
+
307
+ // files 필드 제거
308
+ await fs.promises.writeFile(
309
+ path.join(sourcePath, "package.json"),
310
+ JSON.stringify({ name: "@test/pkg" }),
311
+ );
312
+
313
+ watchResult = await watchReplaceDeps(projectRoot, { "@test/pkg": sourcePath });
314
+
315
+ expect(mockLogger.warn).toHaveBeenCalledWith(
316
+ expect.stringContaining("감시 대상이 없어 워치가 시작되지 않음"),
317
+ );
318
+ }, 10_000);
319
+
320
+ it("files 필드가 없으면 해당 source에 대한 readdir이 호출되지 않는다", async () => {
321
+ const projectRoot = path.join(tmpDir, "project");
322
+ const sourcePath = path.join(tmpDir, "source-pkg");
323
+
324
+ // files 필드 제거
325
+ await fs.promises.writeFile(
326
+ path.join(sourcePath, "package.json"),
327
+ JSON.stringify({ name: "@test/pkg" }),
328
+ );
329
+
330
+ const readdirSpy = vi.spyOn(fs.promises, "readdir");
331
+
332
+ watchResult = await watchReplaceDeps(projectRoot, { "@test/pkg": sourcePath });
333
+
334
+ // sourcePath에 대한 readdir 호출이 없어야 함 (files null이면 생략되어야 함)
335
+ const sourcePathPosix = path.resolve(sourcePath).replace(/\\/g, "/");
336
+ const calledWithSource = readdirSpy.mock.calls.some((args) => {
337
+ const arg = args[0];
338
+ if (typeof arg !== "string") return false;
339
+ return arg.replace(/\\/g, "/") === sourcePathPosix;
340
+ });
341
+
342
+ expect(calledWithSource).toBe(false);
343
+ }, 10_000);
147
344
  });
@@ -1,3 +0,0 @@
1
- export declare class FixtureComponent {
2
- }
3
- //# sourceMappingURL=test.fixture.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"test.fixture.d.ts","sourceRoot":"","sources":["../../../../../../../../tests/angular/fixtures/packages/basic-app/tests/test.fixture.ts"],"names":[],"mappings":"AAEA,qBAKa,gBAAgB;CAAG"}
@@ -1,9 +0,0 @@
1
- import { Component } from "@angular/core";
2
- @Component({
3
- selector: "app-fixture",
4
- standalone: true,
5
- template: `<span>fixture</span>`,
6
- })
7
- export class FixtureComponent {
8
- }
9
- //# sourceMappingURL=test.fixture.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"test.fixture.js","sourceRoot":"","sources":["../../../../../../../../tests/angular/fixtures/packages/basic-app/tests/test.fixture.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1C,CAAC,SAAS,CAAC;IACT,QAAQ,EAAE,aAAa;IACvB,UAAU,EAAE,IAAI;IAChB,QAAQ,EAAE,sBAAsB;CACjC,CAAC;AACF,MAAM,OAAO,gBAAgB;CAAG"}