@simplysm/sd-cli 14.0.45 → 14.0.47

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 (28) hide show
  1. package/dist/commands/publish/git-phase.js +1 -1
  2. package/dist/commands/publish/git-phase.js.map +1 -1
  3. package/dist/deps/server-externals/server-production-files.d.ts.map +1 -1
  4. package/dist/deps/server-externals/server-production-files.js +5 -3
  5. package/dist/deps/server-externals/server-production-files.js.map +1 -1
  6. package/dist/esbuild/esbuild-angular-compiler-plugin.d.ts.map +1 -1
  7. package/dist/esbuild/esbuild-angular-compiler-plugin.js +6 -2
  8. package/dist/esbuild/esbuild-angular-compiler-plugin.js.map +1 -1
  9. package/dist/esbuild/esbuild-config.d.ts.map +1 -1
  10. package/dist/esbuild/esbuild-config.js +2 -5
  11. package/dist/esbuild/esbuild-config.js.map +1 -1
  12. package/dist/esbuild/esbuild-worker-plugin.d.ts +36 -2
  13. package/dist/esbuild/esbuild-worker-plugin.d.ts.map +1 -1
  14. package/dist/esbuild/esbuild-worker-plugin.js +171 -41
  15. package/dist/esbuild/esbuild-worker-plugin.js.map +1 -1
  16. package/dist/utils/output-path-rewriter.d.ts +5 -2
  17. package/dist/utils/output-path-rewriter.d.ts.map +1 -1
  18. package/dist/utils/output-path-rewriter.js +60 -12
  19. package/dist/utils/output-path-rewriter.js.map +1 -1
  20. package/package.json +10 -8
  21. package/src/commands/publish/git-phase.ts +1 -1
  22. package/src/deps/server-externals/server-production-files.ts +5 -3
  23. package/src/esbuild/esbuild-angular-compiler-plugin.ts +6 -2
  24. package/src/esbuild/esbuild-config.ts +2 -7
  25. package/src/esbuild/esbuild-worker-plugin.ts +229 -56
  26. package/src/utils/output-path-rewriter.ts +65 -17
  27. package/tests/deps/server-externals/mise-toml-parse-intent.verify.md +16 -0
  28. package/tests/esbuild/esbuild-worker-plugin.spec.ts +547 -1
@@ -1,39 +1,87 @@
1
1
  import path from "path";
2
2
  import { pathx } from "@simplysm/core-node";
3
+ import { init, parse } from "es-module-lexer";
4
+
5
+ await init;
6
+
7
+ const KNOWN_JS_EXTENSIONS = /\.(js|mjs|cjs|json|css|scss|wasm|node)$/i;
8
+
9
+ /**
10
+ * es-module-lexer의 import에서 specifier 내용의 시작/끝 위치를 반환한다.
11
+ *
12
+ * static import (d === -1): s..e가 따옴표 없는 specifier 내용
13
+ * dynamic import (d >= 0): s..e가 따옴표를 포함한 문자열 리터럴
14
+ */
15
+ function getSpecifierRange(imp: { s: number; e: number; d: number }): [number, number] {
16
+ if (imp.d >= 0) {
17
+ return [imp.s + 1, imp.e - 1];
18
+ }
19
+ return [imp.s, imp.e];
20
+ }
3
21
 
4
22
  /**
5
23
  * ESM 출력에서 확장자가 없는 상대 import/export 경로에 .js 확장자를 추가한다.
6
24
  *
7
- * 매칭: from "./foo", import("./bar"), from "../baz"
25
+ * es-module-lexer를 사용하여 import/export specifier 위치를 정확히 파악한다.
26
+ * 주석, 문자열 리터럴 내부의 패턴은 무시된다.
27
+ *
28
+ * 매칭: from "./foo", import("./bar"), from "../baz", export { x } from "./qux"
8
29
  * 스킵: bare 지정자("lodash"), 이미 알려진 확장자로 끝나는 경로 (.js, .json, .css 등)
9
30
  */
