@simplysm/sd-cli 14.0.43 → 14.0.45

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 (103) hide show
  1. package/dist/angular/ngtsc-build-core.d.ts +12 -3
  2. package/dist/angular/ngtsc-build-core.d.ts.map +1 -1
  3. package/dist/angular/ngtsc-build-core.js +70 -6
  4. package/dist/angular/ngtsc-build-core.js.map +1 -1
  5. package/dist/commands/publish/version-upgrade.d.ts.map +1 -1
  6. package/dist/commands/publish/version-upgrade.js +15 -12
  7. package/dist/commands/publish/version-upgrade.js.map +1 -1
  8. package/dist/deps/replace-deps/replace-deps-resolve.d.ts.map +1 -1
  9. package/dist/deps/replace-deps/replace-deps-resolve.js +6 -7
  10. package/dist/deps/replace-deps/replace-deps-resolve.js.map +1 -1
  11. package/dist/deps/replace-deps/replace-deps.d.ts.map +1 -1
  12. package/dist/deps/replace-deps/replace-deps.js +79 -15
  13. package/dist/deps/replace-deps/replace-deps.js.map +1 -1
  14. package/dist/deps/server-externals/server-production-files.d.ts +10 -5
  15. package/dist/deps/server-externals/server-production-files.d.ts.map +1 -1
  16. package/dist/deps/server-externals/server-production-files.js +22 -26
  17. package/dist/deps/server-externals/server-production-files.js.map +1 -1
  18. package/dist/esbuild/esbuild-angular-compiler-plugin.d.ts +3 -8
  19. package/dist/esbuild/esbuild-angular-compiler-plugin.d.ts.map +1 -1
  20. package/dist/esbuild/esbuild-angular-compiler-plugin.js +57 -83
  21. package/dist/esbuild/esbuild-angular-compiler-plugin.js.map +1 -1
  22. package/dist/esbuild/esbuild-postcss-plugin.d.ts.map +1 -1
  23. package/dist/esbuild/esbuild-postcss-plugin.js +9 -6
  24. package/dist/esbuild/esbuild-postcss-plugin.js.map +1 -1
  25. package/dist/esbuild/esbuild-worker-plugin.d.ts +30 -0
  26. package/dist/esbuild/esbuild-worker-plugin.d.ts.map +1 -0
  27. package/dist/esbuild/esbuild-worker-plugin.js +197 -0
  28. package/dist/esbuild/esbuild-worker-plugin.js.map +1 -0
  29. package/dist/workers/client.worker.d.ts.map +1 -1
  30. package/dist/workers/client.worker.js +4 -25
  31. package/dist/workers/client.worker.js.map +1 -1
  32. package/dist/workers/incremental-mtime-tracker.d.ts +13 -0
  33. package/dist/workers/incremental-mtime-tracker.d.ts.map +1 -0
  34. package/dist/workers/incremental-mtime-tracker.js +65 -0
  35. package/dist/workers/incremental-mtime-tracker.js.map +1 -0
  36. package/dist/workers/library-build.worker.d.ts.map +1 -1
  37. package/dist/workers/library-build.worker.js +37 -15
  38. package/dist/workers/library-build.worker.js.map +1 -1
  39. package/dist/workers/server-build.worker.d.ts.map +1 -1
  40. package/dist/workers/server-build.worker.js +6 -5
  41. package/dist/workers/server-build.worker.js.map +1 -1
  42. package/dist/workers/server-esbuild-context.d.ts.map +1 -1
  43. package/dist/workers/server-esbuild-context.js +3 -1
  44. package/dist/workers/server-esbuild-context.js.map +1 -1
  45. package/dist/workers/server-watch-manager.js +1 -1
  46. package/dist/workers/server-watch-manager.js.map +1 -1
  47. package/package.json +5 -4
  48. package/src/angular/ngtsc-build-core.ts +73 -5
  49. package/src/commands/publish/version-upgrade.ts +43 -34
  50. package/src/deps/replace-deps/replace-deps-resolve.ts +12 -7
  51. package/src/deps/replace-deps/replace-deps.ts +90 -16
  52. package/src/deps/server-externals/server-production-files.ts +26 -28
  53. package/src/esbuild/esbuild-angular-compiler-plugin.ts +82 -123
  54. package/src/esbuild/esbuild-postcss-plugin.ts +9 -6
  55. package/src/esbuild/esbuild-worker-plugin.ts +266 -0
  56. package/src/workers/client.worker.ts +4 -23
  57. package/src/workers/incremental-mtime-tracker.ts +68 -0
  58. package/src/workers/library-build.worker.ts +41 -14
  59. package/src/workers/server-build.worker.ts +6 -5
  60. package/src/workers/server-esbuild-context.ts +3 -1
  61. package/src/workers/server-watch-manager.ts +1 -1
  62. package/tests/angular/ngtsc-build-core.acc.spec.ts +210 -0
  63. package/tests/angular/ngtsc-build-core.spec.ts +52 -0
  64. package/tests/commands/version-upgrade.acc.spec.ts +210 -0
  65. package/tests/commands/version-upgrade.spec.ts +148 -0
  66. package/tests/deps/replace-deps/replace-deps-perf.verify.md +15 -0
  67. package/tests/deps/replace-deps/replace-deps-resolve.acc.spec.ts +124 -0
  68. package/tests/esbuild/esbuild-angular-compiler-plugin-worker.verify.md +56 -28
  69. package/tests/esbuild/esbuild-postcss-plugin-chunking.verify.md +17 -0
  70. package/tests/esbuild/esbuild-postcss-plugin.acc.spec.ts +152 -0
  71. package/tests/esbuild/esbuild-worker-plugin-node.verify.md +11 -0
  72. package/tests/esbuild/esbuild-worker-plugin.acc.spec.ts +318 -0
  73. package/tests/esbuild/esbuild-worker-plugin.spec.ts +297 -0
  74. package/tests/esbuild/esbuild-worker-plugin.verify.md +7 -0
  75. package/tests/esbuild/fixtures/worker-plugin/node-worker.js +2 -0
  76. package/tests/esbuild/fixtures/worker-plugin/shared-worker.js +6 -0
  77. package/tests/esbuild/fixtures/worker-plugin/worker-error.js +1 -0
  78. package/tests/esbuild/fixtures/worker-plugin/worker.js +3 -0
  79. package/tests/esbuild/fixtures/worker-plugin/worker2.js +3 -0
  80. package/tests/utils/ngtsc-build-core-write-emit.spec.ts +124 -0
  81. package/tests/workers/client-worker-mtime-incremental.verify.md +10 -0
  82. package/tests/workers/incremental-mtime-tracker.acc.spec.ts +144 -0
  83. package/tests/workers/incremental-mtime-tracker.spec.ts +102 -0
  84. package/tests/workers/library-build-worker.spec.ts +4 -0
  85. package/tests/workers/server-build-worker-plugin.verify.md +9 -0
  86. package/tests/workers/server-build-worker.spec.ts +26 -12
  87. package/tests/workers/server-esbuild-context.spec.ts +13 -5
  88. package/tests/workers/server-watch-manager.acc.spec.ts +2 -2
  89. package/tests/workers/server-watch-manager.spec.ts +2 -2
  90. package/dist/angular/web-worker-transformer.d.ts +0 -9
  91. package/dist/angular/web-worker-transformer.d.ts.map +0 -1
  92. package/dist/angular/web-worker-transformer.js +0 -73
  93. package/dist/angular/web-worker-transformer.js.map +0 -1
  94. package/src/angular/web-worker-transformer.ts +0 -117
  95. package/tests/angular/web-worker-transformer.spec.ts +0 -154
  96. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.d.ts +0 -2
  97. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.d.ts.map +0 -1
  98. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.js +0 -4
  99. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.js.map +0 -1
  100. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.d.ts +0 -2
  101. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.d.ts.map +0 -1
  102. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.js +0 -4
  103. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.js.map +0 -1
