@simplysm/sd-cli 14.0.16 → 14.0.18

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 (202) hide show
  1. package/README.md +2 -1
  2. package/dist/angular/client-transform-stylesheet.d.ts +2 -0
  3. package/dist/angular/client-transform-stylesheet.d.ts.map +1 -1
  4. package/dist/angular/client-transform-stylesheet.js +88 -2
  5. package/dist/angular/client-transform-stylesheet.js.map +1 -1
  6. package/dist/angular/vite-angular-plugin.d.ts +7 -0
  7. package/dist/angular/vite-angular-plugin.d.ts.map +1 -1
  8. package/dist/angular/vite-angular-plugin.js +78 -16
  9. package/dist/angular/vite-angular-plugin.js.map +1 -1
  10. package/dist/capacitor/capacitor.d.ts.map +1 -1
  11. package/dist/capacitor/capacitor.js +9 -13
  12. package/dist/capacitor/capacitor.js.map +1 -1
  13. package/dist/commands/check.d.ts.map +1 -1
  14. package/dist/commands/check.js +8 -9
  15. package/dist/commands/check.js.map +1 -1
  16. package/dist/commands/device.d.ts.map +1 -1
  17. package/dist/commands/device.js +33 -1
  18. package/dist/commands/device.js.map +1 -1
  19. package/dist/commands/lint.d.ts +0 -1
  20. package/dist/commands/lint.d.ts.map +1 -1
  21. package/dist/commands/lint.js +2 -3
  22. package/dist/commands/lint.js.map +1 -1
  23. package/dist/commands/publish.js +2 -2
  24. package/dist/commands/publish.js.map +1 -1
  25. package/dist/commands/typecheck.d.ts.map +1 -1
  26. package/dist/commands/typecheck.js +0 -1
  27. package/dist/commands/typecheck.js.map +1 -1
  28. package/dist/electron/electron.d.ts +3 -2
  29. package/dist/electron/electron.d.ts.map +1 -1
  30. package/dist/electron/electron.js +54 -31
  31. package/dist/electron/electron.js.map +1 -1
  32. package/dist/engines/BaseEngine.js +1 -1
  33. package/dist/engines/BaseEngine.js.map +1 -1
  34. package/dist/engines/NgtscEngine.d.ts.map +1 -1
  35. package/dist/engines/NgtscEngine.js +0 -1
  36. package/dist/engines/NgtscEngine.js.map +1 -1
  37. package/dist/engines/ServerEsbuildEngine.d.ts.map +1 -1
  38. package/dist/engines/ServerEsbuildEngine.js +0 -1
  39. package/dist/engines/ServerEsbuildEngine.js.map +1 -1
  40. package/dist/engines/TscEngine.d.ts.map +1 -1
  41. package/dist/engines/TscEngine.js +0 -1
  42. package/dist/engines/TscEngine.js.map +1 -1
  43. package/dist/engines/ViteEngine.d.ts.map +1 -1
  44. package/dist/engines/ViteEngine.js +8 -1
  45. package/dist/engines/ViteEngine.js.map +1 -1
  46. package/dist/engines/index.d.ts +0 -10
  47. package/dist/engines/index.d.ts.map +1 -1
  48. package/dist/engines/index.js +0 -5
  49. package/dist/engines/index.js.map +1 -1
  50. package/dist/engines/types.d.ts +0 -1
  51. package/dist/engines/types.d.ts.map +1 -1
  52. package/dist/infra/SignalHandler.d.ts +1 -6
  53. package/dist/infra/SignalHandler.d.ts.map +1 -1
  54. package/dist/infra/SignalHandler.js +4 -13
  55. package/dist/infra/SignalHandler.js.map +1 -1
  56. package/dist/orchestrators/BuildOrchestrator.d.ts.map +1 -1
  57. package/dist/orchestrators/BuildOrchestrator.js +7 -12
  58. package/dist/orchestrators/BuildOrchestrator.js.map +1 -1
  59. package/dist/orchestrators/DevWatchOrchestrator.d.ts.map +1 -1
  60. package/dist/orchestrators/DevWatchOrchestrator.js +17 -10
  61. package/dist/orchestrators/DevWatchOrchestrator.js.map +1 -1
  62. package/dist/sd-cli-entry.d.ts +0 -1
  63. package/dist/sd-cli-entry.d.ts.map +1 -1
  64. package/dist/sd-cli-entry.js +7 -8
  65. package/dist/sd-cli-entry.js.map +1 -1
  66. package/dist/sd-config.types.d.ts +11 -1
  67. package/dist/sd-config.types.d.ts.map +1 -1
  68. package/dist/utils/angular-compiler.d.ts.map +1 -1
  69. package/dist/utils/angular-compiler.js +20 -13
  70. package/dist/utils/angular-compiler.js.map +1 -1
  71. package/dist/utils/esbuild-config.d.ts +1 -1
  72. package/dist/utils/esbuild-config.d.ts.map +1 -1
  73. package/dist/utils/esbuild-config.js +1 -4
  74. package/dist/utils/esbuild-config.js.map +1 -1
  75. package/dist/utils/ngtsc-build-core.d.ts.map +1 -1
  76. package/dist/utils/ngtsc-build-core.js +3 -0
  77. package/dist/utils/ngtsc-build-core.js.map +1 -1
  78. package/dist/utils/tsc-build.d.ts +5 -0
  79. package/dist/utils/tsc-build.d.ts.map +1 -1
  80. package/dist/utils/tsc-build.js +2 -1
  81. package/dist/utils/tsc-build.js.map +1 -1
  82. package/dist/utils/vite-config.d.ts +1 -1
  83. package/dist/utils/vite-config.d.ts.map +1 -1
  84. package/dist/utils/vite-config.js +22 -53
  85. package/dist/utils/vite-config.js.map +1 -1
  86. package/dist/utils/vite-pwa-plugin.d.ts +9 -0
  87. package/dist/utils/vite-pwa-plugin.d.ts.map +1 -0
  88. package/dist/utils/vite-pwa-plugin.js +139 -0
  89. package/dist/utils/vite-pwa-plugin.js.map +1 -0
  90. package/dist/utils/worker-utils.d.ts +2 -5
  91. package/dist/utils/worker-utils.d.ts.map +1 -1
  92. package/dist/utils/worker-utils.js +5 -11
  93. package/dist/utils/worker-utils.js.map +1 -1
  94. package/dist/workers/client.worker.d.ts.map +1 -1
  95. package/dist/workers/client.worker.js +9 -3
  96. package/dist/workers/client.worker.js.map +1 -1
  97. package/dist/workers/library-build.worker.d.ts.map +1 -1
  98. package/dist/workers/library-build.worker.js +6 -2
  99. package/dist/workers/library-build.worker.js.map +1 -1
  100. package/dist/workers/ngtsc-build.worker.js +2 -2
  101. package/dist/workers/ngtsc-build.worker.js.map +1 -1
  102. package/dist/workers/server-build.worker.d.ts.map +1 -1
  103. package/dist/workers/server-build.worker.js +6 -2
  104. package/dist/workers/server-build.worker.js.map +1 -1
  105. package/dist/workers/server-runtime.worker.js +4 -4
  106. package/dist/workers/server-runtime.worker.js.map +1 -1
  107. package/docs/config.md +26 -0
  108. package/docs/pwa-configuration-types.md +1 -1
  109. package/package.json +8 -10
  110. package/src/angular/client-transform-stylesheet.ts +104 -2
  111. package/src/angular/vite-angular-plugin.ts +92 -31
  112. package/src/capacitor/capacitor.ts +10 -26
  113. package/src/commands/check.ts +8 -11
  114. package/src/commands/device.ts +38 -3
  115. package/src/commands/lint.ts +2 -3
  116. package/src/commands/publish.ts +2 -2
  117. package/src/commands/typecheck.ts +0 -1
  118. package/src/electron/electron.ts +62 -43
  119. package/src/engines/BaseEngine.ts +1 -1
  120. package/src/engines/NgtscEngine.ts +0 -1
  121. package/src/engines/ServerEsbuildEngine.ts +0 -1
  122. package/src/engines/TscEngine.ts +0 -1
  123. package/src/engines/ViteEngine.ts +7 -1
  124. package/src/engines/index.ts +0 -10
  125. package/src/engines/types.ts +0 -1
  126. package/src/infra/SignalHandler.ts +4 -14
  127. package/src/orchestrators/BuildOrchestrator.ts +7 -9
  128. package/src/orchestrators/DevWatchOrchestrator.ts +21 -9
  129. package/src/sd-cli-entry.ts +11 -16
  130. package/src/sd-config.types.ts +12 -1
  131. package/src/utils/angular-compiler.ts +21 -21
  132. package/src/utils/esbuild-config.ts +2 -5
  133. package/src/utils/ngtsc-build-core.ts +7 -0
  134. package/src/utils/tsc-build.ts +7 -0
  135. package/src/utils/vite-config.ts +23 -55
  136. package/src/utils/vite-pwa-plugin.ts +168 -0
  137. package/src/utils/worker-utils.ts +5 -11
  138. package/src/workers/client.worker.ts +11 -3
  139. package/src/workers/library-build.worker.ts +6 -2
  140. package/src/workers/ngtsc-build.worker.ts +2 -2
  141. package/src/workers/server-build.worker.ts +7 -2
  142. package/src/workers/server-runtime.worker.ts +4 -4
  143. package/tests/angular/client-transform-stylesheet.spec.ts +43 -0
  144. package/tests/angular/find-affected-by-scss.spec.ts +37 -0
  145. package/tests/angular/fixtures/basic-app/scss/_colors.scss +1 -0
  146. package/tests/angular/fixtures/basic-app/scss/_variables.scss +3 -0
  147. package/tests/angular/fixtures/basic-app/src/styled.component.ts +14 -0
  148. package/tests/angular/linker-disk-cache.spec.ts +158 -0
  149. package/tests/angular/scss-disk-cache.spec.ts +162 -0
  150. package/tests/angular/vite-angular-plugin-hmr-fallback.spec.ts +15 -15
  151. package/tests/angular/vite-angular-plugin-hmr.spec.ts +9 -9
  152. package/tests/angular/vite-angular-plugin-lint.spec.ts +4 -4
  153. package/tests/angular/vite-angular-plugin-scss-hmr.spec.ts +87 -0
  154. package/tests/angular/vite-angular-plugin.spec.ts +15 -15
  155. package/tests/capacitor/capacitor-icon.spec.ts +2 -4
  156. package/tests/capacitor/capacitor-init.spec.ts +2 -4
  157. package/tests/capacitor/capacitor-workspace.spec.ts +2 -4
  158. package/tests/commands/device.spec.ts +100 -0
  159. package/tests/electron/electron.spec.ts +24 -17
  160. package/tests/engines/ngtsc-engine.spec.ts +0 -3
  161. package/tests/engines/server-esbuild-engine.spec.ts +0 -3
  162. package/tests/engines/tsc-engine.spec.ts +1 -2
  163. package/tests/engines/vite-engine.spec.ts +0 -2
  164. package/tests/infra/signal-handler.spec.ts +1 -12
  165. package/tests/orchestrators/build-orchestrator.spec.ts +0 -6
  166. package/tests/orchestrators/dev-watch-orchestrator.spec.ts +24 -66
  167. package/tests/utils/angular-compiler.spec.ts +1396 -32
  168. package/tests/utils/esbuild-config.spec.ts +4 -7
  169. package/tests/utils/{ngtsc-build-core-angular-compiler.spec.ts → ngtsc-build-core.spec.ts} +142 -11
  170. package/tests/utils/tsc-build.spec.ts +4 -1
  171. package/tests/utils/vite-config.spec.ts +130 -261
  172. package/tests/utils/vite-pwa-plugin.acc.spec.ts +143 -0
  173. package/tests/utils/vite-pwa-plugin.spec.ts +350 -0
  174. package/tests/utils/worker-utils.spec.ts +8 -7
  175. package/tests/workers/client-worker.spec.ts +50 -1
  176. package/tests/workers/dev-port-file.verify.md +6 -0
  177. package/tests/workers/library-build-lint.spec.ts +1 -1
  178. package/tests/workers/library-build-worker.spec.ts +1 -1
  179. package/tests/workers/ngtsc-build-lint.spec.ts +1 -1
  180. package/tests/workers/server-build-lint.spec.ts +1 -1
  181. package/tests/workers/server-build-worker.spec.ts +1 -1
  182. package/tests/workers/server-runtime-worker.spec.ts +8 -1
  183. package/dist/infra/WorkerManager.d.ts +0 -40
  184. package/dist/infra/WorkerManager.d.ts.map +0 -1
  185. package/dist/infra/WorkerManager.js +0 -59
  186. package/dist/infra/WorkerManager.js.map +0 -1
  187. package/dist/utils/SdCliReporter.d.ts +0 -18
  188. package/dist/utils/SdCliReporter.d.ts.map +0 -1
  189. package/dist/utils/SdCliReporter.js +0 -144
  190. package/dist/utils/SdCliReporter.js.map +0 -1
  191. package/src/infra/WorkerManager.ts +0 -65
  192. package/src/utils/SdCliReporter.ts +0 -177
  193. package/tests/angular/scss-compiler-async.spec.ts +0 -54
  194. package/tests/commands/dev.spec.ts +0 -53
  195. package/tests/commands/watch.spec.ts +0 -53
  196. package/tests/infra/worker-manager.spec.ts +0 -63
  197. package/tests/utils/angular-compiler-emit.spec.ts +0 -570
  198. package/tests/utils/angular-compiler-init.spec.ts +0 -705
  199. package/tests/utils/angular-compiler-update.spec.ts +0 -293
  200. package/tests/utils/build-env.spec.ts +0 -33
  201. package/tests/utils/ngtsc-build-core-transform-stylesheet.spec.ts +0 -124
  202. package/tests/utils/ngtsc-scss-refactor.spec.ts +0 -47
