@simplysm/sd-cli 14.0.39 → 14.0.41

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 (57) hide show
  1. package/dist/angular/angular-build-pipeline.d.ts +1 -0
  2. package/dist/angular/angular-build-pipeline.d.ts.map +1 -1
  3. package/dist/angular/angular-build-pipeline.js +4 -0
  4. package/dist/angular/angular-build-pipeline.js.map +1 -1
  5. package/dist/angular/angular-compiler.d.ts +1 -0
  6. package/dist/angular/angular-compiler.d.ts.map +1 -1
  7. package/dist/angular/angular-compiler.js +3 -0
  8. package/dist/angular/angular-compiler.js.map +1 -1
  9. package/dist/dev-server/hmr-client-script.d.ts +2 -2
  10. package/dist/dev-server/hmr-client-script.d.ts.map +1 -1
  11. package/dist/dev-server/hmr-client-script.js +4 -4
  12. package/dist/dev-server/hmr-client-script.js.map +1 -1
  13. package/dist/esbuild/esbuild-client-config.js +1 -1
  14. package/dist/esbuild/esbuild-client-config.js.map +1 -1
  15. package/dist/lint/lint-core.d.ts +0 -1
  16. package/dist/lint/lint-core.d.ts.map +1 -1
  17. package/dist/lint/lint-core.js +0 -3
  18. package/dist/lint/lint-core.js.map +1 -1
  19. package/dist/lint/lint-with-program.d.ts +0 -1
  20. package/dist/lint/lint-with-program.d.ts.map +1 -1
  21. package/dist/lint/lint-with-program.js +3 -13
  22. package/dist/lint/lint-with-program.js.map +1 -1
  23. package/dist/workers/build-watch-paths.js +2 -2
  24. package/dist/workers/build-watch-paths.js.map +1 -1
  25. package/dist/workers/client.worker.d.ts.map +1 -1
  26. package/dist/workers/client.worker.js +65 -27
  27. package/dist/workers/client.worker.js.map +1 -1
  28. package/dist/workers/ngtsc-build.worker.d.ts.map +1 -1
  29. package/dist/workers/ngtsc-build.worker.js +8 -0
  30. package/dist/workers/ngtsc-build.worker.js.map +1 -1
  31. package/dist/workers/server-esbuild-context.d.ts.map +1 -1
  32. package/dist/workers/server-esbuild-context.js +14 -1
  33. package/dist/workers/server-esbuild-context.js.map +1 -1
  34. package/package.json +4 -4
  35. package/src/angular/angular-build-pipeline.ts +5 -0
  36. package/src/angular/angular-compiler.ts +4 -0
  37. package/src/dev-server/hmr-client-script.ts +4 -4
  38. package/src/esbuild/esbuild-client-config.ts +1 -1
  39. package/src/lint/lint-core.ts +0 -3
  40. package/src/lint/lint-with-program.ts +3 -14
  41. package/src/workers/build-watch-paths.ts +2 -2
  42. package/src/workers/client.worker.ts +67 -30
  43. package/src/workers/ngtsc-build.worker.ts +9 -0
  44. package/src/workers/server-esbuild-context.ts +13 -1
  45. package/tests/angular/angular-build-pipeline.spec.ts +66 -0
  46. package/tests/utils/angular-compiler.spec.ts +35 -0
  47. package/tests/utils/esbuild-client-config.acc.spec.ts +8 -6
  48. package/tests/utils/esbuild-client-config.spec.ts +6 -5
  49. package/tests/utils/hmr-client-script.acc.spec.ts +8 -8
  50. package/tests/utils/hmr-client-script.spec.ts +5 -5
  51. package/tests/utils/lint-core.spec.ts +2 -3
  52. package/tests/utils/lint-with-program.spec.ts +6 -29
  53. package/tests/workers/build-watch-paths.acc.spec.ts +2 -2
  54. package/tests/workers/build-watch-paths.spec.ts +23 -2
  55. package/tests/workers/ngtsc-build-rootnames-refresh.verify.md +8 -0
  56. package/tests/workers/server-esbuild-context.acc.spec.ts +32 -0
  57. package/tests/workers/server-esbuild-context.spec.ts +81 -0
@@ -1,6 +1,6 @@
1
1
  import path from "path";
2
2
  import fs from "node:fs";
3
- import { createWorker } from "@simplysm/core-node";
3
+ import { createWorker, FsWatcher } from "@simplysm/core-node";
4
4
  import { err as errNs } from "@simplysm/core-common";
5
5
  import { setupWorkerLifecycle } from "./shared-worker-lifecycle.js";