@@ -0,0 +1,297 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import esbuild from "esbuild";
3
+ import path from "path";
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import { fileURLToPath } from "url";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const fixturesDir = path.join(__dirname, "fixtures", "worker-plugin");
10
+
11
+ const { transformWorkerPatterns, createWorkerBundlePlugin } = await import(
12
+ "../../src/esbuild/esbuild-worker-plugin.js"
13
+ );
14
+
15
+ /**
16
+ * transformWorkerPatterns 테스트용 최소 PluginBuild mock 생성.
17
+ * 실제 esbuild 모듈을 사용하여 Worker 번들링이 동작한다.
18
+ */
19
+ function createMockBuild(overrides?: Partial<esbuild.BuildOptions>): esbuild.PluginBuild {
20
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "worker-unit-"));
21
+ return {
22
+ esbuild,
23
+ initialOptions: {
24
+ outdir: tmpDir,
25
+ write: false,
26
+ ...overrides,
27
+ },
28
+ } as unknown as esbuild.PluginBuild;
29
+ }
30
+
31
+ describe("transformWorkerPatterns — 패턴 감지", () => {
32
+ it("Worker 패턴이 없는 content에 대해 undefined를 반환한다", () => {
33
+ const result = transformWorkerPatterns(
34
+ 'console.log("hello");',
35
+ "/test/entry.js",
36
+ createMockBuild(),
37
+ );
38
+
39
+ expect(result).toBeUndefined();
40
+ });
41
+
42
+ it("'Worker' 문자열이 있지만 new URL 패턴이 아니면 undefined를 반환한다", () => {
43
+ const result = transformWorkerPatterns(
44
+ 'const w = new Worker("./worker.js");',
45
+ "/test/entry.js",
46
+ createMockBuild(),
47
+ );
48
+
49
+ expect(result).toBeUndefined();
50
+ });
51
+
52
+ it("import.meta.url이 아닌 URL 생성은 undefined를 반환한다", () => {
53
+ const result = transformWorkerPatterns(
54
+ 'const w = new Worker(new URL("./worker.js", location.href));',
55
+ "/test/entry.js",
56
+ createMockBuild(),
57
+ );
58
+
59
+ expect(result).toBeUndefined();
60
+ });
61
+
62
+ it("Worker + new URL + import.meta.url 패턴을 감지하여 치환한다", () => {
63
+ const entryPath = path.join(fixturesDir, "entry.js");
64
+
65
+ const result = transformWorkerPatterns(
66
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
67
+ entryPath,
68
+ createMockBuild(),
69
+ );
70
+
71
+ expect(result).toBeDefined();
72
+ expect(result!.contents).not.toContain("./worker.js");
73
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
74
+ expect(result!.errors).toHaveLength(0);
75
+ });
76
+
77
+ it("SharedWorker + new URL + import.meta.url 패턴을 감지하여 치환한다", () => {
78
+ const entryPath = path.join(fixturesDir, "entry.js");
79
+
80
+ const result = transformWorkerPatterns(
81
+ `const sw = new SharedWorker(new URL("./shared-worker.js", import.meta.url));`,
82
+ entryPath,
83
+ createMockBuild(),
84
+ );
85
+
86
+ expect(result).toBeDefined();
87
+ expect(result!.contents).not.toContain("./shared-worker.js");
88
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
89
+ });
90
+
91
+ it("복수 Worker 패턴을 모두 치환한다", () => {
92
+ const entryPath = path.join(fixturesDir, "entry.js");
93
+
94
+ const result = transformWorkerPatterns(
95
+ [
96
+ `const w1 = new Worker(new URL("./worker.js", import.meta.url));`,
97
+ `const w2 = new Worker(new URL("./worker2.js", import.meta.url));`,
98
+ ].join("\n"),
99
+ entryPath,
100
+ createMockBuild(),
101
+ );
102
+
103
+ expect(result).toBeDefined();
104
+ expect(result!.contents).not.toContain("./worker.js");
105
+ expect(result!.contents).not.toContain("./worker2.js");
106
+ });
107
+ });
108
+
109
+ describe("transformWorkerPatterns — type: module 처리", () => {
110
+ it("옵션 없는 Worker에 { type: 'module' }을 추가한다", () => {
111
+ const entryPath = path.join(fixturesDir, "entry.js");
112
+
113
+ const result = transformWorkerPatterns(
114
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
115
+ entryPath,
116
+ createMockBuild(),
117
+ );
118
+
119
+ expect(result).toBeDefined();
120
+ // { type: "module" } 추가됨
121
+ expect(result!.contents).toMatch(/\{\s*type:\s*"module"\s*\}/);
122
+ });
123
+
124
+ it("기존 옵션이 있으면 그대로 유지한다", () => {
125
+ const entryPath = path.join(fixturesDir, "entry.js");
126
+
127
+ const result = transformWorkerPatterns(
128
+ `const w = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });`,
129
+ entryPath,
130
+ createMockBuild(),
131
+ );
132
+
133
+ expect(result).toBeDefined();
134
+ expect(result!.contents).toContain('{ type: "module" }');
135
+ });
136
+ });
137
+
138
+ describe("transformWorkerPatterns — 에러 처리", () => {
139
+ it("Worker 빌드 에러를 errors에 포함하여 반환한다", () => {
140
+ const entryPath = path.join(fixturesDir, "entry.js");
141
+
142
+ const result = transformWorkerPatterns(
143
+ `const w = new Worker(new URL("./worker-error.js", import.meta.url));`,
144
+ entryPath,
145
+ createMockBuild(),
146
+ );
147
+
148
+ expect(result).toBeDefined();
149
+ expect(result!.errors.length).toBeGreaterThan(0);
150
+ });
151
+ });
152
+
153
+ describe("transformWorkerPatterns — write 옵션", () => {
154
+ it("write: false일 때 workerOutputFiles를 반환한다", () => {
155
+ const entryPath = path.join(fixturesDir, "entry.js");
156
+
157
+ const result = transformWorkerPatterns(
158
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
159
+ entryPath,
160
+ createMockBuild({ write: false }),
161
+ );
162
+
163
+ expect(result).toBeDefined();
164
+ expect(result!.workerOutputFiles).toBeDefined();
165
+ expect(result!.workerOutputFiles!.length).toBeGreaterThan(0);
166
+ });
167
+
168
+ it("write: true일 때 workerOutputFiles가 undefined이다", () => {
169
+ const entryPath = path.join(fixturesDir, "entry.js");
170
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "worker-write-"));
171
+
172
+ const result = transformWorkerPatterns(
173
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
174
+ entryPath,
175
+ createMockBuild({ write: true, outdir: tmpDir }),
176
+ );
177
+
178
+ expect(result).toBeDefined();
179
+ expect(result!.workerOutputFiles).toBeUndefined();
180
+
181
+ // 대신 디스크에 파일이 기록됨
182
+ const files = fs.readdirSync(tmpDir);
183
+ const workerFile = files.find((f) => /worker-[a-z0-9]+\.js$/i.test(f));
184
+ expect(workerFile).toBeDefined();
185
+ });
186
+ });
187
+
188
+ describe("transformWorkerPatterns — import.meta.resolve 패턴", () => {
189
+ it("import.meta.resolve 상대 경로 패턴을 감지하여 치환한다", () => {
190
+ const entryPath = path.join(fixturesDir, "entry.js");
191
+
192
+ const result = transformWorkerPatterns(
193
+ `const p = import.meta.resolve("./node-worker.js");`,
194
+ entryPath,
195
+ createMockBuild({ platform: "node" }),
196
+ );
197
+
198
+ expect(result).toBeDefined();
199
+ expect(result!.contents).not.toContain("./node-worker.js");
200
+ expect(result!.contents).toMatch(/new URL\("worker-[a-z0-9]+\.js", import\.meta\.url\)\.href/i);
201
+ expect(result!.errors).toHaveLength(0);
202
+ });
203
+
204
+ it("절대 모듈 경로의 import.meta.resolve는 무시한다", () => {
205
+ const result = transformWorkerPatterns(
206
+ `const p = import.meta.resolve("some-package");`,
207
+ "/test/entry.js",
208
+ createMockBuild({ platform: "node" }),
209
+ );
210
+
211
+ expect(result).toBeUndefined();
212
+ });
213
+
214
+ it("import.meta.resolve 패턴 없는 파일은 undefined를 반환한다", () => {
215
+ const result = transformWorkerPatterns(
216
+ `console.log("no resolve");`,
217
+ "/test/entry.js",
218
+ createMockBuild({ platform: "node" }),
219
+ );
220
+
221
+ expect(result).toBeUndefined();
222
+ });
223
+
224
+ it("브라우저 + Node.js Worker 패턴이 공존하면 모두 치환한다", () => {
225
+ const entryPath = path.join(fixturesDir, "entry.js");
226
+
227
+ const result = transformWorkerPatterns(
228
+ [
229
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
230
+ `const p = import.meta.resolve("./node-worker.js");`,
231
+ ].join("\n"),
232
+ entryPath,
233
+ createMockBuild({ platform: "node" }),
234
+ );
235
+
236
+ expect(result).toBeDefined();
237
+ // 브라우저 Worker 치환
238
+ expect(result!.contents).not.toContain('"./worker.js"');
239
+ // Node.js resolve 치환
240
+ expect(result!.contents).not.toContain("import.meta.resolve");
241
+ expect(result!.contents).toMatch(/new URL\("worker-[a-z0-9]+\.js", import\.meta\.url\)\.href/i);
242
+ });
243
+
244
+ it("import.meta.resolve Worker 빌드 에러를 errors에 포함한다", () => {
245
+ const entryPath = path.join(fixturesDir, "entry.js");
246
+
247
+ const result = transformWorkerPatterns(
248
+ `const p = import.meta.resolve("./worker-error.js");`,
249
+ entryPath,
250
+ createMockBuild({ platform: "node" }),
251
+ );
252
+
253
+ expect(result).toBeDefined();
254
+ expect(result!.errors.length).toBeGreaterThan(0);
255
+ });
256
+
257
+ it("write: false일 때 workerOutputFiles를 반환한다", () => {
258
+ const entryPath = path.join(fixturesDir, "entry.js");
259
+
260
+ const result = transformWorkerPatterns(
261
+ `const p = import.meta.resolve("./node-worker.js");`,
262
+ entryPath,
263
+ createMockBuild({ write: false, platform: "node" }),
264
+ );
265
+
266
+ expect(result).toBeDefined();
267
+ expect(result!.workerOutputFiles).toBeDefined();
268
+ expect(result!.workerOutputFiles!.length).toBeGreaterThan(0);
269
+ });
270
+ });
271
+
272
+ describe("createWorkerBundlePlugin — 플러그인 구조", () => {
273
+ it("esbuild Plugin 프로토콜을 따르는 객체를 반환한다", () => {
274
+ const plugin = createWorkerBundlePlugin();
275
+
276
+ expect(plugin.name).toBe("sd-worker-bundle");
277
+ expect(typeof plugin.setup).toBe("function");
278
+ });
279
+
280
+ it("setup에서 onLoad/onEnd 훅이 등록된다", async () => {
281
+ const plugin = createWorkerBundlePlugin();
282
+
283
+ let hasOnLoad = false;
284
+ let hasOnEnd = false;
285
+
286
+ const mockBuild = {
287
+ initialOptions: {},
288
+ onLoad(_opts: unknown, _cb: unknown) { hasOnLoad = true; },
289
+ onEnd(_cb: unknown) { hasOnEnd = true; },
290
+ } as unknown as esbuild.PluginBuild;
291
+
292
+ await plugin.setup(mockBuild);
293
+
294
+ expect(hasOnLoad).toBe(true);
295
+ expect(hasOnEnd).toBe(true);
296
+ });
297
+ });
@@ -0,0 +1,7 @@
1
+ # Worker 빌드 경고가 메인 빌드로 전파됨 — LLM 검증
2
+
3
+ ## 검증 항목
4
+
5
+ - [x] `transformWorkerPatterns`에서 `workerResult.warnings`를 warnings 배열에 push: `esbuild-worker-plugin.ts:109` — `warnings.push(...workerResult.warnings)` 확인. esbuild.buildSync의 BuildResult.warnings가 그대로 전달됨.
6
+ - [x] 반환 결과의 `warnings` 필드에 수집된 경고가 포함됨: `esbuild-worker-plugin.ts:172` — `warnings` 배열이 결과 객체에 포함됨.
7
+ - [x] 플러그인의 onLoad에서 warnings를 esbuild에 전달: `esbuild-worker-plugin.ts:210` — `warnings: result.warnings.length > 0 ? result.warnings : undefined` — onLoad 결과에 warnings를 포함하여 esbuild가 빌드 경고로 처리.
@@ -0,0 +1,2 @@
1
+ const path = require("path");
2
+ process.stdout.write("node-worker:" + path.resolve("."));
@@ -0,0 +1,6 @@
1
+ self.onconnect = (e) => {
2
+ const port = e.ports[0];
3
+ port.onmessage = (ev) => {
4
+ port.postMessage(ev.data);
5
+ };
6
+ };
@@ -0,0 +1 @@
1
+ this is not valid javascript }{][
@@ -0,0 +1,3 @@
1
+ self.onmessage = (e) => {
2
+ self.postMessage(e.data);
3
+ };
@@ -0,0 +1,3 @@
1
+ self.onmessage = (e) => {
2
+ self.postMessage("worker2:" + e.data);
3
+ };
@@ -428,6 +428,130 @@ describe("writeEmitResults with registry", () => {
428
428
  });
429
429
  });