@@ -1,22 +1,116 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import ts from "typescript";
3
+ import path from "path";
4
+ import fs from "fs";
5
+ import os from "os";
3
6
 
4
- // --- Unit Tests: Slice 1 — AngularSourceFileCache ---
7
+ // --- Mock Setup ---
8
+
9
+ const mockAnalyzeAsync = vi.fn().mockResolvedValue(undefined);
10
+ const mockGetDiagnosticsForFile = vi.fn().mockReturnValue([]);
11
+ const mockGetOptionDiagnostics = vi.fn().mockReturnValue([]);
12
+ const mockGetResourceDependencies = vi.fn().mockReturnValue([]);
13
+ const mockIgnoreForDiagnostics = new Set<ts.SourceFile>();
14
+ const mockIgnoreForEmit = new Set<ts.SourceFile>();
15
+ const mockSafeToSkipEmit = vi.fn().mockReturnValue(false);
16
+ const mockRecordSuccessfulEmit = vi.fn();
17
+ const mockPrepareEmit = vi.fn().mockReturnValue({
18
+ transformers: { before: [], after: [] },
19
+ });
20
+
21
+ const ngtscConstructorSpy = vi.fn();
22
+
23
+ function createRealTsProgram(
24
+ files: Record<string, string> = { "index.ts": "export const x = 1;" },
25
+ extraOptions: ts.CompilerOptions = {},
26
+ ): { program: ts.Program; dir: string; rootNames: string[] } {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "angular-compiler-test-"));
28
+ const rootNames: string[] = [];
29
+ for (const [name, content] of Object.entries(files)) {
30
+ const filePath = path.join(dir, name);
31
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
32
+ fs.writeFileSync(filePath, content, "utf-8");
33
+ rootNames.push(filePath);
34
+ }
35
+
36
+ const options: ts.CompilerOptions = {
37
+ target: ts.ScriptTarget.ESNext,
38
+ module: ts.ModuleKind.ESNext,
39
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
40
+ strict: false,
41
+ skipLibCheck: true,
42
+ types: [],
43
+ outDir: path.join(dir, "out"),
44
+ ...extraOptions,
45
+ };
46
+ const host = ts.createCompilerHost(options);
47
+ const program = ts.createProgram(rootNames, options, host);
48
+ return { program, dir, rootNames };
49
+ }
50
+
51
+ let realProgram: { program: ts.Program; dir: string; rootNames: string[] };
52
+
53
+ vi.mock("../../src/utils/angular-build", () => {
54
+ class NgtscProgram {
55
+ compiler = {
56
+ analyzeAsync: mockAnalyzeAsync,
57
+ getDiagnosticsForFile: mockGetDiagnosticsForFile,
58
+ getOptionDiagnostics: mockGetOptionDiagnostics,
59
+ getResourceDependencies: mockGetResourceDependencies,
60
+ ignoreForDiagnostics: mockIgnoreForDiagnostics,
61
+ ignoreForEmit: mockIgnoreForEmit,
62
+ incrementalCompilation: {
63
+ safeToSkipEmit: mockSafeToSkipEmit,
64
+ recordSuccessfulEmit: mockRecordSuccessfulEmit,
65
+ },
66
+ prepareEmit: mockPrepareEmit,
67
+ };
68
+ constructor(...args: unknown[]) {
69
+ ngtscConstructorSpy(...args);
70
+ }
71
+ getTsProgram() {
72
+ return realProgram.program;
73
+ }
74
+ }
75
+ return {
76
+ NgtscProgram,
77
+ OptimizeFor: { WholeProgram: 0, SingleFile: 1 },
78
+ };
79
+ });
80
+
81
+ const { AngularCompiler, AngularSourceFileCache, augmentHostWithCaching } = await import(
82
+ "../../src/utils/angular-compiler"
83
+ );
84
+
85
+ // --- Common beforeEach ---
86
+
87
+ beforeEach(() => {
88
+ vi.clearAllMocks();
89
+ mockIgnoreForDiagnostics.clear();
90
+ mockIgnoreForEmit.clear();
91
+ mockGetResourceDependencies.mockReturnValue([]);
92
+ mockGetDiagnosticsForFile.mockReturnValue([]);
93
+ mockSafeToSkipEmit.mockReturnValue(false);
94
+ mockRecordSuccessfulEmit.mockReset();
95
+ mockPrepareEmit.mockReturnValue({
96
+ transformers: { before: [], after: [] },
97
+ });
98
+
99
+ realProgram = createRealTsProgram();
100
+ });
101
+
102
+ // =============================================================================
103
+ // AngularSourceFileCache
104
+ // =============================================================================
5
105
 