10
31
  export function addJsExtensionToImports(text: string): string {
11
- return text.replace(
12
- /((?:from|import)\s*(?:\(\s*)?["'])(\.\.?\/[^"']*?)(["'](?:\s*\))?)/g,
13
- (_match, prefix: string, importPath: string, suffix: string) => {
14
- if (/\.(js|mjs|cjs|json|css|scss|wasm|node)$/i.test(importPath)) return _match;
15
- return `${prefix}${importPath}.js${suffix}`;
16
- },
17
- );
32
+ const [imports] = parse(text);
33
+ if (imports.length === 0) return text;
34
+
35
+ // 역순으로 치환하여 위치 밀림 방지
36
+ const sorted = [...imports].sort((a, b) => b.s - a.s);
37
+ let result = text;
38
+
39
+ for (const imp of sorted) {
40
+ const specifier = imp.n;
41
+ if (specifier == null) continue;
42
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) continue;
43
+ if (KNOWN_JS_EXTENSIONS.test(specifier)) continue;
44
+
45
+ const [start, end] = getSpecifierRange(imp);
46
+ result = result.slice(0, start) + specifier + ".js" + result.slice(end);
47
+ }
48
+
49
+ return result;
18
50
  }
19
51
 
20
52
  /**
21
53
  * emit된 JS 텍스트에서 상대 .scss import를 .css로 변환한다.
22
54
  * 변환된 텍스트와 원본 .scss import 경로 목록을 반환한다.
23
55
  *
24
- * 매칭: import "./foo.scss", import("./bar.scss"), from "../baz.scss"
56
+ * es-module-lexer를 사용하여 import specifier 위치를 정확히 파악한다.
25
57
  * 상대 import(./ 또는 ../ 시작)만 처리한다.
26
58
  */
27
59
  export function rewriteScssImports(text: string): { text: string; scssImports: string[] } {
60
+ const [imports] = parse(text);
61
+ if (imports.length === 0) return { text, scssImports: [] };
62
+
28
63
  const scssImports: string[] = [];
29
- const rewritten = text.replace(
30
- /((?:from|import)\s*(?:\(\s*)?["'])(\.\.?\/[^"']*?)(\.scss)(["'](?:\s*\))?)/g,
31
- (_match, prefix: string, importBase: string, _ext: string, suffix: string) => {
32
- scssImports.push(`${importBase}.scss`);
33
- return `${prefix}${importBase}.css${suffix}`;
34
- },
35
- );
36
- return { text: rewritten, scssImports };
64
+
65
+ // 역순으로 치환하여 위치 밀림 방지
66
+ const sorted = [...imports].sort((a, b) => b.s - a.s);
67
+ let result = text;
68
+
69
+ for (const imp of sorted) {
70
+ const specifier = imp.n;
71
+ if (specifier == null) continue;
72
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) continue;
73
+ if (!specifier.endsWith(".scss")) continue;
74
+
75
+ scssImports.push(specifier);
76
+ const newSpec = specifier.slice(0, -5) + ".css";
77
+ const [start, end] = getSpecifierRange(imp);
78
+ result = result.slice(0, start) + newSpec + result.slice(end);
79
+ }
80
+
81
+ // 역순으로 수집되었으므로 원래 순서로 복원
82
+ scssImports.reverse();
83
+
84
+ return { text: result, scssImports };
37
85
  }
38
86
 