430
430
 
431
+ // Feature 1.1 (review fix): writeEmitResults에서 sideEffectScssDeps 갱신
432
+ describe("writeEmitResults with sideEffectScssDeps", () => {
433
+ let tmpDir: string;
434
+ let pkgDir: string;
435
+ let srcDir: string;
436
+
437
+ beforeEach(() => {
438
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "write-emit-se-deps-"));
439
+ pkgDir = path.join(tmpDir, "my-pkg");
440
+ srcDir = path.join(pkgDir, "src");
441
+ fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
442
+ fs.mkdirSync(path.join(srcDir, "ui", "layout"), { recursive: true });
443
+ });
444
+
445
+ afterEach(() => {
446
+ fs.rmSync(tmpDir, { recursive: true, force: true });
447
+ });
448
+
449
+ // Acceptance: 새 side-effect SCSS의 의존성이 sideEffectScssDeps에 기록된다
450
+ it("records side-effect SCSS dependencies in sideEffectScssDeps after compileScssFile", async () => {
451
+ const { writeEmitResults } = await import("../../src/angular/ngtsc-build-core");
452
+
453
+ // Create shared variables file
454
+ const scssLoadDir = path.join(pkgDir, "scss");
455
+ fs.mkdirSync(path.join(scssLoadDir, "commons"), { recursive: true });
456
+ fs.writeFileSync(
457
+ path.join(scssLoadDir, "commons", "_variables.scss"),
458
+ "$primary: #ff0000;",
459
+ "utf-8",
460
+ );
461
+
462
+ // Create source SCSS that uses @use
463
+ const scssPath = path.join(srcDir, "ui", "layout", "sd-card.scss");
464
+ fs.writeFileSync(
465
+ scssPath,
466
+ '@use "commons/variables" as vars;\n.sd-card { color: vars.$primary; }',
467
+ "utf-8",
468
+ );
469
+
470
+ const distDir = path.join(pkgDir, "dist");
471
+ const jsPath = path.join(distDir, "my-pkg", "src", "ui", "layout", "sd-card.directive.js");
472
+ const sourceFileName = path.join(srcDir, "ui", "layout", "sd-card.directive.ts");
473
+ const emitResults = [
474
+ {
475
+ filename: jsPath,
476
+ contents: 'import "./sd-card.scss";\nexport class SdCardDirective {}',
477
+ sourceFileName,
478
+ },
479
+ ];
480
+
481
+ const sideEffectScssDeps = new Map<string, Set<string>>();
482
+ const scss: SideEffectScssOptions = {
483
+ loadPaths: [scssLoadDir],
484
+ scssErrors: [],
485
+ scssDependencies: new Map(),
486
+ sideEffectScssDeps,
487
+ };
488
+ writeEmitResults(emitResults, pkgDir, scss);
489
+
490
+ // sideEffectScssDeps should have the SCSS entry with its dependencies
491
+ expect(sideEffectScssDeps.has(scssPath)).toBe(true);
492
+ const deps = sideEffectScssDeps.get(scssPath)!;
493
+ expect(deps.size).toBeGreaterThan(0);
494
+ expect([...deps].some((d) => d.includes("_variables.scss"))).toBe(true);
495
+ });
496
+
497
+ // Unit: sideEffectScssDeps 미제공 시 기존 동작 유지
498
+ it("does not crash when sideEffectScssDeps is not provided", async () => {
499
+ const { writeEmitResults } = await import("../../src/angular/ngtsc-build-core");
500
+
501
+ const scssPath = path.join(srcDir, "ui", "layout", "sd-flex.scss");
502
+ fs.writeFileSync(scssPath, ".sd-flex { display: flex; }", "utf-8");
503
+
504
+ const distDir = path.join(pkgDir, "dist");
505
+ const jsPath = path.join(distDir, "my-pkg", "src", "ui", "layout", "sd-flex.directive.js");
506
+ const emitResults = [
507
+ {
508
+ filename: jsPath,
509
+ contents: 'import "./sd-flex.scss";\nexport class SdFlexDirective {}',
510
+ sourceFileName: path.join(srcDir, "ui", "layout", "sd-flex.directive.ts"),
511
+ },
512
+ ];
513
+
514
+ const scss: SideEffectScssOptions = {
515
+ loadPaths: [],
516
+ scssErrors: [],
517
+ scssDependencies: new Map(),
518
+ };
519
+
520
+ // Should not throw
521
+ writeEmitResults(emitResults, pkgDir, scss);
522
+ expect(scss.scssErrors).toEqual([]);
523
+ });
524
+
525
+ // Unit: SCSS 컴파일 에러 시 sideEffectScssDeps에 기록하지 않는다
526
+ it("does not record in sideEffectScssDeps when SCSS compilation fails", async () => {
527
+ const { writeEmitResults } = await import("../../src/angular/ngtsc-build-core");
528
+
529
+ const distDir = path.join(pkgDir, "dist");
530
+ const jsPath = path.join(distDir, "my-pkg", "src", "ui", "layout", "missing.directive.js");
531
+ const emitResults = [
532
+ {
533
+ filename: jsPath,
534
+ contents: 'import "./missing.scss";\nexport class MissingDirective {}',
535
+ sourceFileName: path.join(srcDir, "ui", "layout", "missing.directive.ts"),
536
+ },
537
+ ];
538
+
539
+ const sideEffectScssDeps = new Map<string, Set<string>>();
540
+ const scss: SideEffectScssOptions = {
541
+ loadPaths: [],
542
+ scssErrors: [],
543
+ scssDependencies: new Map(),
544
+ sideEffectScssDeps,
545
+ };
546
+ writeEmitResults(emitResults, pkgDir, scss);
547
+
548
+ // Error should be reported
549
+ expect(scss.scssErrors.length).toBeGreaterThan(0);
550
+ // sideEffectScssDeps should be empty (no successful compilation)
551
+ expect(sideEffectScssDeps.size).toBe(0);
552
+ });
553
+ });
554
+
431
555
  // Feature 1.2: compileSideEffectScss