6
6
  import {
@@ -14,7 +14,6 @@ import { createHmrService, type HmrService } from "../dev-server/hmr-service.js"
14
14
  import { createHmrPostTransform } from "../dev-server/hmr-client-script.js";
15
15
  import { copyPublicFiles, watchPublicFiles } from "../utils/copy-public.js";
16
16
  import type { SdBrowserSupportConfig, SdPwaConfig } from "../sd-config.types.js";
17
- import type { FsWatcher } from "@simplysm/core-node";
18
17
  import type esbuild from "esbuild";
19
18
 
20
19
  //#region Types
@@ -63,6 +62,7 @@ let esbuildResult: ClientEsbuildResult | undefined;
63
62
  let devServer: DevHttpServer | undefined;
64
63
  let hmrService: HmrService | undefined;
65
64
  let publicWatcher: FsWatcher | undefined;
65
+ let indexHtmlWatcher: FsWatcher | undefined;
66
66
 
67
67
  const { logger, guardStartWatch } = setupWorkerLifecycle("client", async () => {
68
68
  await stopWatch();
@@ -210,8 +210,29 @@ async function startWatch(info: ClientBuildInfo): Promise<ClientBuildResult> {
210
210
  const polyfills = fs.existsSync(polyfillsPath) ? ["src/polyfills.ts"] : undefined;
211
211
  const entryNames = ["main", ...(polyfills != null ? ["polyfills"] : [])];
212
212
 
213
- // 4. templateUpdates Map + esbuild context 생성
213
+ // 4. templateUpdates Map
214
214
  const templateUpdates = new Map<string, string>();
215
+
216
+ // 5. HTTP dev server 생성 + 시작 (포트 확정 — HMR 스크립트에 포트 주입 필요)
217
+ const httpDevServer = createDevHttpServer({
218
+ distDir: outdir,
219
+ basePath,
220
+ port: info.port ?? 0,
221
+ onRequest: (req, res) => hmrService?.handleRequest(req, res) ?? false,
222
+ });
223
+ devServer = httpDevServer;
224
+ const actualPort = await httpDevServer.listen();
225
+
226
+ // 6. HMR WebSocket 서비스 생성
227
+ hmrService = createHmrService({
228
+ httpServer: httpDevServer.httpServer,
229
+ basePath,
230
+ templateUpdates,
231
+ outDir: outdir,
232
+ });
233
+
234
+ // 7. esbuild context 생성
235
+ let lastMetafile: esbuild.Metafile | undefined;
215
236
  let initialBuildResolve: ((result: ClientBuildResult) => void) | undefined;
216
237
  let isInitialBuild = true;
217
238
 
@@ -233,22 +254,26 @@ async function startWatch(info: ClientBuildInfo): Promise<ClientBuildResult> {
233
254
  const prevMtimes = new Map<string, number>();
234
255
 
235
256
  pluginBuild.onStart(() => {
236
- // loadResultCache 무효화: 변경된 JS 파일의 캐시 엔트리 제거
257
+ // sourceFileCache 무효화: 변경된 파일의 loadResultCache + TypeScript 소스 캐시 모두 제거
237
258
  if (esbuildResult != null) {
238
259
  const { loadResultCache } = esbuildResult.sourceFileCache;
260
+ const changedFiles = new Set<string>();
239
261
  for (const file of loadResultCache.watchFiles) {
240
262
  try {
241
263
  const mtime = fs.statSync(file).mtimeMs;
242
264
  const prev = prevMtimes.get(file);
243
265
  if (prev != null && prev !== mtime) {
244
- loadResultCache.invalidate(file);
266
+ changedFiles.add(file);
245
267
  }
246
268
  } catch {
247
269
  if (prevMtimes.has(file)) {
248
- loadResultCache.invalidate(file);
270
+ changedFiles.add(file);
249
271
  }
250
272
  }
251
273
  }
274
+ if (changedFiles.size > 0) {
275
+ esbuildResult.sourceFileCache.invalidate(changedFiles);
276
+ }
252
277
  }
253
278
 
254
279
  if (!isInitialBuild) {
@@ -272,9 +297,10 @@ async function startWatch(info: ClientBuildInfo): Promise<ClientBuildResult> {
272
297
  ],
273
298
  onEnd: async (result: esbuild.BuildResult) => {
274
299
  try {
275
- // index.html 재생성
300
+ // index.html 재생성 (lastMetafile 보관 — index.html 단독 변경 시 재생성용)
276
301
  if (result.metafile != null) {
277
- const hmrPostTransform = createHmrPostTransform(basePath);
302
+ lastMetafile = result.metafile;
303
+ const hmrPostTransform = createHmrPostTransform(basePath, actualPort);
278
304
  const indexPath = path.join(info.pkgDir, "src", "index.html");
279
305
  const indexResult = await generateIndexHtml({
280
306
  indexPath,
@@ -331,37 +357,42 @@ async function startWatch(info: ClientBuildInfo): Promise<ClientBuildResult> {
331
357
  },
332
358
  });
333
359
 
334
- // 5. HTTP dev server 생성
335
- const httpDevServer = createDevHttpServer({
336
- distDir: outdir,
337
- basePath,
338
- port: info.port ?? 0,
339
- onRequest: (req, res) => hmrService?.handleRequest(req, res) ?? false,
340
- });
341
- devServer = httpDevServer;
342
-
343
- // 6. HMR WebSocket 서비스 생성
344
- hmrService = createHmrService({
345
- httpServer: httpDevServer.httpServer,
346
- basePath,
347
- templateUpdates,
348
- outDir: outdir,
349
- });
350
-
351
- // 7. esbuild watch 시작 + 초기 빌드 대기
360
+ // 8. esbuild watch 시작 + 초기 빌드 대기
352
361
  await esbuildResult.context.watch();
353
362
 
354
363
  const initialResult = await new Promise<ClientBuildResult>((resolve) => {
355
364
  initialBuildResolve = resolve;
356
365
  });
357
366
 
358
- // 8. HTTP 서버 시작
359
- const actualPort = await httpDevServer.listen();
367
+ // 9. src/index.html 감시 (esbuild watch는 HTML을 감시하지 않음)
368
+ const indexHtmlSrcPath = path.join(info.pkgDir, "src", "index.html");
369
+ indexHtmlWatcher = await FsWatcher.watch([indexHtmlSrcPath]);
370
+ indexHtmlWatcher.onChange({ delay: 300 }, async () => {
371
+ if (lastMetafile == null) return;
372
+ try {
373
+ sender.send("buildStart", {});
374
+ const hmrPostTransform = createHmrPostTransform(basePath, actualPort);
375
+ const indexResult = await generateIndexHtml({
376
+ indexPath: indexHtmlSrcPath,
377
+ metafile: lastMetafile,
378
+ outdir,
379
+ baseHref: basePath,
380
+ mode: "dev",
381
+ entryNames,
382
+ postTransform: hmrPostTransform,
383
+ });
384
+ fs.writeFileSync(path.join(outdir, "index.html"), indexResult.content);
385
+ hmrService?.broadcast({ type: "full-reload" });
386
+ sender.send("build", { success: true });
387
+ } catch (err) {
388
+ sender.send("error", { message: errNs.message(err) });
389
+ }
390
+ });
360
391
 
361
- // 9. serverReady 이벤트 전송
392
+ // 10. serverReady 이벤트 전송
362
393
  sender.send("serverReady", { port: actualPort });
363
394
 
364
- // 10. .config.json + .dev-port 기록
395
+ // 11. .config.json + .dev-port 기록
365
396
  writeConfigJson(outdir, info.configs);
366
397
  fs.writeFileSync(path.join(outdir, ".dev-port"), String(actualPort));
367
398
 
@@ -403,6 +434,12 @@ async function stopWatch(): Promise<void> {
403
434
  publicWatcher = undefined;
404
435
  }
405
436
 
437
+ // 5. index.html 감시 종료
438
+ if (indexHtmlWatcher != null) {
439
+ await indexHtmlWatcher.close();
440
+ indexHtmlWatcher = undefined;
441
+ }
442
+
406
443
  logger.debug("esbuild watch 정리 완료");
407
444
  }
408
445
 
@@ -296,6 +296,15 @@ async function startWatch(info: NgtscBuildInfo): Promise<void> {
296
296
 
297
297
  sender.send("buildStart", {});
298
298
 
299
+ // 파일 추가/삭제 시 rootNames 재스캔
300
+ if (addOrRemove) {
301
+ const newParsedConfig = parseTsconfig(watchInfo!.pkgDir);
302
+ const newSourceFiles = watchInfo!.output.includeTests === true
303
+ ? getPackageFiles(watchInfo!.pkgDir, newParsedConfig)
304
+ : getPackageSourceFiles(watchInfo!.pkgDir, newParsedConfig);
305
+ pipeline.updateRootNames(newSourceFiles);
306
+ }
307
+
299
308
  // Pipeline 증분 업데이트 (SCSS 의존성 초기화 포함)
300
309
  pipeline.clearScssDependencies();
301
310
  const updateResult = await pipeline.update(modifiedFiles);
@@ -1,5 +1,6 @@
1
1
  import type ts from "typescript";
2
2
  import esbuild from "esbuild";
3
+ import { err as errNs } from "@simplysm/core-common";
3
4
  import {
4
5
  createServerEsbuildOptions,
5
6
  writeChangedOutputFiles,
@@ -76,7 +77,18 @@ export async function rebuild(): Promise<{
76
77
  } | null> {
77
78
  if (context == null) return null;
78
79
 
79
- const result = await context.rebuild();
80
+ let result: esbuild.BuildResult;
81
+ try {
82
+ result = await context.rebuild();
83
+ } catch (err) {
84
+ const tscErrors = tscPlugin?.getErrors() ?? [];
85
+ const allErrors = [errNs.message(err), ...tscErrors];
86
+ return {
87
+ success: false,
88
+ errors: allErrors.length > 0 ? allErrors : undefined,
89
+ warnings: undefined,
90
+ };
91
+ }
80
92
 
81
93
  if (result.metafile != null) {
82
94
  lastMetafile = result.metafile;
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import path from "path";
3
+ import fs from "node:fs";
3
4
  import ts from "typescript";
4
5
  import { AngularBuildPipeline } from "../../src/angular/angular-build-pipeline.js";
5
6
  import { AngularSourceFileCache } from "../../src/angular/angular-compiler.js";
@@ -168,6 +169,71 @@ describe("AngularBuildPipeline", () => {
168
169
  expect(pipeline.getEmittedFile(appPath)).toBeDefined();
169
170
  });
170
171
 
172
+ // --- updateRootNames ---
173
+
174
+ // Acceptance: Scenario "updateRootNames 호출 시 compiler까지 전파"
175
+ it("updateRootNames로 새 파일 추가 후 update가 성공하고 새 파일이 emit된다", async () => {
176
+ const pipeline = new AngularBuildPipeline(
177
+ createPipelineOptions("library", { sourceFileCache: new AngularSourceFileCache() }),
178
+ );
179
+ await pipeline.initialize();
180
+
181
+ const tempPath = path.join(FIXTURE_DIR, "src/temp-update-root-names-test.ts");
182
+ fs.writeFileSync(tempPath, "export const tempRootNamesValue = 42;", "utf-8");
183
+
184
+ try {
185
+ const parsed = parseFixtureConfig();
186
+ const newRootNames = getPackageSourceFiles(FIXTURE_DIR, parsed);
187
+ pipeline.updateRootNames(newRootNames);
188
+
189
+ const result = await pipeline.update([tempPath]);
190
+ expect(result.diagnostics.errors).toHaveLength(0);
191
+ expect(pipeline.getEmittedFile(tempPath.replace(/\\/g, "/"))).toBeDefined();
192
+ } finally {
193
+ fs.unlinkSync(tempPath);
194
+ }
195
+ });
196
+
197
+ // Acceptance: Scenario "기존 소스 파일 삭제 시 rootNames에서 제거"
198
+ it("updateRootNames로 파일 제거 후 update에서 해당 파일이 프로그램에서 제외된다", async () => {
199
+ const tempPath = path.join(FIXTURE_DIR, "src/temp-to-remove.ts");
200
+ fs.writeFileSync(tempPath, "export const toRemove = 1;", "utf-8");
201
+
202
+ try {
203
+ const sourceFileCache = new AngularSourceFileCache();
204
+ const pipeline = new AngularBuildPipeline(
205
+ createPipelineOptions("library", { sourceFileCache }),
206
+ );
207
+ await pipeline.initialize();
208
+
209
+ // 파일이 초기 프로그램에 포함되어 있는지 확인
210
+ const initialFiles = pipeline.getTsProgram().getSourceFiles().map((sf) => sf.fileName.replace(/\\/g, "/"));
211
+ expect(initialFiles.some((f) => f.includes("temp-to-remove.ts"))).toBe(true);
212
+
213
+ // 파일 삭제 시뮬레이션: 디스크에서 삭제 후 rootNames 재스캔
214
+ fs.unlinkSync(tempPath);
215
+ const parsed = parseFixtureConfig();
216
+ const newRootNames = getPackageSourceFiles(FIXTURE_DIR, parsed);
217
+ pipeline.updateRootNames(newRootNames);
218
+
219
+ const result = await pipeline.update([tempPath]);
220
+ expect(result.diagnostics.errors).toHaveLength(0);
221
+
222
+ // 삭제된 파일이 프로그램에서 제외되었는지 확인
223
+ const updatedFiles = pipeline.getTsProgram().getSourceFiles().map((sf) => sf.fileName.replace(/\\/g, "/"));
224
+ expect(updatedFiles.some((f) => f.includes("temp-to-remove.ts"))).toBe(false);
225
+ } finally {
226
+ if (fs.existsSync(tempPath)) {
227
+ fs.unlinkSync(tempPath);
228
+ }
229
+ }
230
+ });
231
+
232
+ it("updateRootNames가 compiler 미초기화 상태에서도 에러 없이 동작한다", () => {
233
+ const pipeline = new AngularBuildPipeline(createPipelineOptions("library"));
234
+ expect(() => pipeline.updateRootNames(["src/new.ts"])).not.toThrow();
235
+ });
236
+
171
237
  // --- getPackageSourceFiles ---
172
238
 
173
239
  it("getPackageSourceFiles includes fixture files by default", () => {
@@ -283,6 +283,41 @@ describe("AngularCompiler — Unit Tests", () => {
283
283
  });
284
284
  });
285
285
 
286
+ // =============================================================================
287
+ // AngularCompiler — updateRootNames
288
+ // =============================================================================
289
+
290
+ describe("AngularCompiler — updateRootNames", () => {
291
+ // Acceptance: Scenario "updateRootNames 호출 시 compiler까지 전파"
292
+ it("updateRootNames() 후 initialize()에서 새 rootNames가 NgtscProgram에 전달된다", async () => {
293
+ const compiler = new AngularCompiler({
294
+ rootNames: ["src/main.ts"],
295
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
296
+ });
297
+
298
+ await compiler.initialize();
299
+ expect(ngtscConstructorSpy.mock.calls[0][0]).toEqual(["src/main.ts"]);
300
+
301
+ compiler.updateRootNames(["src/main.ts", "src/new.ts"]);
302
+ await compiler.initialize();
303
+
304
+ expect(ngtscConstructorSpy.mock.calls[1][0]).toEqual(["src/main.ts", "src/new.ts"]);
305
+ });
306
+
307
+ it("updateRootNames()로 빈 배열 설정 후 initialize()에서 빈 rootNames가 전달된다", async () => {
308
+ const compiler = new AngularCompiler({
309
+ rootNames: ["src/main.ts"],
310
+ compilerOptions: { target: ts.ScriptTarget.ESNext },
311
+ });
312
+
313
+ await compiler.initialize();
314
+ compiler.updateRootNames([]);
315
+ await compiler.initialize();
316
+
317
+ expect(ngtscConstructorSpy.mock.calls[1][0]).toEqual([]);
318
+ });
319
+ });
320
+
286
321
  // =============================================================================
287
322
  // AngularCompiler — 초기화
288
323
  // =============================================================================
@@ -432,8 +432,8 @@ describe("createClientEsbuildContext — Acceptance", () => {
432
432
  );
433
433
  });
434
434
 
435
- // Scenario: SCSS 플러그인이 angularPlugin 다음, customPlugins 앞에 배치된다
436
- it("plugins 배열이 [angularPlugin, scssPlugin, customPlugins, onEndPlugin] 순서로 구성된다", async () => {
435
+ // Scenario: customPlugins가 angularPlugin 이전에 배치된다 (onStart에서 sourceFileCache 무효화 선행 필요)
436
+ it("plugins 배열이 [customPlugins, angularPlugin, scssPlugin, onEndPlugin] 순서로 구성된다", async () => {
437
437
  const customPlugin = { name: "custom", setup: vi.fn() };
438
438
  await createClientEsbuildContext({
439
439
  pkgDir: "/workspace/packages/my-app",
@@ -446,13 +446,15 @@ describe("createClientEsbuildContext — Acceptance", () => {
446
446
  const esbuildOptions = vi.mocked(esbuild.context).mock.calls[0][0];
447
447
  const pluginNames = esbuildOptions.plugins!.map((p: any) => p.name);
448
448
 
449
- expect(pluginNames[0]).toBe("angular-compiler");
450
- expect(pluginNames[1]).toBe("sd-scss");
451
449
  expect(pluginNames).toContain("custom");
450
+ expect(pluginNames).toContain("angular-compiler");
451
+ expect(pluginNames).toContain("sd-scss");
452
452
  expect(pluginNames[pluginNames.length - 1]).toBe("sd-on-end");
453
453
 
454
- const scssIdx = pluginNames.indexOf("sd-scss");
455
454
  const customIdx = pluginNames.indexOf("custom");
456
- expect(scssIdx).toBeLessThan(customIdx);
455
+ const angularIdx = pluginNames.indexOf("angular-compiler");
456
+ const scssIdx = pluginNames.indexOf("sd-scss");
457
+ expect(customIdx).toBeLessThan(angularIdx);
458
+ expect(angularIdx).toBeLessThan(scssIdx);
457
459
  });
458
460
  });
@@ -439,14 +439,17 @@ describe("createClientEsbuildContext — onEnd 플러그인", () => {
439
439
  expect(pluginNames[pluginNames.length - 1]).toBe("sd-on-end");
440
440
  });
441
441
 
442
- it("angularPlugin 항상 plugins 번째에 위치", async () => {
442
+ it("customPlugins가 angularPlugin 이전에 위치 (onStart에서 sourceFileCache 무효화 선행)", async () => {
443
443
  await createClientEsbuildContext({
444
444
  ...baseDev,
445
445
  plugins: [{ name: "custom", setup: vi.fn() } as any],
446
446
  onEnd: vi.fn(),
447
447
  });
448
448
  const opts = vi.mocked(esbuild.context).mock.calls[0][0];
449
- expect(opts.plugins![0]).toEqual(mockAngularPlugin);
449
+ const pluginNames = opts.plugins!.map((p: any) => p.name);
450
+ const customIdx = pluginNames.indexOf("custom");
451
+ const angularIdx = pluginNames.indexOf("angular-compiler");
452
+ expect(customIdx).toBeLessThan(angularIdx);
450
453
  });
451
454
  });
452
455
 
@@ -481,7 +484,7 @@ describe("createClientEsbuildContext — SCSS 플러그인 통합", () => {
481
484
  expect(pluginNames).toContain("sd-scss");
482
485
  });
483
486
 
484
- it("sd-scss 플러그인이 angularPlugin 다음, customPlugins 앞에 위치", async () => {
487
+ it("sd-scss 플러그인이 angularPlugin 다음에 위치", async () => {
485
488
  const customPlugin = { name: "custom", setup: vi.fn() };
486
489
  await createClientEsbuildContext({
487
490
  ...baseDev,
@@ -492,10 +495,8 @@ describe("createClientEsbuildContext — SCSS 플러그인 통합", () => {
492
495
 
493
496
  const angularIdx = pluginNames.indexOf("angular-compiler");
494
497
  const scssIdx = pluginNames.indexOf("sd-scss");
495
- const customIdx = pluginNames.indexOf("custom");
496
498
 
497
499
  expect(scssIdx).toBe(angularIdx + 1);
498
- expect(scssIdx).toBeLessThan(customIdx);
499
500
  });
500
501
  });
501
502
 
@@ -5,7 +5,7 @@ import { getHmrClientScript, createHmrPostTransform } from "../../src/dev-server
5
5
  describe("HMR 클라이언트 스크립트 통합", () => {
6
6
  describe("Scenario: HMR 클라이언트 문법 호환성", () => {
7
7
  it("Chrome 61 비호환 문법을 사용하지 않는다", () => {
8
- const script = getHmrClientScript("/app/");
8
+ const script = getHmrClientScript("/app/", 4200);
9
9
 
10
10
  // optional chaining (?.) 미사용
11
11
  expect(script).not.toMatch(/\?\./);
@@ -16,13 +16,13 @@ describe("HMR 클라이언트 스크립트 통합", () => {
16
16
  });
17
17
 
18
18
  it("WebSocket 연결 코드를 포함한다", () => {
19
- const script = getHmrClientScript("/app/");
19
+ const script = getHmrClientScript("/app/", 4200);
20
20
  expect(script).toContain("WebSocket");
21
21
  expect(script).toContain("ws://");
22
22
  });
23
23
 
24
24
  it("component-update, css-update, full-reload 메시지 핸들러를 포함한다", () => {
25
- const script = getHmrClientScript("/app/");
25
+ const script = getHmrClientScript("/app/", 4200);
26
26
  expect(script).toContain("component-update");
27
27
  expect(script).toContain("css-update");
28
28
  expect(script).toContain("full-reload");
@@ -31,7 +31,7 @@ describe("HMR 클라이언트 스크립트 통합", () => {
31
31
  });
32
32
 
33
33
  it("자동 재연결 로직을 포함한다", () => {
34
- const script = getHmrClientScript("/app/");
34
+ const script = getHmrClientScript("/app/", 4200);
35
35
  expect(script).toContain("setTimeout");
36
36
  expect(script).toContain("connect");
37
37
  });
@@ -77,7 +77,7 @@ describe("HMR 클라이언트 스크립트 통합", () => {
77
77
  }
78
78
 
79
79
  it("css-update 시 msg.files와 매칭되는 link만 cache-busting 적용", () => {
80
- const script = getHmrClientScript("/app/");
80
+ const script = getHmrClientScript("/app/", 4200);
81
81
  const { sandbox, triggerMessage, mainLink, vendorLink } = createScriptEnv();
82
82
 
83
83
  runInNewContext(script, sandbox);
@@ -89,7 +89,7 @@ describe("HMR 클라이언트 스크립트 통합", () => {
89
89
  });
90
90
 
91
91
  it("css-update 시 files에 여러 파일이 있으면 매칭되는 모든 link를 업데이트", () => {
92
- const script = getHmrClientScript("/app/");
92
+ const script = getHmrClientScript("/app/", 4200);
93
93
  const { sandbox, triggerMessage, mainLink, vendorLink } = createScriptEnv();
94
94
 
95
95
  runInNewContext(script, sandbox);
@@ -107,7 +107,7 @@ describe("HMR 클라이언트 스크립트 통합", () => {
107
107
 
108
108
  describe("Scenario: 스크립트 주입", () => {
109
109
  it("postTransform이 </body> 직전에 script 태그를 삽입한다", async () => {
110
- const transform = createHmrPostTransform("/app/");
110
+ const transform = createHmrPostTransform("/app/", 4200);
111
111
  const html = "<html><body><div>content</div></body></html>";
112
112
  const result = await transform(html);
113
113
 
@@ -117,7 +117,7 @@ describe("HMR 클라이언트 스크립트 통합", () => {
117
117
  });
118
118
 
119
119
  it("</body>가 없는 HTML에서도 스크립트를 추가한다", async () => {
120
- const transform = createHmrPostTransform("/app/");
120
+ const transform = createHmrPostTransform("/app/", 4200);
121
121
  const html = "<html><body><div>content</div>";
122
122
  const result = await transform(html);
123
123
 
@@ -3,19 +3,19 @@ import { getHmrClientScript, createHmrPostTransform } from "../../src/dev-server
3
3
 
4
4
  describe("getHmrClientScript", () => {
5
5
  it("유효한 JavaScript를 생성한다", () => {
6
- const script = getHmrClientScript("/app/");
6
+ const script = getHmrClientScript("/app/", 4200);
7
7
  // new Function으로 구문 오류 확인
8
8
  expect(() => new Function(script)).not.toThrow();
9
9
  });
10
10
 
11
11
  it("IIFE로 감싸져 있다", () => {
12
- const script = getHmrClientScript("/app/");
12
+ const script = getHmrClientScript("/app/", 4200);
13
13
  expect(script.trimStart()).toMatch(/^\(function\(\)/);
14
14
  expect(script.trimEnd()).toMatch(/\}\)\(\);$/);
15
15
  });
16
16
 
17
17
  it("const/let 대신 var를 사용한다", () => {
18
- const script = getHmrClientScript("/app/");
18
+ const script = getHmrClientScript("/app/", 4200);
19
19
  // var 사용 확인
20
20
  expect(script).toContain("var ws");
21
21
  // const/let 미사용 확인
@@ -26,7 +26,7 @@ describe("getHmrClientScript", () => {
26
26
 
27
27
  describe("createHmrPostTransform", () => {
28
28
  it("여러 </body> 태그가 있으면 마지막 것 앞에 주입한다", async () => {
29
- const transform = createHmrPostTransform("/app/");
29
+ const transform = createHmrPostTransform("/app/", 4200);
30
30
  const html = "<body>first</body><body>second</body>";
31
31
  const result = await transform(html);
32
32
 
@@ -37,7 +37,7 @@ describe("createHmrPostTransform", () => {
37
37
  });
38
38
 
39
39
  it("빈 HTML에도 스크립트를 추가한다", async () => {
40
- const transform = createHmrPostTransform("/");
40
+ const transform = createHmrPostTransform("/", 4200);
41
41
  const result = await transform("");
42
42
  expect(result).toContain("<script>");
43
43
  });
@@ -134,13 +134,12 @@ describe("executeLint", () => {
134
134
  else process.env["TIMING"] = origTiming;
135
135
  });
136
136
 
137
- it("creates ESLint with cache enabled and correct cache location", async () => {
137
+ it("creates ESLint without cache", async () => {
138
138
  await executeLint({ targets: [], fix: false, timing: false });
139
139
 
140
140
  expect(mocks.eslintCtor).toHaveBeenCalledWith(
141
- expect.objectContaining({
141
+ expect.not.objectContaining({
142
142
  cache: true,
143
- cacheLocation: expect.stringContaining("eslint.cache"),
144
143
  }),
145
144
  );
146
145
  });
@@ -288,8 +288,8 @@ describe("LintWithProgramRunner", () => {
288
288
  });
289
289
  });
290
290
 
291
- describe("Scenario: ESLint cache policy depends on affectedFiles", () => {
292
- it("disables ESLint cache when affectedFiles is provided (watch rebuild)", async () => {
291
+ describe("Scenario: ESLint cache is disabled", () => {
292
+ it("does not pass cache option to ESLint", async () => {
293
293
  const program = createMockProgram([
294
294
  createMockSourceFile("/workspace/packages/my-pkg/src/a.ts"),
295
295
  ]);
@@ -299,37 +299,14 @@ describe("LintWithProgramRunner", () => {
299
299
  pkgName: "my-pkg",
300
300
  });
301
301
 
302
- // When: lint with affectedFiles (watch rebuild)
303
302
  await runner.lint({
304
303
  program: program as any,
305
- affectedFiles: new Set(["/workspace/packages/my-pkg/src/a.ts"]),
306
- });
307
-
308
- // Then: ESLint is created with cache: false
309
- expect(MockESLintClass).toHaveBeenCalledWith(
310
- expect.objectContaining({ cache: false }),
311
- );
312
- });
313
-
314
- it("enables ESLint cache when affectedFiles is not provided (one-time build)", async () => {
315
- const program = createMockProgram([
316
- createMockSourceFile("/workspace/packages/my-pkg/src/a.ts"),
317
- ]);
318
-
319
- const runner = new LintWithProgramRunner({
320
- cwd: "/workspace",
321
- pkgName: "my-pkg",
322
304
  });
323
305
 
324
- // When: lint without affectedFiles (one-time build)
325
- await runner.lint({
326
- program: program as any,
327
- });
328
-
329
- // Then: ESLint is created with cache: true
330
- expect(MockESLintClass).toHaveBeenCalledWith(
331
- expect.objectContaining({ cache: true }),
332
- );
306
+ // cache 옵션이 전달되지 않아야 한다
307
+ const ctorArg = MockESLintClass.mock.calls[0][0] as Record<string, unknown>;
308
+ expect(ctorArg).not.toHaveProperty("cache");
309
+ expect(ctorArg).not.toHaveProperty("cacheLocation");
333
310
  });
334
311
  });
335
312
 
@@ -37,9 +37,9 @@ describe("buildWatchPaths", () => {
37
37
  expect(result.watchPaths).toContainEqual(
38
38
  expect.stringContaining("core-common/src/**/*.ts"),
39
39
  );
40
- // replaceDeps dist (cwd + pkgDir 두 위치)
40
+ // replaceDeps dist (cwd + pkgDir 두 위치, js + dts)
41
41
  expect(result.watchPaths).toContainEqual(
42
- expect.stringContaining("node_modules/@external/lib/dist/**/*.{js,mjs,cjs}"),
42
+ expect.stringContaining("node_modules/@external/lib/dist/**/*.{js,mjs,cjs,d.ts,d.mts,d.cts}"),
43
43
  );
44
44
  // deps 결과 반환
45
45
  expect(result.deps.workspaceDeps).toEqual(["core-common"]);
@@ -63,17 +63,38 @@ describe("buildWatchPaths", () => {
63
63
  // cwd node_modules
64
64
  expect(
65
65
  watchPaths.some((p) =>
66
- p.includes("/ws/node_modules/@scope/pkg/dist/**/*.{js,mjs,cjs}"),
66
+ p.includes("/ws/node_modules/@scope/pkg/dist/**/*.{js,mjs,cjs,d.ts,d.mts,d.cts}"),
67
67
  ),
68
68
  ).toBe(true);
69
69
  // pkgDir node_modules
70
70
  expect(
71
71
  watchPaths.some((p) =>
72
- p.includes("lib/node_modules/@scope/pkg/dist/**/*.{js,mjs,cjs}"),
72
+ p.includes("lib/node_modules/@scope/pkg/dist/**/*.{js,mjs,cjs,d.ts,d.mts,d.cts}"),
73
73
  ),
74
74
  ).toBe(true);
75
75
  });
76
76
 
77
+ it("includes d.ts extensions in replaceDeps paths for non-scoped packages", () => {
78
+ mockCollectDeps.mockReturnValue({
79
+ workspaceDeps: [],
80
+ replaceDeps: ["some-lib"],
81
+ });
82
+
83
+ const { watchPaths } = buildWatchPaths({
84
+ pkgDir: "/ws/packages/lib",
85
+ cwd: "/ws",
86
+ srcGlobs: ["*.ts"],
87
+ });
88
+
89
+ const dtsGlob = "*.{js,mjs,cjs,d.ts,d.mts,d.cts}";
90
+ expect(
91
+ watchPaths.some((p) => p.includes(`/ws/node_modules/some-lib/dist/**/${dtsGlob}`)),
92
+ ).toBe(true);
93
+ expect(
94
+ watchPaths.some((p) => p.includes(`lib/node_modules/some-lib/dist/**/${dtsGlob}`)),
95
+ ).toBe(true);
96
+ });
97
+
77
98
  it("includes extraDirs globs for each directory", () => {
78
99
  mockCollectDeps.mockReturnValue({
79
100
  workspaceDeps: ["dep-a"],
@@ -0,0 +1,8 @@
1
+ # ngtsc watch rootNames 갱신 — LLM 검증
2
+
3
+ ## 검증 항목
4
+
5
+ - [x] onChange에서 addOrRemove === true일 때 parseTsconfig + getPackageSourceFiles + pipeline.updateRootNames 호출: `ngtsc-build.worker.ts:299-306` — `if (addOrRemove)` 블록 내에서 `parseTsconfig(watchInfo!.pkgDir)` → `getPackageSourceFiles()` 또는 `getPackageFiles()` → `pipeline.updateRootNames(newSourceFiles)` 호출 체인이 정확히 구현됨
6
+ - [x] includeTests 분기가 초기 설정(line 233-235)과 동일 패턴: 초기 설정 `watchInfo.output.includeTests === true ? getPackageFiles(...) : getPackageSourceFiles(...)` (line 233-235)와 onChange 내 분기 `watchInfo!.output.includeTests === true ? getPackageFiles(...) : getPackageSourceFiles(...)` (line 302-304)가 동일한 패턴을 사용
7
+ - [x] addOrRemove === false일 때 rootNames 미갱신: `if (addOrRemove)` 블록(line 300-306) 내부에서만 rootNames 재스캔이 수행되므로, addOrRemove가 false일 때는 `pipeline.updateRootNames()`가 호출되지 않음. `shouldSkipRebuild`(line 293)에서 `hasAddOrRemove`가 false이고 변경 파일이 lastSourceFilePaths에 있으면 정상적으로 `pipeline.update(modifiedFiles)`만 실행됨
8
+ - [x] rootNames 재스캔이 shouldSkipRebuild 이후 + pipeline.update() 이전에 위치: line 293에서 shouldSkipRebuild 체크 → line 299에서 rootNames 재스캔 → line 310에서 pipeline.update(). 올바른 순서