6
106
  describe("AngularSourceFileCache — Unit Tests", () => {
7
- it("modifiedFiles는 빈 Set으로 초기화된다", async () => {
8
- const { AngularSourceFileCache } = await import(
9
- "../../src/utils/angular-compiler"
10
- );
107
+ it("modifiedFiles는 빈 Set으로 초기화된다", () => {
11
108
  const cache = new AngularSourceFileCache();
12
109
  expect(cache.modifiedFiles).toBeInstanceOf(Set);
13
110
  expect(cache.modifiedFiles.size).toBe(0);
14
111
  });
15
112
 
16
- it("invalidate는 여러 파일을 한번에 처리한다", async () => {
17
- const { AngularSourceFileCache } = await import(
18
- "../../src/utils/angular-compiler"
19
- );
113
+ it("invalidate는 여러 파일을 한번에 처리한다", () => {
20
114
  const cache = new AngularSourceFileCache();
21
115
  const sf1 = ts.createSourceFile("a.ts", "", ts.ScriptTarget.ESNext);
22
116
  const sf2 = ts.createSourceFile("b.ts", "", ts.ScriptTarget.ESNext);
@@ -31,11 +125,7 @@ describe("AngularSourceFileCache — Unit Tests", () => {
31
125
  expect(cache.modifiedFiles.has("src/b.ts")).toBe(true);
32
126
  });
33
127
 
34
- it("augmentHostWithCaching — 캐시 미스 시 원본 호출 후 캐시 저장", async () => {
35
- const { AngularSourceFileCache, augmentHostWithCaching } = await import(
36
- "../../src/utils/angular-compiler"
37
- );
38
-
128
+ it("augmentHostWithCaching — 캐시 미스 시 원본 호출 후 캐시 저장", () => {
39
129
  const cache = new AngularSourceFileCache();
40
130
  const fakeSourceFile = ts.createSourceFile(
41
131
  "miss.ts",
@@ -56,11 +146,7 @@ describe("AngularSourceFileCache — Unit Tests", () => {
56
146
  expect(cache.get("miss.ts")).toBe(fakeSourceFile);
57
147
  });
58
148
 
59
- it("augmentHostWithCaching — shouldCreateNewSourceFile=true이면 캐시 무시", async () => {
60
- const { AngularSourceFileCache, augmentHostWithCaching } = await import(
61
- "../../src/utils/angular-compiler"
62
- );
63
-
149
+ it("augmentHostWithCaching — shouldCreateNewSourceFile=true이면 캐시 무시", () => {
64
150
  const cache = new AngularSourceFileCache();
65
151
  const cachedFile = ts.createSourceFile("cached.ts", "old", ts.ScriptTarget.ESNext);
66
152
  const freshFile = ts.createSourceFile("cached.ts", "new", ts.ScriptTarget.ESNext);
@@ -79,16 +165,10 @@ describe("AngularSourceFileCache — Unit Tests", () => {
79
165
  });
80
166
  });
81
167
 
82
- // --- Acceptance Tests: Slice 1 — AngularSourceFileCache ---
83
-
84
168
  describe("AngularSourceFileCache", () => {
85
169
  // Scenario: SourceFileCache 통합
86
170
  describe("augmentHostWithCaching으로 호스트에 캐시를 통합한다", () => {
87
- it("캐시에 있는 파일은 재파싱 없이 반환되고, 미스 시 원본 호출 후 캐시 저장", async () => {
88
- const { AngularSourceFileCache, augmentHostWithCaching } = await import(
89
- "../../src/utils/angular-compiler"
90
- );
91
-
171
+ it("캐시에 있는 파일은 재파싱 없이 반환되고, 미스 시 원본 호출 후 캐시 저장", () => {
92
172
  const cache = new AngularSourceFileCache();
93
173
  const compilerOptions: ts.CompilerOptions = {
94
174
  target: ts.ScriptTarget.ESNext,
@@ -125,11 +205,7 @@ describe("AngularSourceFileCache", () => {
125
205
 
126
206
  // Scenario: SourceFileCache invalidate
127
207
  describe("invalidate로 파일을 캐시에서 삭제하고 modifiedFiles에 추가한다", () => {
128
- it("invalidate 후 캐시에서 삭제되고 modifiedFiles에 추가된다", async () => {
129
- const { AngularSourceFileCache } = await import(
130
- "../../src/utils/angular-compiler"
131
- );
132
-
208
+ it("invalidate 후 캐시에서 삭제되고 modifiedFiles에 추가된다", () => {
133
209
  const cache = new AngularSourceFileCache();
134
210
  const fakeSourceFile = ts.createSourceFile(
135
211
  "component.ts",
@@ -149,3 +225,1291 @@ describe("AngularSourceFileCache", () => {
149
225
  });
150
226
  });
151
227
  });
228
+
229
+ // =============================================================================
230
+ // AngularCompiler — initialize
231
+ // =============================================================================
232
+
233
+ describe("AngularCompiler — Unit Tests", () => {
234
+ it("initialize() 전 getTsProgram()은 에러를 던진다", () => {
235
+ const compiler = new AngularCompiler({
236
+ rootNames: ["src/main.ts"],
237
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
238
+ });
239
+
240
+ expect(() => compiler.getTsProgram()).toThrow("initialize()");
241
+ });
242
+
243
+ it("initialize() 전 compiler getter는 에러를 던진다", () => {
244
+ const compiler = new AngularCompiler({
245
+ rootNames: ["src/main.ts"],
246
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
247
+ });
248
+
249
+ expect(() => compiler.compiler).toThrow("initialize()");
250
+ });
251
+
252
+ it("initialize() 전 ngtscProgram은 undefined이다", () => {
253
+ const compiler = new AngularCompiler({
254
+ rootNames: ["src/main.ts"],
255
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
256
+ });
257
+
258
+ expect(compiler.ngtscProgram).toBeUndefined();
259
+ });
260
+
261
+ it("host.readResource가 파일 내용을 반환한다", async () => {
262
+ const compiler = new AngularCompiler({
263
+ rootNames: realProgram.rootNames,
264
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
265
+ });
266
+
267
+ await compiler.initialize();
268
+
269
+ const hostArg = ngtscConstructorSpy.mock.calls[0][2] as Record<string, Function>;
270
+ // readResource는 host.readFile을 래핑한다
271
+ expect(typeof hostArg["readResource"]).toBe("function");
272
+ const content = (hostArg["readResource"])(realProgram.rootNames[0]);
273
+ expect(content).toContain("export const x = 1");
274
+ });
275
+
276
+ it("host.transformResource — style이 아닌 type은 null을 반환한다", async () => {
277
+ const transformStylesheet = vi.fn().mockResolvedValue("css");
278
+ const compiler = new AngularCompiler({
279
+ rootNames: realProgram.rootNames,
280
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
281
+ transformStylesheet,
282
+ });
283
+
284
+ await compiler.initialize();
285
+
286
+ const hostArg = ngtscConstructorSpy.mock.calls[0][2] as Record<string, Function>;
287
+ const result = await (hostArg["transformResource"])("data", {
288
+ type: "template",
289
+ containingFile: "/app/comp.ts",
290
+ resourceFile: null,
291
+ });
292
+ expect(result).toBeNull();
293
+ expect(transformStylesheet).not.toHaveBeenCalled();
294
+ });
295
+
296
+ it("host.transformResource — 빈 스타일은 빈 content를 반환한다", async () => {
297
+ const transformStylesheet = vi.fn().mockResolvedValue("css");
298
+ const compiler = new AngularCompiler({
299
+ rootNames: realProgram.rootNames,
300
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
301
+ transformStylesheet,
302
+ });
303
+
304
+ await compiler.initialize();
305
+
306
+ const hostArg = ngtscConstructorSpy.mock.calls[0][2] as Record<string, Function>;
307
+ const result = await (hostArg["transformResource"])(" ", {
308
+ type: "style",
309
+ containingFile: "/app/comp.ts",
310
+ resourceFile: null,
311
+ });
312
+ expect(result).toEqual({ content: "" });
313
+ expect(transformStylesheet).not.toHaveBeenCalled();
314
+ });
315
+
316
+ it("host.getModifiedResourceFiles는 sourceFileCache.modifiedFiles를 반환한다", async () => {
317
+ const cache = new AngularSourceFileCache();
318
+ cache.modifiedFiles.add("src/styles.scss");
319
+
320
+ const compiler = new AngularCompiler({
321
+ rootNames: realProgram.rootNames,
322
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
323
+ sourceFileCache: cache,
324
+ });
325
+
326
+ await compiler.initialize();
327
+
328
+ const hostArg = ngtscConstructorSpy.mock.calls[0][2] as Record<string, Function>;
329
+ expect(typeof hostArg["getModifiedResourceFiles"]).toBe("function");
330
+ const modifiedFiles = (hostArg["getModifiedResourceFiles"])();
331
+ expect(modifiedFiles.has("src/styles.scss")).toBe(true);
332
+ });
333
+
334
+ it("resourceNameToFileName — 존재하지 않는 파일은 null 반환", async () => {
335
+ const compiler = new AngularCompiler({
336
+ rootNames: realProgram.rootNames,
337
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
338
+ });
339
+
340
+ await compiler.initialize();
341
+
342
+ const hostArg = ngtscConstructorSpy.mock.calls[0][2] as Record<string, Function>;
343
+ const result = (hostArg["resourceNameToFileName"])(
344
+ "./nonexistent.html",
345
+ realProgram.rootNames[0],
346
+ );
347
+ expect(result).toBeNull();
348
+ });
349
+
350
+ it("resourceNameToFileName — 존재하는 템플릿 파일은 resolvedPath 반환", async () => {
351
+ const htmlPath = path.join(realProgram.dir, "comp.html");
352
+ fs.writeFileSync(htmlPath, "<div></div>", "utf-8");
353
+
354
+ const compiler = new AngularCompiler({
355
+ rootNames: realProgram.rootNames,
356
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
357
+ });
358
+
359
+ await compiler.initialize();
360
+
361
+ const hostArg = ngtscConstructorSpy.mock.calls[0][2] as Record<string, Function>;
362
+ const result = (hostArg["resourceNameToFileName"])(
363
+ "./comp.html",
364
+ realProgram.rootNames[0],
365
+ );
366
+ expect(result).toBe(htmlPath);
367
+ });
368
+
369
+ it("resourceNameToFileName — externalStylesheets와 stylesheet일 때 SHA256 ID 반환", async () => {
370
+ const scssPath = path.join(realProgram.dir, "comp.scss");
371
+ fs.writeFileSync(scssPath, ".x{}", "utf-8");
372
+
373
+ const externalStylesheets = new Map<string, string>();
374
+ const compiler = new AngularCompiler({
375
+ rootNames: realProgram.rootNames,
376
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
377
+ externalStylesheets,
378
+ });
379
+
380
+ await compiler.initialize();
381
+
382
+ const hostArg = ngtscConstructorSpy.mock.calls[0][2] as Record<string, Function>;
383
+ const result = (hostArg["resourceNameToFileName"])(
384
+ "./comp.scss",
385
+ realProgram.rootNames[0],
386
+ );
387
+ // SHA256 ID + .css가 반환되어야 한다
388
+ expect(result).toMatch(/^[a-f0-9]{64}\.css$/);
389
+ // externalStylesheets에 매핑이 저장되어야 한다
390
+ expect(externalStylesheets.has(scssPath)).toBe(true);
391
+ });
392
+
393
+ it("angularCompilerOptions가 compilerOptions에 병합된다", async () => {
394
+ const compiler = new AngularCompiler({
395
+ rootNames: realProgram.rootNames,
396
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
397
+ angularCompilerOptions: { strictTemplates: true },
398
+ });
399
+
400
+ await compiler.initialize();
401
+
402
+ const passedOptions = ngtscConstructorSpy.mock.calls[0][1] as Record<string, unknown>;
403
+ expect(passedOptions["strictTemplates"]).toBe(true);
404
+ });
405
+
406
+ it("collectDiagnostics() — initialize() 전 호출 시 에러", () => {
407
+ const compiler = new AngularCompiler({
408
+ rootNames: ["src/main.ts"],
409
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
410
+ });
411
+
412
+ expect(() => [...compiler.collectDiagnostics()]).toThrow("initialize()");
413
+ });
414
+ });
415
+
416
+ describe("AngularCompiler — 초기화", () => {
417
+ // Scenario: 최초 초기화
418
+ it("initialize()로 호스트, NgtscProgram, BuilderProgram이 생성되고 analyzeAsync가 호출된다", async () => {
419
+ const compiler = new AngularCompiler({
420
+ rootNames: ["src/main.ts"],
421
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
422
+ });
423
+
424
+ const result = await compiler.initialize();
425
+
426
+ // NgtscProgram이 생성되었다
427
+ expect(ngtscConstructorSpy).toHaveBeenCalledTimes(1);
428
+ // oldProgram은 undefined (최초)
429
+ expect(ngtscConstructorSpy.mock.calls[0][3]).toBeUndefined();
430
+ // analyzeAsync가 호출되었다
431
+ expect(mockAnalyzeAsync).toHaveBeenCalledTimes(1);
432
+ // affectedFiles가 반환된다
433
+ expect(result.affectedFiles).toBeInstanceOf(Set);
434
+ });
435
+
436
+ // Scenario: 재초기화 (incremental)
437
+ it("두번째 initialize()에서 이전 NgtscProgram이 oldProgram으로 전달된다", async () => {
438
+ const compiler = new AngularCompiler({
439
+ rootNames: ["src/main.ts"],
440
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
441
+ });
442
+
443
+ await compiler.initialize();
444
+ const firstProgram = compiler.ngtscProgram;
445
+
446
+ await compiler.initialize();
447
+
448
+ // 두번째 호출에서 oldProgram으로 첫번째 NgtscProgram이 전달됨
449
+ expect(ngtscConstructorSpy).toHaveBeenCalledTimes(2);
450
+ expect(ngtscConstructorSpy.mock.calls[1][3]).toBe(firstProgram);
451
+ });
452
+
453
+ // Scenario: transformStylesheet 콜백 주입 (library)
454
+ it("transformStylesheet 콜백이 host.transformResource를 통해 호출된다", async () => {
455
+ const transformStylesheet = vi.fn().mockResolvedValue("body { color: red; }");
456
+
457
+ const compiler = new AngularCompiler({
458
+ rootNames: ["src/main.ts"],
459
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
460
+ transformStylesheet,
461
+ });
462
+
463
+ await compiler.initialize();
464
+
465
+ // NgtscProgram 생성 시 전달된 host를 가져온다
466
+ const hostArg = ngtscConstructorSpy.mock.calls[0][2] as Record<string, unknown>;
467
+ expect(typeof hostArg["transformResource"]).toBe("function");
468
+
469
+ // transformResource를 호출하면 transformStylesheet가 호출된다
470
+ const result = await (hostArg["transformResource"] as Function)(
471
+ ".button { color: blue; }",
472
+ { type: "style", containingFile: "/app/comp.ts", resourceFile: "/app/comp.scss" },
473
+ );
474
+ expect(transformStylesheet).toHaveBeenCalledWith(
475
+ ".button { color: blue; }",
476
+ "/app/comp.ts",
477
+ "/app/comp.scss",
478
+ );
479
+ expect(result).toEqual({ content: "body { color: red; }" });
480
+ });
481
+
482
+ // Scenario: compilerOptionsTransformer 적용
483
+ it("compilerOptionsTransformer가 NgtscProgram 생성에 사용된다", async () => {
484
+ const transformer = vi.fn((opts: ts.CompilerOptions) => ({
485
+ ...opts,
486
+ noEmit: false,
487
+ declaration: false,
488
+ }));
489
+
490
+ const compiler = new AngularCompiler({
491
+ rootNames: ["src/main.ts"],
492
+ compilerOptions: { target: ts.ScriptTarget.ESNext, noEmit: true },
493
+ compilerOptionsTransformer: transformer,
494
+ });
495
+
496
+ await compiler.initialize();
497
+
498
+ expect(transformer).toHaveBeenCalledTimes(1);
499
+ // NgtscProgram에 전달된 options에 변환이 적용되었는지 확인
500
+ const passedOptions = ngtscConstructorSpy.mock.calls[0][1] as ts.CompilerOptions;
501
+ expect(passedOptions.noEmit).toBe(false);
502
+ expect(passedOptions.declaration).toBe(false);
503
+ });
504
+
505
+ // Scenario: resourceNameToFileName으로 외부 스타일시트 경로 해석
506
+ it("host.resourceNameToFileName이 절대 경로를 반환한다", async () => {
507
+ const compiler = new AngularCompiler({
508
+ rootNames: ["src/main.ts"],
509
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
510
+ });
511
+
512
+ await compiler.initialize();
513
+
514
+ const hostArg = ngtscConstructorSpy.mock.calls[0][2] as Record<string, Function>;
515
+ expect(typeof hostArg["resourceNameToFileName"]).toBe("function");
516
+ });
517
+
518
+ // Scenario: declaration true 설정 (library)
519
+ it("compilerOptionsTransformer로 declaration: true 설정", async () => {
520
+ const compiler = new AngularCompiler({
521
+ rootNames: ["src/main.ts"],
522
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
523
+ compilerOptionsTransformer: (opts) => ({
524
+ ...opts,
525
+ declaration: true,
526
+ declarationMap: true,
527
+ }),
528
+ });
529
+
530
+ await compiler.initialize();
531
+
532
+ const passedOptions = ngtscConstructorSpy.mock.calls[0][1] as ts.CompilerOptions;
533
+ expect(passedOptions.declaration).toBe(true);
534
+ expect(passedOptions.declarationMap).toBe(true);
535
+ });
536
+
537
+ // Scenario: declaration false 설정 (client)
538
+ it("compilerOptionsTransformer로 declaration: false 설정", async () => {
539
+ const compiler = new AngularCompiler({
540
+ rootNames: ["src/main.ts"],
541
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
542
+ compilerOptionsTransformer: (opts) => ({
543
+ ...opts,
544
+ declaration: false,
545
+ }),
546
+ });
547
+
548
+ await compiler.initialize();
549
+
550
+ const passedOptions = ngtscConstructorSpy.mock.calls[0][1] as ts.CompilerOptions;
551
+ expect(passedOptions.declaration).toBe(false);
552
+ });
553
+
554
+ // Scenario: 초기화 후 ts.Program 획득
555
+ it("getTsProgram()으로 ts.Program을 반환한다", async () => {
556
+ const compiler = new AngularCompiler({
557
+ rootNames: ["src/main.ts"],
558
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
559
+ });
560
+
561
+ await compiler.initialize();
562
+
563
+ const program = compiler.getTsProgram();
564
+ expect(program).toBe(realProgram.program);
565
+ });
566
+
567
+ // Scenario: ESLint에 Program 주입 가능
568
+ it("getTsProgram()의 결과가 ESLint parserOptions.programs에 주입 가능한 형태이다", async () => {
569
+ const compiler = new AngularCompiler({
570
+ rootNames: ["src/main.ts"],
571
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
572
+ });
573
+
574
+ await compiler.initialize();
575
+
576
+ const program = compiler.getTsProgram();
577
+ // ESLint parserOptions.programs는 ts.Program 배열을 받는다
578
+ const parserOptions = { programs: [program], project: null };
579
+ expect(parserOptions.programs).toHaveLength(1);
580
+ expect(parserOptions.programs[0]).toBe(realProgram.program);
581
+ });
582
+
583
+ // Scenario: SourceFileCache 통합
584
+ it("sourceFileCache 제공 시 augmentHostWithCaching이 적용된다", async () => {
585
+ const cache = new AngularSourceFileCache();
586
+
587
+ const compiler = new AngularCompiler({
588
+ rootNames: ["src/main.ts"],
589
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
590
+ sourceFileCache: cache,
591
+ });
592
+
593
+ await compiler.initialize();
594
+
595
+ // host가 캐시를 사용하도록 래핑되었는지 간접 확인:
596
+ // NgtscProgram이 성공적으로 생성되었으면 호스트가 정상적으로 구성된 것
597
+ expect(ngtscConstructorSpy).toHaveBeenCalledTimes(1);
598
+ });
599
+
600
+ // Scenario: packageJsonCache — node_modules 변경 시 clear
601
+ it("modifiedFiles에 node_modules 파일이 있으면 packageJsonCache가 clear된다", async () => {
602
+ const cache = new AngularSourceFileCache();
603
+
604
+ const compiler = new AngularCompiler({
605
+ rootNames: ["src/main.ts"],
606
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
607
+ sourceFileCache: cache,
608
+ });
609
+
610
+ // 첫 초기화 — packageJsonCache 생성
611
+ await compiler.initialize();
612
+
613
+ // node_modules 파일 변경
614
+ cache.modifiedFiles.add("node_modules/some-pkg/index.js");
615
+
616
+ // 재초기화 — packageJsonCache.clear() 호출되어야 함
617
+ // 에러 없이 완료되면 성공
618
+ await compiler.initialize();
619
+
620
+ expect(ngtscConstructorSpy).toHaveBeenCalledTimes(2);
621
+ });
622
+
623
+ // Scenario: packageJsonCache — node_modules 미변경 시 재사용
624
+ it("modifiedFiles에 node_modules 파일이 없으면 packageJsonCache가 재사용된다", async () => {
625
+ const cache = new AngularSourceFileCache();
626
+
627
+ const compiler = new AngularCompiler({
628
+ rootNames: ["src/main.ts"],
629
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
630
+ sourceFileCache: cache,
631
+ });
632
+
633
+ // 첫 초기화
634
+ await compiler.initialize();
635
+
636
+ // 일반 파일만 변경
637
+ cache.modifiedFiles.add("src/app/component.ts");
638
+
639
+ // 재초기화
640
+ await compiler.initialize();
641
+
642
+ // 에러 없이 완료
643
+ expect(ngtscConstructorSpy).toHaveBeenCalledTimes(2);
644
+ });
645
+ });
646
+
647
+ // =============================================================================
648
+ // AngularCompiler — affected 파일
649
+ // =============================================================================
650
+
651
+ describe("AngularCompiler — affected 파일", () => {
652
+ // Scenario: 소스 파일 변경 시 affected 파일 수집
653
+ it("initialize()가 BuilderProgram 기반으로 affected 파일을 반환한다", async () => {
654
+ const compiler = new AngularCompiler({
655
+ rootNames: realProgram.rootNames,
656
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
657
+ });
658
+
659
+ const result = await compiler.initialize();
660
+ // affected 파일이 Set으로 반환된다
661
+ expect(result.affectedFiles).toBeInstanceOf(Set);
662
+ });
663
+
664
+ // Scenario: 리소스 변경 시 해당 .ts 파일이 affected에 추가
665
+ it("리소스 변경 시 해당 .ts 파일이 affected에 추가된다", async () => {
666
+ // realProgram has index.ts — mock its resource dependencies
667
+ const sourceFiles = realProgram.program.getSourceFiles();
668
+ const indexFile = sourceFiles.find((sf) => sf.fileName.includes("index.ts"));
669
+ const scssPath = path.join(realProgram.dir, "component.scss");
670
+ fs.writeFileSync(scssPath, ".x { }", "utf-8");
671
+
672
+ mockGetResourceDependencies.mockImplementation((sf: ts.SourceFile) => {
673
+ if (sf === indexFile) {
674
+ return [scssPath];
675
+ }
676
+ return [];
677
+ });
678
+
679
+ const cache = new AngularSourceFileCache();
680
+ cache.modifiedFiles.add(scssPath);
681
+
682
+ const compiler = new AngularCompiler({
683
+ rootNames: realProgram.rootNames,
684
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
685
+ sourceFileCache: cache,
686
+ });
687
+
688
+ const result = await compiler.initialize();
689
+ // index.ts가 affected에 추가되어야 한다 (리소스 변경으로 인해)
690
+ const affectedFileNames = [...result.affectedFiles].map((sf) => sf.fileName);
691
+ expect(affectedFileNames.some((f) => f.includes("index.ts"))).toBe(true);
692
+ });
693
+ });
694
+
695
+ // =============================================================================
696
+ // AngularCompiler — collectDiagnostics
697
+ // =============================================================================
698
+
699
+ describe("AngularCompiler — collectDiagnostics", () => {
700
+ // Scenario: 4종 진단 수집
701
+ it("collectDiagnostics가 Option, Syntactic, Semantic, Angular 진단을 수집한다", async () => {
702
+ const compiler = new AngularCompiler({
703
+ rootNames: realProgram.rootNames,
704
+ compilerOptions: {
705
+ target: ts.ScriptTarget.ESNext,
706
+ module: ts.ModuleKind.ESNext,
707
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
708
+ skipLibCheck: true,
709
+ types: [],
710
+ },
711
+ });
712
+
713
+ await compiler.initialize();
714
+
715
+ // collectDiagnostics가 에러 없이 실행된다
716
+ const diagnostics = [...compiler.collectDiagnostics()];
717
+ expect(Array.isArray(diagnostics)).toBe(true);
718
+ });
719
+
720
+ // Scenario: affected 파일의 Angular 진단은 재계산 후 캐시
721
+ it("affected 파일의 Angular 진단은 getDiagnosticsForFile로 재계산된다", async () => {
722
+ const indexFile = realProgram.program.getSourceFiles()
723
+ .find((sf) => sf.fileName.includes("index.ts"));
724
+ const scssPath = path.join(realProgram.dir, "component.scss");
725
+ fs.writeFileSync(scssPath, ".x { }", "utf-8");
726
+
727
+ const mockDiag = {
728
+ category: ts.DiagnosticCategory.Error,
729
+ code: 1000,
730
+ messageText: "test error",
731
+ file: indexFile,
732
+ start: 0,
733
+ length: 5,
734
+ } as ts.Diagnostic;
735
+ mockGetDiagnosticsForFile.mockReturnValue([mockDiag]);
736
+ mockGetResourceDependencies.mockImplementation((sf: ts.SourceFile) => {
737
+ if (sf.fileName.includes("index.ts")) {
738
+ return [scssPath];
739
+ }
740
+ return [];
741
+ });
742
+
743
+ const cache = new AngularSourceFileCache();
744
+ cache.modifiedFiles.add(scssPath);
745
+
746
+ const compiler = new AngularCompiler({
747
+ rootNames: realProgram.rootNames,
748
+ compilerOptions: {
749
+ target: ts.ScriptTarget.ESNext,
750
+ module: ts.ModuleKind.ESNext,
751
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
752
+ skipLibCheck: true,
753
+ types: [],
754
+ },
755
+ sourceFileCache: cache,
756
+ });
757
+
758
+ await compiler.initialize();
759
+
760
+ const diagnostics = [...compiler.collectDiagnostics()];
761
+ // getDiagnosticsForFile이 호출되었다
762
+ expect(mockGetDiagnosticsForFile).toHaveBeenCalled();
763
+ // 진단이 수집되었다
764
+ expect(diagnostics.some((d) => d.code === 1000)).toBe(true);
765
+ });
766
+
767
+ // Scenario: non-affected 파일의 Angular 진단은 캐시 반환
768
+ it("non-affected 파일의 Angular 진단은 캐시에서 반환된다 (첫 빌드에서 모든 파일이 affected)", async () => {
769
+ const mockDiag = {
770
+ category: ts.DiagnosticCategory.Warning,
771
+ code: 2000,
772
+ messageText: "cached warning",
773
+ } as ts.Diagnostic;
774
+ mockGetDiagnosticsForFile.mockReturnValue([mockDiag]);
775
+
776
+ const compiler = new AngularCompiler({
777
+ rootNames: realProgram.rootNames,
778
+ compilerOptions: {
779
+ target: ts.ScriptTarget.ESNext,
780
+ module: ts.ModuleKind.ESNext,
781
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
782
+ skipLibCheck: true,
783
+ types: [],
784
+ },
785
+ });
786
+
787
+ await compiler.initialize();
788
+
789
+ // 첫 collectDiagnostics — 초기 빌드에서 모든 파일이 affected → getDiagnosticsForFile 호출됨
790
+ const diags1 = [...compiler.collectDiagnostics()];
791
+ const callCount1 = mockGetDiagnosticsForFile.mock.calls.length;
792
+ expect(callCount1).toBeGreaterThan(0);
793
+ expect(diags1.some((d) => d.code === 2000)).toBe(true);
794
+
795
+ // 두번째 initialize + collectDiagnostics — 변경 없으면 캐시 사용
796
+ mockGetDiagnosticsForFile.mockClear();
797
+ await compiler.initialize();
798
+ const diags2 = [...compiler.collectDiagnostics()];
799
+ // 두번째에서는 변경이 없으므로 affected가 비어 있어야 하고, 캐시에서 반환
800
+ // (하지만 BuilderProgram은 재생성되므로 affected가 다시 계산될 수 있다)
801
+ // 최소한 에러 없이 동작해야 한다
802
+ expect(Array.isArray(diags2)).toBe(true);
803
+ });
804
+
805
+ // Scenario: 리소스 변경 시 해당 .ts 파일의 캐시 무효화
806
+ it("리소스 변경 시 해당 .ts 파일의 diagnosticCache가 무효화된다", async () => {
807
+ const scssPath = path.join(realProgram.dir, "styles.scss");
808
+ fs.writeFileSync(scssPath, ".y { }", "utf-8");
809
+
810
+ const mockDiag = {
811
+ category: ts.DiagnosticCategory.Error,
812
+ code: 3000,
813
+ messageText: "resource error",
814
+ } as ts.Diagnostic;
815
+ mockGetDiagnosticsForFile.mockReturnValue([mockDiag]);
816
+ mockGetResourceDependencies.mockImplementation((sf: ts.SourceFile) => {
817
+ if (sf.fileName.includes("index.ts")) {
818
+ return [scssPath];
819
+ }
820
+ return [];
821
+ });
822
+
823
+ const cache = new AngularSourceFileCache();
824
+
825
+ const compiler = new AngularCompiler({
826
+ rootNames: realProgram.rootNames,
827
+ compilerOptions: {
828
+ target: ts.ScriptTarget.ESNext,
829
+ module: ts.ModuleKind.ESNext,
830
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
831
+ skipLibCheck: true,
832
+ types: [],
833
+ },
834
+ sourceFileCache: cache,
835
+ });
836
+
837
+ // 첫 빌드
838
+ await compiler.initialize();
839
+ void [...compiler.collectDiagnostics()];
840
+ expect(mockGetDiagnosticsForFile).toHaveBeenCalled();
841
+
842
+ // 리소스 변경 후 재빌드
843
+ mockGetDiagnosticsForFile.mockClear();
844
+ cache.modifiedFiles.add(scssPath);
845
+
846
+ await compiler.initialize();
847
+ void [...compiler.collectDiagnostics()];
848
+
849
+ // 리소스 변경으로 캐시 무효화 → getDiagnosticsForFile이 다시 호출되어야 한다
850
+ expect(mockGetDiagnosticsForFile).toHaveBeenCalled();
851
+ });
852
+ });
853
+
854
+ // =============================================================================
855
+ // AngularCompiler — emitAffectedFiles
856
+ // =============================================================================
857
+
858
+ describe("AngularCompiler.emitAffectedFiles — Unit Tests", () => {
859
+ it("initialize() 전 호출 시 에러를 던진다", () => {
860
+ const compiler = new AngularCompiler({
861
+ rootNames: ["src/main.ts"],
862
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
863
+ });
864
+
865
+ expect(() => [...compiler.emitAffectedFiles()]).toThrow("initialize()");
866
+ });
867
+
868
+ it("noEmit=true이면 빈 Iterator를 반환한다", async () => {
869
+ realProgram = createRealTsProgram(
870
+ { "a.ts": "export const a = 1;" },
871
+ { noEmit: true },
872
+ );
873
+
874
+ const compiler = new AngularCompiler({
875
+ rootNames: realProgram.rootNames,
876
+ compilerOptions: {
877
+ target: ts.ScriptTarget.ESNext,
878
+ module: ts.ModuleKind.ESNext,
879
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
880
+ skipLibCheck: true,
881
+ types: [],
882
+ noEmit: true,
883
+ },
884
+ });
885
+
886
+ await compiler.initialize();
887
+ const results = [...compiler.emitAffectedFiles()];
888
+ expect(results).toEqual([]);
889
+ });
890
+
891
+ it("ignoreForEmit에 포함된 파일은 emit되지 않는다", async () => {
892
+ realProgram = createRealTsProgram({ "a.ts": "export const a = 1;" });
893
+
894
+ const compiler = new AngularCompiler({
895
+ rootNames: realProgram.rootNames,
896
+ compilerOptions: {
897
+ target: ts.ScriptTarget.ESNext,
898
+ module: ts.ModuleKind.ESNext,
899
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
900
+ skipLibCheck: true,
901
+ types: [],
902
+ },
903
+ });
904
+
905
+ await compiler.initialize();
906
+
907
+ // ignoreForEmit에 모든 소스 파일 추가
908
+ const tsProgram = compiler.getTsProgram();
909
+ for (const sf of tsProgram.getSourceFiles()) {
910
+ mockIgnoreForEmit.add(sf);
911
+ }
912
+
913
+ const results = [...compiler.emitAffectedFiles()];
914
+ expect(results).toEqual([]);
915
+ });
916
+
917
+ it("declaration file은 2차 루프에서 skip된다", async () => {
918
+ realProgram = createRealTsProgram({ "a.ts": "export const a = 1;" });
919
+
920
+ const compiler = new AngularCompiler({
921
+ rootNames: realProgram.rootNames,
922
+ compilerOptions: {
923
+ target: ts.ScriptTarget.ESNext,
924
+ module: ts.ModuleKind.ESNext,
925
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
926
+ skipLibCheck: true,
927
+ types: [],
928
+ },
929
+ });
930
+
931
+ await compiler.initialize();
932
+ // 이미 emit된 파일이 없는 상태에서도 .d.ts 파일은 2차 루프에서 skip
933
+ const results = [...compiler.emitAffectedFiles()];
934
+ // .d.ts 입력 파일이 결과에 포함되지 않아야 한다
935
+ for (const r of results) {
936
+ // emit된 결과의 원본 소스가 declaration file이면 안 됨 (입력 .d.ts 파일)
937
+ expect((r as { filename: string }).filename).not.toMatch(/node_modules/);
938
+ }
939
+ });
940
+
941
+ it("sourceFilter가 적용되면 필터를 통과하지 못한 파일은 결과에 포함되지 않는다", async () => {
942
+ realProgram = createRealTsProgram({
943
+ "a.ts": "export const a = 1;",
944
+ "b.ts": "export const b = 2;",
945
+ });
946
+
947
+ const compiler = new AngularCompiler({
948
+ rootNames: realProgram.rootNames,
949
+ compilerOptions: {
950
+ target: ts.ScriptTarget.ESNext,
951
+ module: ts.ModuleKind.ESNext,
952
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
953
+ skipLibCheck: true,
954
+ types: [],
955
+ },
956
+ });
957
+
958
+ await compiler.initialize();
959
+
960
+ // 파일 a만 허용 (sourceFilter는 소스 파일명 기준)
961
+ const results = [...compiler.emitAffectedFiles({
962
+ sourceFilter: (fileName: string) => fileName.includes("a.ts"),
963
+ })];
964
+
965
+ // a.ts에서 나온 출력만 포함 (b.ts 출력은 제외)
966
+ expect(results.length).toBeGreaterThan(0);
967
+ for (const r of results) {
968
+ expect((r as { filename: string }).filename).toMatch(/[/\\]a\./);
969
+ }
970
+ });
971
+
972
+ it("EmitResult는 filename과 contents를 포함한다", async () => {
973
+ realProgram = createRealTsProgram({ "a.ts": "export const a = 1;" });
974
+
975
+ const compiler = new AngularCompiler({
976
+ rootNames: realProgram.rootNames,
977
+ compilerOptions: {
978
+ target: ts.ScriptTarget.ESNext,
979
+ module: ts.ModuleKind.ESNext,
980
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
981
+ skipLibCheck: true,
982
+ types: [],
983
+ },
984
+ });
985
+
986
+ await compiler.initialize();
987
+ const results = [...compiler.emitAffectedFiles()];
988
+
989
+ expect(results.length).toBeGreaterThan(0);
990
+ for (const r of results) {
991
+ expect(r).toHaveProperty("filename");
992
+ expect(r).toHaveProperty("contents");
993
+ expect(typeof (r as { filename: string }).filename).toBe("string");
994
+ expect(typeof (r as { contents: string }).contents).toBe("string");
995
+ }
996
+ });
997
+ });
998
+
999
+ describe("AngularCompiler — emitAffectedFiles", () => {
1000
+ // Scenario: 변경된 파일만 emit
1001
+ it("변경된 파일만 emit하고 변경되지 않은 파일은 emit하지 않는다", async () => {
1002
+ // 파일 A, B, C로 프로그램 구성
1003
+ realProgram = createRealTsProgram({
1004
+ "a.ts": "export const a = 1;",
1005
+ "b.ts": "export const b = 2;",
1006
+ "c.ts": "export const c = 3;",
1007
+ });
1008
+
1009
+ const compiler = new AngularCompiler({
1010
+ rootNames: realProgram.rootNames,
1011
+ compilerOptions: {
1012
+ target: ts.ScriptTarget.ESNext,
1013
+ module: ts.ModuleKind.ESNext,
1014
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1015
+ skipLibCheck: true,
1016
+ types: [],
1017
+ },
1018
+ });
1019
+
1020
+ await compiler.initialize();
1021
+
1022
+ // 첫 빌드에서 모든 파일이 affected → 모두 emit
1023
+ const firstResults = [...compiler.emitAffectedFiles()];
1024
+ const firstEmittedFiles = firstResults.map((r: { filename: string }) => r.filename);
1025
+ expect(firstEmittedFiles.length).toBeGreaterThan(0);
1026
+
1027
+ // 두번째 빌드: safeToSkipEmit이 true이면 skip
1028
+ mockSafeToSkipEmit.mockReturnValue(true);
1029
+ await compiler.initialize();
1030
+
1031
+ const secondResults = [...compiler.emitAffectedFiles()];
1032
+ // safeToSkipEmit=true + affectedFiles에 없는 파일은 emit하지 않는다
1033
+ expect(secondResults.length).toBe(0);
1034
+ });
1035
+
1036
+ // Scenario: 첫 빌드에서 모든 파일 emit
1037
+ it("첫 빌드에서 모든 소스 파일이 emit된다", async () => {
1038
+ realProgram = createRealTsProgram({
1039
+ "a.ts": "export const a = 1;",
1040
+ "b.ts": "export const b = 2;",
1041
+ });
1042
+
1043
+ const compiler = new AngularCompiler({
1044
+ rootNames: realProgram.rootNames,
1045
+ compilerOptions: {
1046
+ target: ts.ScriptTarget.ESNext,
1047
+ module: ts.ModuleKind.ESNext,
1048
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1049
+ skipLibCheck: true,
1050
+ types: [],
1051
+ },
1052
+ });
1053
+
1054
+ await compiler.initialize();
1055
+ const results = [...compiler.emitAffectedFiles()];
1056
+
1057
+ // 모든 소스 파일이 emit됨 (declaration file 제외)
1058
+ expect(results.length).toBeGreaterThanOrEqual(2);
1059
+ });
1060
+
1061
+ // Scenario: Angular가 affected로 판단하지만 TypeScript가 판단하지 않은 파일
1062
+ it("TypeScript가 emit하지 않았지만 safeToSkipEmit이 false인 파일이 emit된다", async () => {
1063
+ realProgram = createRealTsProgram({
1064
+ "a.ts": "export const a = 1;",
1065
+ });
1066
+
1067
+ // safeToSkipEmit이 false이면 2차 루프에서 emit해야 한다
1068
+ mockSafeToSkipEmit.mockReturnValue(false);
1069
+
1070
+ const compiler = new AngularCompiler({
1071
+ rootNames: realProgram.rootNames,
1072
+ compilerOptions: {
1073
+ target: ts.ScriptTarget.ESNext,
1074
+ module: ts.ModuleKind.ESNext,
1075
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1076
+ skipLibCheck: true,
1077
+ types: [],
1078
+ },
1079
+ });
1080
+
1081
+ await compiler.initialize();
1082
+ const results = [...compiler.emitAffectedFiles()];
1083
+
1084
+ // 파일이 emit됨
1085
+ expect(results.length).toBeGreaterThan(0);
1086
+ // recordSuccessfulEmit이 호출됨
1087
+ expect(mockRecordSuccessfulEmit).toHaveBeenCalled();
1088
+ });
1089
+
1090
+ // Scenario: safeToSkipEmit이 true이고 affectedFiles에 없는 파일은 skip
1091
+ it("safeToSkipEmit이 true이고 affectedFiles에 없으면 emit하지 않는다", async () => {
1092
+ realProgram = createRealTsProgram({
1093
+ "a.ts": "export const a = 1;",
1094
+ });
1095
+
1096
+ const compiler = new AngularCompiler({
1097
+ rootNames: realProgram.rootNames,
1098
+ compilerOptions: {
1099
+ target: ts.ScriptTarget.ESNext,
1100
+ module: ts.ModuleKind.ESNext,
1101
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1102
+ skipLibCheck: true,
1103
+ types: [],
1104
+ },
1105
+ });
1106
+
1107
+ // 첫 빌드
1108
+ await compiler.initialize();
1109
+ void [...compiler.emitAffectedFiles()];
1110
+
1111
+ // 두번째 빌드: safeToSkipEmit=true + 변경 없음
1112
+ mockSafeToSkipEmit.mockReturnValue(true);
1113
+ await compiler.initialize();
1114
+
1115
+ const results = [...compiler.emitAffectedFiles()];
1116
+ expect(results.length).toBe(0);
1117
+ });
1118
+
1119
+ // Scenario: emit 성공 시 incremental 추적 기록
1120
+ it("emit 성공 시 recordSuccessfulEmit이 호출된다", async () => {
1121
+ realProgram = createRealTsProgram({
1122
+ "a.ts": "export const a = 1;",
1123
+ });
1124
+
1125
+ const compiler = new AngularCompiler({
1126
+ rootNames: realProgram.rootNames,
1127
+ compilerOptions: {
1128
+ target: ts.ScriptTarget.ESNext,
1129
+ module: ts.ModuleKind.ESNext,
1130
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1131
+ skipLibCheck: true,
1132
+ types: [],
1133
+ },
1134
+ });
1135
+
1136
+ await compiler.initialize();
1137
+ void [...compiler.emitAffectedFiles()];
1138
+
1139
+ expect(mockRecordSuccessfulEmit).toHaveBeenCalled();
1140
+ });
1141
+
1142
+ // Scenario: prepareEmit()의 transformers가 emit에 적용
1143
+ it("prepareEmit()의 transformers가 emit에 적용된다", async () => {
1144
+ realProgram = createRealTsProgram({
1145
+ "a.ts": "export const a = 1;",
1146
+ });
1147
+
1148
+ const mockTransformerBefore = vi.fn(
1149
+ (_ctx: ts.TransformationContext) => (sf: ts.SourceFile) => sf,
1150
+ );
1151
+ mockPrepareEmit.mockReturnValue({
1152
+ transformers: { before: [mockTransformerBefore], after: [] },
1153
+ });
1154
+
1155
+ const compiler = new AngularCompiler({
1156
+ rootNames: realProgram.rootNames,
1157
+ compilerOptions: {
1158
+ target: ts.ScriptTarget.ESNext,
1159
+ module: ts.ModuleKind.ESNext,
1160
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1161
+ skipLibCheck: true,
1162
+ types: [],
1163
+ },
1164
+ });
1165
+
1166
+ await compiler.initialize();
1167
+ void [...compiler.emitAffectedFiles()];
1168
+
1169
+ // prepareEmit이 호출되었다
1170
+ expect(mockPrepareEmit).toHaveBeenCalled();
1171
+ });
1172
+
1173
+ // Scenario: 추가 transformers를 외부에서 주입 가능
1174
+ it("additionalTransformers가 emit 시 적용된다", async () => {
1175
+ realProgram = createRealTsProgram({
1176
+ "a.ts": "export const a = 1;",
1177
+ });
1178
+
1179
+ const additionalBefore = vi.fn(
1180
+ (_ctx: ts.TransformationContext) => (sf: ts.SourceFile) => sf,
1181
+ );
1182
+
1183
+ const compiler = new AngularCompiler({
1184
+ rootNames: realProgram.rootNames,
1185
+ compilerOptions: {
1186
+ target: ts.ScriptTarget.ESNext,
1187
+ module: ts.ModuleKind.ESNext,
1188
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1189
+ skipLibCheck: true,
1190
+ types: [],
1191
+ },
1192
+ });
1193
+
1194
+ await compiler.initialize();
1195
+ const results = [...compiler.emitAffectedFiles({
1196
+ additionalTransformers: {
1197
+ before: [additionalBefore],
1198
+ },
1199
+ })];
1200
+
1201
+ // emit이 실행되었다
1202
+ expect(results.length).toBeGreaterThan(0);
1203
+ });
1204
+
1205
+ // Scenario: JS와 .d.ts 모두 emit
1206
+ it("declaration=true, noEmit=false일 때 .js와 .d.ts 모두 emit된다", async () => {
1207
+ realProgram = createRealTsProgram(
1208
+ { "a.ts": "export const a = 1;" },
1209
+ { declaration: true, noEmit: false },
1210
+ );
1211
+
1212
+ const compiler = new AngularCompiler({
1213
+ rootNames: realProgram.rootNames,
1214
+ compilerOptions: {
1215
+ target: ts.ScriptTarget.ESNext,
1216
+ module: ts.ModuleKind.ESNext,
1217
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1218
+ skipLibCheck: true,
1219
+ types: [],
1220
+ declaration: true,
1221
+ noEmit: false,
1222
+ outDir: path.join(realProgram.dir, "out"),
1223
+ },
1224
+ });
1225
+
1226
+ await compiler.initialize();
1227
+ const results = [...compiler.emitAffectedFiles()];
1228
+
1229
+ const filenames = results.map((r: { filename: string }) => r.filename);
1230
+ const hasJs = filenames.some((f: string) => f.endsWith(".js"));
1231
+ const hasDts = filenames.some((f: string) => f.endsWith(".d.ts"));
1232
+ expect(hasJs).toBe(true);
1233
+ expect(hasDts).toBe(true);
1234
+ });
1235
+
1236
+ // Scenario: JS만 emit
1237
+ it("declaration=false일 때 .js만 emit되고 .d.ts는 생성되지 않는다", async () => {
1238
+ realProgram = createRealTsProgram(
1239
+ { "a.ts": "export const a = 1;" },
1240
+ { declaration: false, noEmit: false },
1241
+ );
1242
+
1243
+ const compiler = new AngularCompiler({
1244
+ rootNames: realProgram.rootNames,
1245
+ compilerOptions: {
1246
+ target: ts.ScriptTarget.ESNext,
1247
+ module: ts.ModuleKind.ESNext,
1248
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1249
+ skipLibCheck: true,
1250
+ types: [],
1251
+ declaration: false,
1252
+ noEmit: false,
1253
+ outDir: path.join(realProgram.dir, "out"),
1254
+ },
1255
+ });
1256
+
1257
+ await compiler.initialize();
1258
+ const results = [...compiler.emitAffectedFiles()];
1259
+
1260
+ const filenames = results.map((r: { filename: string }) => r.filename);
1261
+ const hasJs = filenames.some((f: string) => f.endsWith(".js"));
1262
+ const hasDts = filenames.some((f: string) => f.endsWith(".d.ts"));
1263
+ expect(hasJs).toBe(true);
1264
+ expect(hasDts).toBe(false);
1265
+ });
1266
+
1267
+ // Scenario: .d.ts만 emit
1268
+ it("emitDeclarationOnly=true일 때 .d.ts만 emit되고 .js는 생성되지 않는다", async () => {
1269
+ realProgram = createRealTsProgram(
1270
+ { "a.ts": "export const a = 1;" },
1271
+ { declaration: true, emitDeclarationOnly: true },
1272
+ );
1273
+
1274
+ const compiler = new AngularCompiler({
1275
+ rootNames: realProgram.rootNames,
1276
+ compilerOptions: {
1277
+ target: ts.ScriptTarget.ESNext,
1278
+ module: ts.ModuleKind.ESNext,
1279
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1280
+ skipLibCheck: true,
1281
+ types: [],
1282
+ declaration: true,
1283
+ emitDeclarationOnly: true,
1284
+ outDir: path.join(realProgram.dir, "out"),
1285
+ },
1286
+ });
1287
+
1288
+ await compiler.initialize();
1289
+ const results = [...compiler.emitAffectedFiles()];
1290
+
1291
+ const filenames = results.map((r: { filename: string }) => r.filename);
1292
+ const hasJs = filenames.some((f: string) => f.endsWith(".js"));
1293
+ const hasDts = filenames.some((f: string) => f.endsWith(".d.ts"));
1294
+ expect(hasJs).toBe(false);
1295
+ expect(hasDts).toBe(true);
1296
+ });
1297
+
1298
+ // Scenario: emit 없음 (typecheck 전용)
1299
+ it("noEmit=true일 때 어떤 파일도 emit되지 않는다", async () => {
1300
+ realProgram = createRealTsProgram(
1301
+ { "a.ts": "export const a = 1;" },
1302
+ { noEmit: true },
1303
+ );
1304
+
1305
+ const compiler = new AngularCompiler({
1306
+ rootNames: realProgram.rootNames,
1307
+ compilerOptions: {
1308
+ target: ts.ScriptTarget.ESNext,
1309
+ module: ts.ModuleKind.ESNext,
1310
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1311
+ skipLibCheck: true,
1312
+ types: [],
1313
+ noEmit: true,
1314
+ },
1315
+ });
1316
+
1317
+ await compiler.initialize();
1318
+ const results = [...compiler.emitAffectedFiles()];
1319
+
1320
+ expect(results.length).toBe(0);
1321
+ });
1322
+ });
1323
+
1324
+ // =============================================================================
1325
+ // AngularCompiler — update
1326
+ // =============================================================================
1327
+
1328
+ describe("AngularCompiler.update — Unit Tests", () => {
1329
+ it("sourceFileCache가 없으면 에러를 던진다", async () => {
1330
+ const compiler = new AngularCompiler({
1331
+ rootNames: realProgram.rootNames,
1332
+ compilerOptions: {
1333
+ target: ts.ScriptTarget.ESNext,
1334
+ module: ts.ModuleKind.ESNext,
1335
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1336
+ skipLibCheck: true,
1337
+ types: [],
1338
+ },
1339
+ });
1340
+
1341
+ await compiler.initialize();
1342
+ await expect(compiler.update(new Set(["a.ts"]))).rejects.toThrow();
1343
+ });
1344
+
1345
+ it("update() 후 affectedFiles가 반환된다", async () => {
1346
+ const cache = new AngularSourceFileCache();
1347
+
1348
+ const compiler = new AngularCompiler({
1349
+ rootNames: realProgram.rootNames,
1350
+ compilerOptions: {
1351
+ target: ts.ScriptTarget.ESNext,
1352
+ module: ts.ModuleKind.ESNext,
1353
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1354
+ skipLibCheck: true,
1355
+ types: [],
1356
+ },
1357
+ sourceFileCache: cache,
1358
+ });
1359
+
1360
+ await compiler.initialize();
1361
+ const result = await compiler.update(new Set(["nonexistent.ts"]));
1362
+
1363
+ expect(result).toHaveProperty("affectedFiles");
1364
+ expect(result.affectedFiles).toBeInstanceOf(Set);
1365
+ });
1366
+ });
1367
+
1368
+ describe("AngularCompiler — update", () => {
1369
+ // Scenario: 파일 변경 후 incremental rebuild
1370
+ it("update() 후 initialize()가 재호출되어 affectedFiles가 갱신된다", async () => {
1371
+ const cache = new AngularSourceFileCache();
1372
+
1373
+ const compiler = new AngularCompiler({
1374
+ rootNames: realProgram.rootNames,
1375
+ compilerOptions: {
1376
+ target: ts.ScriptTarget.ESNext,
1377
+ module: ts.ModuleKind.ESNext,
1378
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1379
+ skipLibCheck: true,
1380
+ types: [],
1381
+ },
1382
+ sourceFileCache: cache,
1383
+ });
1384
+
1385
+ // 초기 빌드
1386
+ await compiler.initialize();
1387
+ const initCallCount = ngtscConstructorSpy.mock.calls.length;
1388
+
1389
+ // 파일 변경 후 update
1390
+ const aPath = realProgram.rootNames[0];
1391
+ const result = await compiler.update(new Set([aPath]));
1392
+
1393
+ // initialize()가 재호출됨
1394
+ expect(ngtscConstructorSpy.mock.calls.length).toBe(initCallCount + 1);
1395
+ // sourceFileCache에서 invalidate 됨
1396
+ const normalizedPath = aPath.replace(/\\/g, "/");
1397
+ expect(cache.has(normalizedPath)).toBe(false);
1398
+ expect(cache.modifiedFiles.has(normalizedPath)).toBe(true);
1399
+ // affectedFiles가 반환됨
1400
+ expect(result.affectedFiles).toBeInstanceOf(Set);
1401
+ });
1402
+
1403
+ // Scenario: node_modules 파일 변경 시 packageJsonCache clear
1404
+ it("node_modules 파일이 변경되면 packageJsonCache가 clear된다", async () => {
1405
+ const cache = new AngularSourceFileCache();
1406
+
1407
+ const compiler = new AngularCompiler({
1408
+ rootNames: realProgram.rootNames,
1409
+ compilerOptions: {
1410
+ target: ts.ScriptTarget.ESNext,
1411
+ module: ts.ModuleKind.ESNext,
1412
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1413
+ skipLibCheck: true,
1414
+ types: [],
1415
+ },
1416
+ sourceFileCache: cache,
1417
+ });
1418
+
1419
+ // 초기 빌드
1420
+ await compiler.initialize();
1421
+
1422
+ // node_modules 파일 변경으로 update
1423
+ const result = await compiler.update(
1424
+ new Set(["node_modules/some-package/index.js"]),
1425
+ );
1426
+
1427
+ // packageJsonCache.clear()가 호출됨 (에러 없이 완료)
1428
+ expect(result.affectedFiles).toBeInstanceOf(Set);
1429
+ // initialize()가 재호출됨
1430
+ expect(ngtscConstructorSpy.mock.calls.length).toBe(2);
1431
+ });
1432
+
1433
+ // Scenario: SCSS 파일 변경 시 관련 컴포넌트가 affected에 포함
1434
+ it("SCSS 파일 변경 시 관련 컴포넌트가 affected에 포함된다", async () => {
1435
+ realProgram = createRealTsProgram({
1436
+ "component.ts": "export class Comp {}",
1437
+ });
1438
+
1439
+ const scssPath = path.join(realProgram.dir, "component.scss");
1440
+ fs.writeFileSync(scssPath, ".x { }", "utf-8");
1441
+
1442
+ const sourceFiles = realProgram.program.getSourceFiles();
1443
+ const compFile = sourceFiles.find((sf) => sf.fileName.includes("component.ts"));
1444
+
1445
+ mockGetResourceDependencies.mockImplementation((sf: ts.SourceFile) => {
1446
+ if (sf === compFile) {
1447
+ return [scssPath];
1448
+ }
1449
+ return [];
1450
+ });
1451
+
1452
+ const cache = new AngularSourceFileCache();
1453
+
1454
+ const compiler = new AngularCompiler({
1455
+ rootNames: realProgram.rootNames,
1456
+ compilerOptions: {
1457
+ target: ts.ScriptTarget.ESNext,
1458
+ module: ts.ModuleKind.ESNext,
1459
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1460
+ skipLibCheck: true,
1461
+ types: [],
1462
+ },
1463
+ sourceFileCache: cache,
1464
+ });
1465
+
1466
+ // 초기 빌드
1467
+ await compiler.initialize();
1468
+
1469
+ // SCSS 변경 후 update
1470
+ const result = await compiler.update(new Set([scssPath]));
1471
+
1472
+ // component.ts가 affected에 포함
1473
+ const affectedFileNames = [...result.affectedFiles].map(
1474
+ (sf: ts.SourceFile) => sf.fileName,
1475
+ );
1476
+ expect(affectedFileNames.some((f: string) => f.includes("component.ts"))).toBe(true);
1477
+ });
1478
+
1479
+ // Scenario: 리소스 변경 없으면 diagnosticCache 재사용
1480
+ it("리소스 변경 없으면 diagnosticCache가 재사용된다", async () => {
1481
+ const mockDiag = {
1482
+ category: ts.DiagnosticCategory.Error,
1483
+ code: 5000,
1484
+ messageText: "cached diagnostic",
1485
+ } as ts.Diagnostic;
1486
+ mockGetDiagnosticsForFile.mockReturnValue([mockDiag]);
1487
+
1488
+ const cache = new AngularSourceFileCache();
1489
+
1490
+ const compiler = new AngularCompiler({
1491
+ rootNames: realProgram.rootNames,
1492
+ compilerOptions: {
1493
+ target: ts.ScriptTarget.ESNext,
1494
+ module: ts.ModuleKind.ESNext,
1495
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
1496
+ skipLibCheck: true,
1497
+ types: [],
1498
+ },
1499
+ sourceFileCache: cache,
1500
+ });
1501
+
1502
+ // 초기 빌드 + diagnostics
1503
+ await compiler.initialize();
1504
+ void [...compiler.collectDiagnostics()];
1505
+ expect(mockGetDiagnosticsForFile).toHaveBeenCalled();
1506
+
1507
+ // 변경 없이 update → diagnosticCache 재사용
1508
+ mockGetDiagnosticsForFile.mockClear();
1509
+ await compiler.update(new Set([]));
1510
+ const diags2 = [...compiler.collectDiagnostics()];
1511
+
1512
+ // diagnostics가 반환된다 (에러 없이 동작)
1513
+ expect(Array.isArray(diags2)).toBe(true);
1514
+ });
1515
+ });