432
556
  describe("compileSideEffectScss", () => {
433
557
  let tmpDir: string;
@@ -0,0 +1,10 @@
1
+ # client.worker mtime 증분 추적 — LLM 검증
2
+
3
+ ## 검증 항목
4
+
5
+ - [x] createSourceFileCachePlugin이 IncrementalMtimeTracker를 사용하는지 확인: `client.worker.ts:198` — `const mtimeTracker = new IncrementalMtimeTracker();` 확인됨
6
+ - [x] onStart에서 detectChanges 결과로 invalidate가 호출되는지 확인: `client.worker.ts:210-213` — `mtimeTracker.detectChanges(watchTargets)` 결과를 `sourceFileCache.invalidate(changedFiles)`에 전달. 기존 로직과 동작 동일
7
+ - [x] onEnd에서 updateMtimes가 호출되는지 확인: `client.worker.ts:228` — `mtimeTracker.updateMtimes(watchTargets)` 호출 확인. `prevMtimes.clear()` + 전체 순회 제거됨
8
+ - [x] esbuildResult == null 가드가 유지되는지 확인: onStart의 `if (esbuildResult != null)` (라인 202), onEnd의 `if (esbuildResult == null) return;` (라인 222) 모두 유지됨
9
+ - [x] isInitialBuild 가드와 sender.send("buildStart")가 유지되는지 확인: `client.worker.ts:216-218` — `if (!isInitialBuild) { sender.send("buildStart", {}); }` 유지됨
10
+ - [x] import 경로에 .js 확장자가 없는지 확인: `client.worker.ts:20` — `from "./incremental-mtime-tracker"` (확장자 없음)
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { IncrementalMtimeTracker } from "../../src/workers/incremental-mtime-tracker";
6
+
7
+ describe("IncrementalMtimeTracker — Acceptance", () => {
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mtime-acc-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ function createFile(name: string): string {
19
+ const filePath = path.join(tmpDir, name);
20
+ fs.writeFileSync(filePath, `content-${name}`, "utf-8");
21
+ return filePath;
22
+ }
23
+
24
+ function touchFile(filePath: string): void {
25
+ const stat = fs.statSync(filePath);
26
+ const newTime = stat.mtimeMs / 1000 + 2;
27
+ fs.utimesSync(filePath, newTime, newTime);
28
+ }
29
+
30
+ // Scenario: 변경된 파일만 mtime 갱신
31
+ it("onEnd에서 변경된 파일만 stat 호출한다", () => {
32
+ const tracker = new IncrementalMtimeTracker();
33
+ const fileA = createFile("a.ts");
34
+ const fileB = createFile("b.ts");
35
+ const fileC = createFile("c.ts");
36
+ const targets = [fileA, fileB, fileC];
37
+
38
+ // 초기 빌드: prevMtimes 채우기
39
+ tracker.updateMtimes(targets);
40
+
41
+ // 파일 A 변경
42
+ touchFile(fileA);
43
+
44
+ // onStart: 변경 감지
45
+ const changed = tracker.detectChanges(targets);
46
+ expect(changed.size).toBe(1);
47
+ expect(changed.has(fileA)).toBe(true);
48
+
49
+ // onEnd: 증분 갱신 — 변경된 A만 stat
50
+ const spy = vi.spyOn(fs, "statSync");
51
+ tracker.updateMtimes(targets);
52
+ const statPaths = spy.mock.calls.map((c) => c[0]);
53
+ expect(statPaths).toContain(fileA);
54
+ expect(statPaths).not.toContain(fileB);
55
+ expect(statPaths).not.toContain(fileC);
56
+ spy.mockRestore();
57
+ });
58
+
59
+ // Scenario: 새로 추가된 파일만 stat 호출
60
+ it("onEnd에서 새로 추가된 파일만 stat 호출한다", () => {
61
+ const tracker = new IncrementalMtimeTracker();
62
+ const fileA = createFile("a.ts");
63
+ const fileB = createFile("b.ts");
64
+
65
+ tracker.updateMtimes([fileA, fileB]);
66
+
67
+ const fileC = createFile("c.ts");
68
+ tracker.detectChanges([fileA, fileB, fileC]);
69
+
70
+ const spy = vi.spyOn(fs, "statSync");
71
+ tracker.updateMtimes([fileA, fileB, fileC]);
72
+ const statPaths = spy.mock.calls.map((c) => c[0]);
73
+ expect(statPaths).toEqual([fileC]);
74
+ spy.mockRestore();
75
+ });
76
+
77
+ // Scenario: 변경도 신규도 없으면 stat 호출 0회
78
+ it("변경도 신규도 없으면 onEnd에서 stat 호출이 0회이다", () => {
79
+ const tracker = new IncrementalMtimeTracker();
80
+ const fileA = createFile("a.ts");
81
+ const fileB = createFile("b.ts");
82
+
83
+ tracker.updateMtimes([fileA, fileB]);
84
+ tracker.detectChanges([fileA, fileB]);
85
+
86
+ const spy = vi.spyOn(fs, "statSync");
87
+ tracker.updateMtimes([fileA, fileB]);
88
+ expect(spy.mock.calls).toHaveLength(0);
89
+ spy.mockRestore();
90
+ });
91
+
92
+ // Scenario: 삭제된 파일이 prevMtimes에서 제거된다
93
+ it("삭제된 파일은 이후 빌드에서 변경으로 감지되지 않는다", () => {
94
+ const tracker = new IncrementalMtimeTracker();
95
+ const fileA = createFile("a.ts");
96
+ const fileB = createFile("b.ts");
97
+
98
+ tracker.updateMtimes([fileA, fileB]);
99
+
100
+ // fileB를 watchTargets에서 제거
101
+ tracker.detectChanges([fileA]);
102
+ tracker.updateMtimes([fileA]);
103
+
104
+ // fileB를 다시 추가 — "신규" 파일이므로 changedFiles에 포함되지 않음
105
+ const changed = tracker.detectChanges([fileA, fileB]);
106
+ expect(changed.has(fileB)).toBe(false);
107
+ });
108
+
109
+ // Scenario: mtime 변경된 파일이 changedFiles에 포함된다
110
+ it("mtime이 변경된 파일이 changedFiles에 포함된다", () => {
111
+ const tracker = new IncrementalMtimeTracker();
112
+ const fileA = createFile("a.ts");
113
+
114
+ tracker.updateMtimes([fileA]);
115
+ touchFile(fileA);
116
+
117
+ const changed = tracker.detectChanges([fileA]);
118
+ expect(changed.has(fileA)).toBe(true);
119
+ });
120
+
121
+ // Scenario: 삭제된 파일이 changedFiles에 포함된다
122
+ it("삭제된 파일이 changedFiles에 포함된다", () => {
123
+ const tracker = new IncrementalMtimeTracker();
124
+ const fileA = createFile("a.ts");
125
+
126
+ tracker.updateMtimes([fileA]);
127
+ fs.unlinkSync(fileA);
128
+
129
+ const changed = tracker.detectChanges([fileA]);
130
+ expect(changed.has(fileA)).toBe(true);
131
+ });
132
+
133
+ // Scenario: 신규 파일은 changedFiles에 포함되지 않는다
134
+ it("신규 파일은 changedFiles에 포함되지 않는다", () => {
135
+ const tracker = new IncrementalMtimeTracker();
136
+ const fileA = createFile("a.ts");
137
+
138
+ tracker.updateMtimes([fileA]);
139
+
140
+ const fileB = createFile("b.ts");
141
+ const changed = tracker.detectChanges([fileA, fileB]);
142
+ expect(changed.has(fileB)).toBe(false);
143
+ });
144
+ });