39
87
  /**
@@ -0,0 +1,16 @@
1
+ # mise.toml 파싱 실패 시 명시적 크래시 의도 — LLM 검증
2
+
3
+ 대상: `packages/sd-cli/src/deps/server-externals/server-production-files.ts` `generateProductionFiles()` 내 `TOML.parse(miseContent)` 호출부
4
+
5
+ 근거: `.tasks/260417132441_review-regex-to-parser/1.3-toml-parse-intent-comment.md`
6
+
7
+ ## 검증 항목
8
+
9
+ - [x] **WHY 주석 존재**: `server-production-files.ts:114`에 `// mise.toml은 저장소에서 관리되는 설정 파일이므로, 파싱 실패 시 폴백하지 않고 예외를 전파하여 설정 오류를 즉시 드러낸다.` 주석이 `TOML.parse(miseContent)` 호출(`:115`) 직전에 존재함을 확인
10
+ - [x] **명시적 크래시 의도 서술**: 주석이 "저장소에서 관리되는 설정 파일", "폴백하지 않고", "예외를 전파", "설정 오류를 즉시 드러낸다" 표현을 모두 포함하여 wbs에 명시된 WHY를 서술
11
+ - [x] **WHAT 서술 지양**: "TOML을 파싱한다" 같은 코드 재서술이 아니라, 폴백을 의도적으로 하지 않는 이유(설정 오류 즉시 노출)를 설명
12
+ - [x] **try-catch 미추가**: `:109-121` 블록 전체에 try 키워드 없음을 확인 — 파싱 예외는 상위로 전파됨
13
+ - [x] **주석 스타일**: `//` 한 줄 주석 (JSDoc 아님)
14
+ - [x] **주석 언어**: 한국어
15
+ - [x] **기타 코드 미변경**: `git diff server-production-files.ts` 결과 라인 114의 주석 추가 1줄 외 변경 없음 (아래 명령 실행 결과 참조)
16
+ - [x] **기존 테스트 통과**: `pnpm exec vitest run --project node packages/sd-cli/tests/workers/server-build-worker.spec.ts` 25/25 통과, 특히 `generates dist/mise.toml when packageManager=mise` 테스트(TOML.parse 경로 실행)가 통과하여 파싱 로직 회귀 없음을 확인. 참고: 전체 sd-cli 테스트(`packages/sd-cli/tests/`)에서 1건 실패(`esbuild-worker-plugin.acc.spec.ts:143`)가 있으나 이는 Feature 1.1 영역이며 본 Feature 1.3 변경(주석 1줄 추가)과 무관
@@ -8,7 +8,7 @@ import { fileURLToPath } from "url";
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const fixturesDir = path.join(__dirname, "fixtures", "worker-plugin");
10
10
 
11
- const { transformWorkerPatterns, createWorkerBundlePlugin } = await import(
11
+ const { transformWorkerPatterns, createWorkerBundlePlugin, findWorkerPatterns } = await import(
12
12
  "../../src/esbuild/esbuild-worker-plugin.js"
13
13
  );
14
14
 
@@ -28,6 +28,36 @@ function createMockBuild(overrides?: Partial<esbuild.BuildOptions>): esbuild.Plu
28
28
  } as unknown as esbuild.PluginBuild;
29
29
  }
30
30
 
31
+ /**
32
+ * transformSync 호출을 추적할 수 있도록 esbuild 네임스페이스를 wrap한다.
33
+ * vi.spyOn이 ESM namespace frozen property에서 실패하므로 대안으로 사용한다.
34
+ */
35
+ function createTrackedBuild(overrides?: Partial<esbuild.BuildOptions>): {
36
+ build: esbuild.PluginBuild;
37
+ transformSyncCalls: Array<Parameters<typeof esbuild.transformSync>>;
38
+ } {
39
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "worker-unit-"));
40
+ const transformSyncCalls: Array<Parameters<typeof esbuild.transformSync>> = [];
41
+ const trackedEsbuild = {
42
+ ...esbuild,
43
+ transformSync: (...args: Parameters<typeof esbuild.transformSync>) => {
44
+ transformSyncCalls.push(args);
45
+ return esbuild.transformSync(...args);
46
+ },
47
+ } as typeof esbuild;
48
+
49
+ const build = {
50
+ esbuild: trackedEsbuild,
51
+ initialOptions: {
52
+ outdir: tmpDir,
53
+ write: false,
54
+ ...overrides,
55
+ },
56
+ } as unknown as esbuild.PluginBuild;
57
+
58
+ return { build, transformSyncCalls };
59
+ }
60
+
31
61
  describe("transformWorkerPatterns — 패턴 감지", () => {
32
62
  it("Worker 패턴이 없는 content에 대해 undefined를 반환한다", () => {
33
63
  const result = transformWorkerPatterns(
@@ -59,6 +89,27 @@ describe("transformWorkerPatterns — 패턴 감지", () => {
59
89
  expect(result).toBeUndefined();
60
90
  });
61
91
 
92
+ it("주석 내 Worker 패턴은 무시한다", () => {
93
+ const result = transformWorkerPatterns(
94
+ `// new Worker(new URL("./worker.js", import.meta.url))
95
+ const x = 1;`,
96
+ "/test/entry.js",
97
+ createMockBuild(),
98
+ );
99
+
100
+ expect(result).toBeUndefined();
101
+ });
102
+
103
+ it("문자열 리터럴 내 Worker 패턴은 무시한다", () => {
104
+ const result = transformWorkerPatterns(
105
+ `const s = "new Worker(new URL(\\"./worker.js\\", import.meta.url))";`,
106
+ "/test/entry.js",
107
+ createMockBuild(),
108
+ );
109
+
110
+ expect(result).toBeUndefined();
111
+ });
112
+
62
113
  it("Worker + new URL + import.meta.url 패턴을 감지하여 치환한다", () => {
63
114
  const entryPath = path.join(fixturesDir, "entry.js");
64
115
 
@@ -295,3 +346,498 @@ describe("createWorkerBundlePlugin — 플러그인 구조", () => {
295
346
  expect(hasOnEnd).toBe(true);
296
347
  });
297
348
  });
349
+
350
+ describe("findWorkerPatterns — AST 기반 패턴 탐지", () => {
351
+ it("Worker + new URL + import.meta.url 패턴을 탐지한다", () => {
352
+ const matches = findWorkerPatterns(
353
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
354
+ );
355
+
356
+ expect(matches).toHaveLength(1);
357
+ expect(matches[0].type).toBe("browser");
358
+ expect(matches[0].urlPath).toBe("./worker.js");
359
+ expect(matches[0].workerType).toBe("Worker");
360
+ expect(matches[0].existingOpts).toBeUndefined();
361
+ });
362
+
363
+ it("SharedWorker 패턴을 탐지한다", () => {
364
+ const matches = findWorkerPatterns(
365
+ `const sw = new SharedWorker(new URL("./sw.js", import.meta.url));`,
366
+ );
367
+
368
+ expect(matches).toHaveLength(1);
369
+ expect(matches[0].workerType).toBe("SharedWorker");
370
+ expect(matches[0].urlPath).toBe("./sw.js");
371
+ });
372
+
373
+ it("옵션 객체가 있는 Worker 패턴을 탐지하고 원본 텍스트를 보존한다", () => {
374
+ const content = `const w = new Worker(new URL("./w.js", import.meta.url), { type: "module" });`;
375
+ const matches = findWorkerPatterns(content);
376
+
377
+ expect(matches).toHaveLength(1);
378
+ expect(matches[0].existingOpts).toBe('{ type: "module" }');
379
+ });
380
+
381
+ it("import.meta.resolve 상대 경로 패턴을 탐지한다", () => {
382
+ const matches = findWorkerPatterns(
383
+ `const p = import.meta.resolve("./node-worker.js");`,
384
+ );
385
+
386
+ expect(matches).toHaveLength(1);
387
+ expect(matches[0].type).toBe("node");
388
+ expect(matches[0].urlPath).toBe("./node-worker.js");
389
+ });
390
+
391
+ it("import.meta.resolve 절대 모듈 경로는 무시한다", () => {
392
+ const matches = findWorkerPatterns(
393
+ `const p = import.meta.resolve("some-package");`,
394
+ );
395
+
396
+ expect(matches).toHaveLength(0);
397
+ });
398
+
399
+ it("new URL 없는 Worker는 무시한다", () => {
400
+ const matches = findWorkerPatterns(
401
+ `const w = new Worker("./worker.js");`,
402
+ );
403
+
404
+ expect(matches).toHaveLength(0);
405
+ });
406
+
407
+ it("import.meta.url이 아닌 URL 생성은 무시한다", () => {
408
+ const matches = findWorkerPatterns(
409
+ `const w = new Worker(new URL("./w.js", location.href));`,
410
+ );
411
+
412
+ expect(matches).toHaveLength(0);
413
+ });
414
+
415
+ it("복수 패턴을 모두 탐지한다", () => {
416
+ const matches = findWorkerPatterns(
417
+ [
418
+ `const w1 = new Worker(new URL("./w1.js", import.meta.url));`,
419
+ `const w2 = new Worker(new URL("./w2.js", import.meta.url));`,
420
+ ].join("\n"),
421
+ );
422
+
423
+ expect(matches).toHaveLength(2);
424
+ expect(matches[0].urlPath).toBe("./w1.js");
425
+ expect(matches[1].urlPath).toBe("./w2.js");
426
+ });
427
+
428
+ it("start/end 위치가 정확하다", () => {
429
+ const content = `const w = new Worker(new URL("./w.js", import.meta.url));`;
430
+ const matches = findWorkerPatterns(content);
431
+
432
+ expect(matches).toHaveLength(1);
433
+ const matched = content.slice(matches[0].start, matches[0].end);
434
+ expect(matched).toBe(`new Worker(new URL("./w.js", import.meta.url))`);
435
+ });
436
+
437
+ it("주석 내 Worker 패턴은 무시한다", () => {
438
+ const matches = findWorkerPatterns(
439
+ `// new Worker(new URL("./w.js", import.meta.url))
440
+ const x = 1;`,
441
+ );
442
+
443
+ expect(matches).toHaveLength(0);
444
+ });
445
+
446
+ it("문자열 리터럴 내 Worker 패턴은 무시한다", () => {
447
+ const matches = findWorkerPatterns(
448
+ `const s = "new Worker(new URL(\\"./w.js\\", import.meta.url))";`,
449
+ );
450
+
451
+ expect(matches).toHaveLength(0);
452
+ });
453
+
454
+ it("블록 주석 내 Worker 패턴은 무시한다", () => {
455
+ const matches = findWorkerPatterns(
456
+ `/* new Worker(new URL("./w.js", import.meta.url)) */
457
+ const x = 1;`,
458
+ );
459
+
460
+ expect(matches).toHaveLength(0);
461
+ });
462
+
463
+ it("주석 내 import.meta.resolve는 무시한다", () => {
464
+ const matches = findWorkerPatterns(
465
+ `// import.meta.resolve("./w.js")
466
+ const x = 1;`,
467
+ );
468
+
469
+ expect(matches).toHaveLength(0);
470
+ });
471
+
472
+ it("파싱 불가능한 코드는 빈 배열을 반환한다", () => {
473
+ const matches = findWorkerPatterns(
474
+ `this is not valid javascript }{][`,
475
+ );
476
+
477
+ expect(matches).toHaveLength(0);
478
+ });
479
+ });
480
+
481
+ describe("transformWorkerPatterns — TypeScript 파일 처리", () => {
482
+ it("import type이 포함된 .ts 파일에서 Worker 패턴을 탐지하여 치환한다", () => {
483
+ const entryPath = path.join(fixturesDir, "entry.ts");
484
+ const result = transformWorkerPatterns(
485
+ `import type { T } from "pkg";
486
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
487
+ entryPath,
488
+ createMockBuild(),
489
+ );
490
+
491
+ expect(result).toBeDefined();
492
+ expect(result!.contents).not.toContain("./worker.js");
493
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
494
+ expect(result!.errors).toHaveLength(0);
495
+ });
496
+
497
+ it("import type이 포함된 .ts 파일에서 import.meta.resolve 패턴을 탐지하여 치환한다", () => {
498
+ const entryPath = path.join(fixturesDir, "entry.ts");
499
+ const result = transformWorkerPatterns(
500
+ `import type { T } from "pkg";
501
+ const p = import.meta.resolve("./node-worker.js");`,
502
+ entryPath,
503
+ createMockBuild({ platform: "node" }),
504
+ );
505
+
506
+ expect(result).toBeDefined();
507
+ expect(result!.contents).not.toContain("./node-worker.js");
508
+ expect(result!.contents).toMatch(
509
+ /new URL\("worker-[a-z0-9]+\.js", import\.meta\.url\)\.href/i,
510
+ );
511
+ expect(result!.errors).toHaveLength(0);
512
+ });
513
+
514
+ it("타입 어노테이션이 있는 변수 선언(const w: Worker = ...)에서 Worker를 탐지한다", () => {
515
+ const entryPath = path.join(fixturesDir, "entry.ts");
516
+ const result = transformWorkerPatterns(
517
+ `const w: Worker = new Worker(new URL("./worker.js", import.meta.url));`,
518
+ entryPath,
519
+ createMockBuild(),
520
+ );
521
+
522
+ expect(result).toBeDefined();
523
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
524
+ });
525
+
526
+ it(".mts 확장자의 TS 파일도 변환 후 처리한다", () => {
527
+ const entryPath = path.join(fixturesDir, "entry.mts");
528
+ const result = transformWorkerPatterns(
529
+ `import type { T } from "pkg";
530
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
531
+ entryPath,
532
+ createMockBuild(),
533
+ );
534
+
535
+ expect(result).toBeDefined();
536
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
537
+ });
538
+
539
+ it(".cts 확장자의 TS 파일도 변환 후 처리한다", () => {
540
+ const entryPath = path.join(fixturesDir, "entry.cts");
541
+ const result = transformWorkerPatterns(
542
+ `import type { T } from "pkg";
543
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
544
+ entryPath,
545
+ createMockBuild(),
546
+ );
547
+
548
+ expect(result).toBeDefined();
549
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
550
+ });
551
+
552
+ it("TS 파일의 주석 내 Worker 패턴은 무시한다", () => {
553
+ const entryPath = path.join(fixturesDir, "entry.ts");
554
+ const result = transformWorkerPatterns(
555
+ `import type { T } from "pkg";
556
+ // new Worker(new URL("./worker.js", import.meta.url))
557
+ const x: number = 1;`,
558
+ entryPath,
559
+ createMockBuild(),
560
+ );
561
+
562
+ expect(result).toBeUndefined();
563
+ });
564
+
565
+ it("TS 파일의 문자열 리터럴 내 Worker 패턴은 무시한다", () => {
566
+ const entryPath = path.join(fixturesDir, "entry.ts");
567
+ const result = transformWorkerPatterns(
568
+ `import type { T } from "pkg";
569
+ const s: string = "new Worker(new URL(\\"./worker.js\\", import.meta.url))";`,
570
+ entryPath,
571
+ createMockBuild(),
572
+ );
573
+
574
+ expect(result).toBeUndefined();
575
+ });
576
+
577
+ it("사전 필터를 통과하지 못하는 TS 파일(Worker 키워드 없음)은 undefined를 반환한다", () => {
578
+ const entryPath = path.join(fixturesDir, "entry.ts");
579
+ const result = transformWorkerPatterns(
580
+ `import type { T } from "pkg";
581
+ const x: number = 1;`,
582
+ entryPath,
583
+ createMockBuild(),
584
+ );
585
+
586
+ expect(result).toBeUndefined();
587
+ });
588
+
589
+ it("TS 변환 실패(문법 오류) 시 errors에 에러를 포함하여 반환한다", () => {
590
+ const entryPath = path.join(fixturesDir, "entry.ts");
591
+ const result = transformWorkerPatterns(
592
+ `const w = new Worker(new URL("./worker.js", import.meta.url)); const x: =`,
593
+ entryPath,
594
+ createMockBuild(),
595
+ );
596
+
597
+ expect(result).toBeDefined();
598
+ expect(result!.errors.length).toBeGreaterThan(0);
599
+ });
600
+ });
601
+
602
+ describe("createWorkerBundlePlugin — TS 파일 onLoad 반환", () => {
603
+ it("TS 파일에서 Worker 감지 시 loader로 'js'를 반환한다", async () => {
604
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "worker-onload-"));
605
+ const tsFile = path.join(tmpDir, "entry.ts");
606
+ fs.copyFileSync(
607
+ path.join(fixturesDir, "worker.js"),
608
+ path.join(tmpDir, "worker.js"),
609
+ );
610
+ fs.writeFileSync(
611
+ tsFile,
612
+ `import type { T } from "pkg";
613
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
614
+ );
615
+
616
+ const plugin = createWorkerBundlePlugin();
617
+
618
+ let onLoadCallback: ((args: { path: string }) => Promise<any> | any) | null = null;
619
+ const mockBuild = {
620
+ esbuild,
621
+ initialOptions: { outdir: tmpDir, write: false },
622
+ onLoad: (_filter: unknown, cb: (args: { path: string }) => Promise<any> | any) => {
623
+ onLoadCallback = cb;
624
+ },
625
+ onEnd: () => { /* noop */ },
626
+ } as unknown as esbuild.PluginBuild;
627
+
628
+ await plugin.setup(mockBuild);
629
+ expect(onLoadCallback).not.toBeNull();
630
+
631
+ const result = await onLoadCallback!({ path: tsFile });
632
+ expect(result).toBeDefined();
633
+ expect(result.loader).toBe("js");
634
+ expect(result.contents).toMatch(/worker-[a-z0-9]+\.js/i);
635
+ });
636
+ });
637
+
638
+ describe("transformWorkerPatterns — skipTsTransform 옵션", () => {
639
+ it("skipTsTransform: true + .ts 경로 + JS content → transformSync 호출 없이 정상 치환", () => {
640
+ const entryPath = path.join(fixturesDir, "entry.ts");
641
+ const { build, transformSyncCalls } = createTrackedBuild();
642
+
643
+ // ngtsc emit 결과를 흉내: 파일 경로는 .ts이지만 content는 이미 JS
644
+ const result = transformWorkerPatterns(
645
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
646
+ entryPath,
647
+ build,
648
+ { skipTsTransform: true },
649
+ );
650
+
651
+ expect(transformSyncCalls).toHaveLength(0);
652
+ expect(result).toBeDefined();
653
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
654
+ expect(result!.errors).toHaveLength(0);
655
+ });
656
+
657
+ it("skipTsTransform: true + .ts 경로 + 실제 TS 구문 → 계약 위반으로 undefined (조용한 누락)", () => {
658
+ const entryPath = path.join(fixturesDir, "entry.ts");
659
+ const { build, transformSyncCalls } = createTrackedBuild();
660
+
661
+ // 호출자가 계약을 위반하여 실제 TS 구문을 넘긴 경우:
662
+ // transformSync가 스킵되므로 acorn이 TS 구문 파싱 실패 → 빈 matches → undefined
663
+ const result = transformWorkerPatterns(
664
+ `import type { T } from "pkg";
665
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
666
+ entryPath,
667
+ build,
668
+ { skipTsTransform: true },
669
+ );
670
+
671
+ expect(transformSyncCalls).toHaveLength(0);
672
+ expect(result).toBeUndefined();
673
+ });
674
+
675
+ it("옵션 생략 + .ts + import type + Worker → transformSync 호출 후 정상 감지 (후방 호환)", () => {
676
+ const entryPath = path.join(fixturesDir, "entry.ts");
677
+ const { build, transformSyncCalls } = createTrackedBuild();
678
+
679
+ const result = transformWorkerPatterns(
680
+ `import type { T } from "pkg";
681
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
682
+ entryPath,
683
+ build,
684
+ );
685
+
686
+ expect(transformSyncCalls).toHaveLength(1);
687
+ expect(transformSyncCalls[0][1]).toMatchObject({ loader: "ts" });
688
+ expect(result).toBeDefined();
689
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
690
+ });
691
+
692
+ it("옵션 { skipTsTransform: false } → 기본 동작 (transformSync 호출)", () => {
693
+ const entryPath = path.join(fixturesDir, "entry.ts");
694
+ const { build, transformSyncCalls } = createTrackedBuild();
695
+
696
+ const result = transformWorkerPatterns(
697
+ `import type { T } from "pkg";
698
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
699
+ entryPath,
700
+ build,
701
+ { skipTsTransform: false },
702
+ );
703
+
704
+ expect(transformSyncCalls).toHaveLength(1);
705
+ expect(result).toBeDefined();
706
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
707
+ });
708
+
709
+ it("빈 객체 {} → skipTsTransform undefined → 기본 동작 (transformSync 호출)", () => {
710
+ const entryPath = path.join(fixturesDir, "entry.ts");
711
+ const { build, transformSyncCalls } = createTrackedBuild();
712
+
713
+ const result = transformWorkerPatterns(
714
+ `import type { T } from "pkg";
715
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
716
+ entryPath,
717
+ build,
718
+ {},
719
+ );
720
+
721
+ expect(transformSyncCalls).toHaveLength(1);
722
+ expect(result).toBeDefined();
723
+ });
724
+
725
+ it("skipTsTransform: true + .js 경로 → 동작 동일 (확장자 분기 진입 안 함)", () => {
726
+ const entryPath = path.join(fixturesDir, "entry.js");
727
+ const { build, transformSyncCalls } = createTrackedBuild();
728
+
729
+ const result = transformWorkerPatterns(
730
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
731
+ entryPath,
732
+ build,
733
+ { skipTsTransform: true },
734
+ );
735
+
736
+ expect(transformSyncCalls).toHaveLength(0);
737
+ expect(result).toBeDefined();
738
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
739
+ });
740
+ });
741
+
742
+ describe("transformWorkerPatterns — 사전 필터 정규식 경계", () => {
743
+ it("타입 어노테이션만 등장한 Worker 키워드 → 사전 필터 차단", () => {
744
+ const { build, transformSyncCalls } = createTrackedBuild();
745
+
746
+ const result = transformWorkerPatterns(
747
+ `const x: Worker = 1 as any;`,
748
+ path.join(fixturesDir, "entry.ts"),
749
+ build,
750
+ );
751
+
752
+ expect(transformSyncCalls).toHaveLength(0);
753
+ expect(result).toBeUndefined();
754
+ });
755
+
756
+ it("interface 선언에만 등장한 Worker → 사전 필터 차단", () => {
757
+ const { build, transformSyncCalls } = createTrackedBuild();
758
+
759
+ const result = transformWorkerPatterns(
760
+ `interface WorkerLike { run(): void; }`,
761
+ path.join(fixturesDir, "entry.ts"),
762
+ build,
763
+ );
764
+
765
+ expect(transformSyncCalls).toHaveLength(0);
766
+ expect(result).toBeUndefined();
767
+ });
768
+
769
+ it("import type에만 등장한 Worker → 사전 필터 차단", () => {
770
+ const { build, transformSyncCalls } = createTrackedBuild();
771
+
772
+ const result = transformWorkerPatterns(
773
+ `import type { Worker } from "./types";`,
774
+ path.join(fixturesDir, "entry.ts"),
775
+ build,
776
+ );
777
+
778
+ expect(transformSyncCalls).toHaveLength(0);
779
+ expect(result).toBeUndefined();
780
+ });
781
+
782
+ it("new Worker 호출 → 사전 필터 통과, AST 감지", () => {
783
+ const entryPath = path.join(fixturesDir, "entry.js");
784
+ const result = transformWorkerPatterns(
785
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
786
+ entryPath,
787
+ createMockBuild(),
788
+ );
789
+
790
+ expect(result).toBeDefined();
791
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
792
+ });
793
+
794
+ it("new SharedWorker 호출 → 사전 필터 통과, AST 감지", () => {
795
+ const entryPath = path.join(fixturesDir, "entry.js");
796
+ const result = transformWorkerPatterns(
797
+ `const sw = new SharedWorker(new URL("./shared-worker.js", import.meta.url));`,
798
+ entryPath,
799
+ createMockBuild(),
800
+ );
801
+
802
+ expect(result).toBeDefined();
803
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
804
+ });
805
+
806
+ it("import.meta.resolve 호출 → 사전 필터 통과, AST 감지", () => {
807
+ const entryPath = path.join(fixturesDir, "entry.js");
808
+ const result = transformWorkerPatterns(
809
+ `const p = import.meta.resolve("./node-worker.js");`,
810
+ entryPath,
811
+ createMockBuild({ platform: "node" }),
812
+ );
813
+
814
+ expect(result).toBeDefined();
815
+ expect(result!.contents).toMatch(
816
+ /new URL\("worker-[a-z0-9]+\.js", import\.meta\.url\)\.href/i,
817
+ );
818
+ });
819
+
820
+ it("new Worker (연속 공백) → 사전 필터 통과", () => {
821
+ const entryPath = path.join(fixturesDir, "entry.js");
822
+ const result = transformWorkerPatterns(
823
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
824
+ entryPath,
825
+ createMockBuild(),
826
+ );
827
+
828
+ expect(result).toBeDefined();
829
+ });
830
+
831
+ it("WorkerSubClass 식별자 (단어 경계 위반) → 사전 필터 차단", () => {
832
+ const { build, transformSyncCalls } = createTrackedBuild();
833
+
834
+ const result = transformWorkerPatterns(
835
+ `class WorkerSubClass {}\nconst x = new WorkerSubClass();`,
836
+ path.join(fixturesDir, "entry.ts"),
837
+ build,
838
+ );
839
+
840
+ expect(transformSyncCalls).toHaveLength(0);
841
+ expect(result).toBeUndefined();
842
+ });
843
+ });