@typed/virtual-modules 1.0.0-beta.1

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 (82) hide show
  1. package/README.md +135 -0
  2. package/dist/CompilerHostAdapter.d.ts +3 -0
  3. package/dist/CompilerHostAdapter.d.ts.map +1 -0
  4. package/dist/CompilerHostAdapter.js +160 -0
  5. package/dist/LanguageServiceAdapter.d.ts +3 -0
  6. package/dist/LanguageServiceAdapter.d.ts.map +1 -0
  7. package/dist/LanguageServiceAdapter.js +488 -0
  8. package/dist/LanguageServiceSession.d.ts +16 -0
  9. package/dist/LanguageServiceSession.d.ts.map +1 -0
  10. package/dist/LanguageServiceSession.js +122 -0
  11. package/dist/NodeModulePluginLoader.d.ts +8 -0
  12. package/dist/NodeModulePluginLoader.d.ts.map +1 -0
  13. package/dist/NodeModulePluginLoader.js +175 -0
  14. package/dist/PluginManager.d.ts +10 -0
  15. package/dist/PluginManager.d.ts.map +1 -0
  16. package/dist/PluginManager.js +151 -0
  17. package/dist/TypeInfoApi.d.ts +71 -0
  18. package/dist/TypeInfoApi.d.ts.map +1 -0
  19. package/dist/TypeInfoApi.js +855 -0
  20. package/dist/VmcConfigLoader.d.ts +31 -0
  21. package/dist/VmcConfigLoader.d.ts.map +1 -0
  22. package/dist/VmcConfigLoader.js +231 -0
  23. package/dist/VmcResolverLoader.d.ts +30 -0
  24. package/dist/VmcResolverLoader.d.ts.map +1 -0
  25. package/dist/VmcResolverLoader.js +71 -0
  26. package/dist/collectTypeTargetSpecs.d.ts +6 -0
  27. package/dist/collectTypeTargetSpecs.d.ts.map +1 -0
  28. package/dist/collectTypeTargetSpecs.js +19 -0
  29. package/dist/index.d.ts +13 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +12 -0
  32. package/dist/internal/VirtualRecordStore.d.ts +60 -0
  33. package/dist/internal/VirtualRecordStore.d.ts.map +1 -0
  34. package/dist/internal/VirtualRecordStore.js +199 -0
  35. package/dist/internal/materializeVirtualFile.d.ts +12 -0
  36. package/dist/internal/materializeVirtualFile.d.ts.map +1 -0
  37. package/dist/internal/materializeVirtualFile.js +28 -0
  38. package/dist/internal/path.d.ts +40 -0
  39. package/dist/internal/path.d.ts.map +1 -0
  40. package/dist/internal/path.js +71 -0
  41. package/dist/internal/sanitize.d.ts +6 -0
  42. package/dist/internal/sanitize.d.ts.map +1 -0
  43. package/dist/internal/sanitize.js +15 -0
  44. package/dist/internal/tsInternal.d.ts +65 -0
  45. package/dist/internal/tsInternal.d.ts.map +1 -0
  46. package/dist/internal/tsInternal.js +99 -0
  47. package/dist/internal/validation.d.ts +28 -0
  48. package/dist/internal/validation.d.ts.map +1 -0
  49. package/dist/internal/validation.js +66 -0
  50. package/dist/typeTargetBootstrap.d.ts +33 -0
  51. package/dist/typeTargetBootstrap.d.ts.map +1 -0
  52. package/dist/typeTargetBootstrap.js +57 -0
  53. package/dist/types.d.ts +405 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +15 -0
  56. package/package.json +38 -0
  57. package/src/CompilerHostAdapter.test.ts +180 -0
  58. package/src/CompilerHostAdapter.ts +316 -0
  59. package/src/LanguageServiceAdapter.test.ts +521 -0
  60. package/src/LanguageServiceAdapter.ts +631 -0
  61. package/src/LanguageServiceSession.ts +160 -0
  62. package/src/LanguageServiceTester.integration.test.ts +268 -0
  63. package/src/NodeModulePluginLoader.test.ts +170 -0
  64. package/src/NodeModulePluginLoader.ts +268 -0
  65. package/src/PluginManager.test.ts +178 -0
  66. package/src/PluginManager.ts +218 -0
  67. package/src/TypeInfoApi.test.ts +1211 -0
  68. package/src/TypeInfoApi.ts +1228 -0
  69. package/src/VmcConfigLoader.test.ts +108 -0
  70. package/src/VmcConfigLoader.ts +297 -0
  71. package/src/VmcResolverLoader.test.ts +181 -0
  72. package/src/VmcResolverLoader.ts +116 -0
  73. package/src/collectTypeTargetSpecs.ts +22 -0
  74. package/src/index.ts +35 -0
  75. package/src/internal/VirtualRecordStore.ts +304 -0
  76. package/src/internal/materializeVirtualFile.ts +38 -0
  77. package/src/internal/path.ts +106 -0
  78. package/src/internal/sanitize.ts +16 -0
  79. package/src/internal/tsInternal.ts +127 -0
  80. package/src/internal/validation.ts +85 -0
  81. package/src/typeTargetBootstrap.ts +75 -0
  82. package/src/types.ts +535 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Creates a CreateTypeInfoApiSession factory backed by a TypeScript Language Service.
3
+ * The program evolves over time as files change (via getModifiedTime versioning),
4
+ * so type-aware virtual module builds stay current during development.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import type * as ts from "typescript";
9
+ import type { CreateTypeInfoApiSession, TypeInfoApiSession, TypeTargetSpec } from "./types.js";
10
+ import { createTypeInfoApiSession } from "./TypeInfoApi.js";
11
+ import {
12
+ ensureTypeTargetBootstrapFile,
13
+ getProgramWithTypeTargetBootstrap,
14
+ getTypeTargetBootstrapPath,
15
+ } from "./typeTargetBootstrap.js";
16
+
17
+ function findTsconfig(fromDir: string): string | undefined {
18
+ let dir = resolve(fromDir);
19
+ const root = resolve(dir, "/");
20
+ while (dir !== root) {
21
+ const candidate = join(dir, "tsconfig.json");
22
+ if (existsSync(candidate)) return candidate;
23
+ const parent = dirname(dir);
24
+ if (parent === dir) break;
25
+ dir = parent;
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ export interface CreateLanguageServiceSessionFactoryOptions {
31
+ readonly ts: typeof import("typescript");
32
+ readonly projectRoot: string;
33
+ readonly typeTargetSpecs?: readonly TypeTargetSpec[];
34
+ readonly tsconfigPath?: string;
35
+ }
36
+
37
+ /**
38
+ * Creates a LanguageService-backed CreateTypeInfoApiSession factory.
39
+ * Use when you need type-aware virtual module resolution with a program that
40
+ * evolves as files change (e.g. Vite dev server, standalone tooling).
41
+ *
42
+ * Returns undefined if tsconfig cannot be found or parsed.
43
+ */
44
+ export function createLanguageServiceSessionFactory(
45
+ options: CreateLanguageServiceSessionFactoryOptions,
46
+ ): CreateTypeInfoApiSession | undefined {
47
+ const { ts, projectRoot, typeTargetSpecs, tsconfigPath: explicitTsconfigPath } = options;
48
+ const tsconfigPath = explicitTsconfigPath ?? findTsconfig(projectRoot);
49
+ if (!tsconfigPath) return undefined;
50
+
51
+ let parsed: ts.ParsedCommandLine;
52
+
53
+ try {
54
+ // oxlint-disable-next-line typescript/unbound-method
55
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
56
+ if (configFile.error) return undefined;
57
+ const configDir = dirname(tsconfigPath);
58
+ parsed = ts.parseJsonConfigFileContent(
59
+ configFile.config,
60
+ ts.sys,
61
+ configDir,
62
+ undefined,
63
+ tsconfigPath,
64
+ );
65
+ if (parsed.errors.length > 0) return undefined;
66
+ } catch {
67
+ return undefined;
68
+ }
69
+
70
+ let rootNames = parsed.fileNames;
71
+ if (typeTargetSpecs && typeTargetSpecs.length > 0) {
72
+ ensureTypeTargetBootstrapFile(projectRoot, typeTargetSpecs);
73
+ const bootstrapPath = getTypeTargetBootstrapPath(projectRoot);
74
+ rootNames = [...rootNames, bootstrapPath];
75
+ }
76
+
77
+ const sys = ts.sys;
78
+ const compilerOptions = parsed.options;
79
+
80
+ const host: ts.LanguageServiceHost = {
81
+ getCompilationSettings: () => compilerOptions,
82
+ getScriptFileNames: () => rootNames,
83
+ getScriptVersion: (fileName: string) => {
84
+ const mtime = sys.getModifiedTime?.(fileName);
85
+ return mtime ? String(mtime.getTime()) : "0";
86
+ },
87
+ getScriptSnapshot: (fileName: string) => {
88
+ if (!sys.fileExists(fileName)) return undefined;
89
+ const content = sys.readFile(fileName);
90
+ return content !== undefined ? ts.ScriptSnapshot.fromString(content) : undefined;
91
+ },
92
+ getCurrentDirectory: () => dirname(tsconfigPath),
93
+ getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
94
+ directoryExists: sys.directoryExists?.bind(sys),
95
+ fileExists: sys.fileExists?.bind(sys),
96
+ readFile: sys.readFile?.bind(sys),
97
+ readDirectory: sys.readDirectory?.bind(sys),
98
+ };
99
+
100
+ const languageService = ts.createLanguageService(host);
101
+
102
+ const createTypeInfoApiSessionFn: CreateTypeInfoApiSession = ({
103
+ id: _id,
104
+ importer: _importer,
105
+ }) => {
106
+ let session: TypeInfoApiSession | null = null;
107
+ let apiUsed = false;
108
+
109
+ const getSession = (): TypeInfoApiSession => {
110
+ if (session) return session;
111
+
112
+ const program = languageService.getProgram();
113
+ if (!program) {
114
+ throw new Error(
115
+ "TypeInfo session creation failed: Program not yet available from Language Service. Retry when project is loaded.",
116
+ );
117
+ }
118
+
119
+ const programWithBootstrap = getProgramWithTypeTargetBootstrap(
120
+ ts,
121
+ program,
122
+ projectRoot,
123
+ typeTargetSpecs,
124
+ );
125
+
126
+ session = createTypeInfoApiSession({
127
+ ts,
128
+ program: programWithBootstrap,
129
+ ...(typeTargetSpecs && typeTargetSpecs.length > 0
130
+ ? { typeTargetSpecs, failWhenNoTargetsResolved: false }
131
+ : {}),
132
+ });
133
+ return session;
134
+ };
135
+
136
+ return {
137
+ api: {
138
+ file: (path, opts) => {
139
+ apiUsed = true;
140
+ return getSession().api.file(path, opts);
141
+ },
142
+ directory: (glob, opts) => {
143
+ apiUsed = true;
144
+ return getSession().api.directory(glob, opts);
145
+ },
146
+ resolveExport: (baseDir, filePath, exportName) => {
147
+ apiUsed = true;
148
+ return getSession().api.resolveExport(baseDir, filePath, exportName);
149
+ },
150
+ isAssignableTo: (node, targetId, projection) => {
151
+ apiUsed = true;
152
+ return getSession().api.isAssignableTo(node, targetId, projection);
153
+ },
154
+ },
155
+ consumeDependencies: () => (apiUsed ? getSession().consumeDependencies() : ([] as const)),
156
+ };
157
+ };
158
+
159
+ return createTypeInfoApiSessionFn;
160
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Integration test using @manuth/typescript-languageservice-tester.
3
+ * Validates that the LS tester harness runs against a real tsserver workspace.
4
+ * Full virtual-module resolution in tsserver would require a TypeScript plugin
5
+ * that attaches our adapter (TS-4 / TS-12); the in-memory test in LanguageServiceAdapter.test.ts
6
+ * covers resolution and diagnostics with ts.createLanguageService + adapter.
7
+ *
8
+ * Additional in-process integration tests validate load, build, and import of virtual
9
+ * modules with the real TypeScript language service and compiler (real temp dirs, real hosts).
10
+ */
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import ts from "typescript";
15
+ import { afterEach, describe, expect, it } from "vitest";
16
+ import { LanguageServiceTester } from "@manuth/typescript-languageservice-tester";
17
+ import { attachCompilerHostAdapter } from "./CompilerHostAdapter.js";
18
+ import { attachLanguageServiceAdapter } from "./LanguageServiceAdapter.js";
19
+ import { NodeModulePluginLoader } from "./NodeModulePluginLoader.js";
20
+ import { PluginManager } from "./PluginManager.js";
21
+
22
+ const tempDirs: string[] = [];
23
+
24
+ function createTempDir(): string {
25
+ const dir = mkdtempSync(join(tmpdir(), "typed-vm-lstester-"));
26
+ tempDirs.push(dir);
27
+ return dir;
28
+ }
29
+
30
+ const standardCompilerOptions: ts.CompilerOptions = {
31
+ strict: true,
32
+ noEmit: true,
33
+ target: ts.ScriptTarget.ESNext,
34
+ module: ts.ModuleKind.ESNext,
35
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
36
+ skipLibCheck: true,
37
+ };
38
+
39
+ function createProjectWithVirtualEntry(dir: string, entryContent: string): { entryPath: string } {
40
+ writeFileSync(
41
+ join(dir, "tsconfig.json"),
42
+ JSON.stringify({
43
+ compilerOptions: {
44
+ strict: true,
45
+ target: "ESNext",
46
+ module: "ESNext",
47
+ moduleResolution: "Bundler",
48
+ noEmit: true,
49
+ skipLibCheck: true,
50
+ },
51
+ include: ["*.ts"],
52
+ }),
53
+ "utf8",
54
+ );
55
+ const entryPath = join(dir, "entry.ts");
56
+ writeFileSync(entryPath, entryContent, "utf8");
57
+ return { entryPath };
58
+ }
59
+
60
+ afterEach(() => {
61
+ while (tempDirs.length > 0) {
62
+ const dir = tempDirs.pop();
63
+ if (dir) {
64
+ try {
65
+ rmSync(dir, { recursive: true, force: true });
66
+ } catch {
67
+ // ignore
68
+ }
69
+ }
70
+ }
71
+ });
72
+
73
+ describe("LanguageServiceTester integration", () => {
74
+ it("runs tsserver-backed analysis via LanguageServiceTester", { timeout: 60_000 }, async () => {
75
+ const dir = createTempDir();
76
+ writeFileSync(
77
+ join(dir, "tsconfig.json"),
78
+ JSON.stringify({
79
+ compilerOptions: {
80
+ strict: true,
81
+ target: "ESNext",
82
+ module: "ESNext",
83
+ noEmit: true,
84
+ skipLibCheck: true,
85
+ },
86
+ include: ["*.ts"],
87
+ }),
88
+ "utf8",
89
+ );
90
+ writeFileSync(join(dir, "index.ts"), "export const x = 1;\n", "utf8");
91
+
92
+ const tester = new LanguageServiceTester(dir);
93
+ try {
94
+ await tester.Install();
95
+ await tester.Configure();
96
+ const result = await tester.AnalyzeCode("export const y = 2;", "TS", "file.ts");
97
+ expect(result).toBeDefined();
98
+ expect(result.Diagnostics).toBeDefined();
99
+ expect(Array.isArray(result.Diagnostics)).toBe(true);
100
+ } finally {
101
+ await tester.Dispose();
102
+ }
103
+ });
104
+ });
105
+
106
+ describe("Virtual modules with real TypeScript", () => {
107
+ it(
108
+ "imports virtual module with real Language Service (host reads from disk)",
109
+ { timeout: 15_000 },
110
+ () => {
111
+ const dir = createTempDir();
112
+ const { entryPath } = createProjectWithVirtualEntry(
113
+ dir,
114
+ 'import type { Foo } from "virtual:foo";\nexport const value: Foo = { n: 1 };\n',
115
+ );
116
+
117
+ const host: ts.LanguageServiceHost = {
118
+ getCompilationSettings: () => standardCompilerOptions,
119
+ getScriptFileNames: () => [entryPath],
120
+ getScriptVersion: () => "1",
121
+ getScriptSnapshot: (fileName: string) => {
122
+ const content = ts.sys.readFile(fileName);
123
+ return content != null ? ts.ScriptSnapshot.fromString(content) : undefined;
124
+ },
125
+ getCurrentDirectory: () => dir,
126
+ getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
127
+ fileExists: (fileName) => ts.sys.fileExists(fileName),
128
+ readFile: (fileName) => ts.sys.readFile(fileName),
129
+ readDirectory: (...args: Parameters<typeof ts.sys.readDirectory>) =>
130
+ ts.sys.readDirectory(...args),
131
+ };
132
+
133
+ const manager = new PluginManager([
134
+ {
135
+ name: "virtual",
136
+ shouldResolve: (id) => id === "virtual:foo",
137
+ build: () => "export interface Foo { n: number }",
138
+ },
139
+ ]);
140
+
141
+ const languageService = ts.createLanguageService(host);
142
+ const adapter = attachLanguageServiceAdapter({
143
+ ts,
144
+ languageService,
145
+ languageServiceHost: host,
146
+ resolver: manager,
147
+ projectRoot: dir,
148
+ });
149
+
150
+ try {
151
+ const diagnostics = languageService.getSemanticDiagnostics(entryPath);
152
+ expect(diagnostics).toHaveLength(0);
153
+ const program = languageService.getProgram();
154
+ expect(program).toBeDefined();
155
+ expect(program!.getSourceFiles().some((sf) => sf.fileName.includes("__virtual_"))).toBe(
156
+ true,
157
+ );
158
+ } finally {
159
+ adapter.dispose();
160
+ }
161
+ },
162
+ );
163
+
164
+ it(
165
+ "builds program that imports virtual module with real CompilerHost",
166
+ { timeout: 15_000 },
167
+ () => {
168
+ const dir = createTempDir();
169
+ const { entryPath } = createProjectWithVirtualEntry(
170
+ dir,
171
+ 'import type { Foo } from "virtual:foo";\nexport const value: Foo = { n: 1 };\n',
172
+ );
173
+
174
+ const manager = new PluginManager([
175
+ {
176
+ name: "virtual",
177
+ shouldResolve: (id) => id === "virtual:foo",
178
+ build: () => "export interface Foo { n: number }",
179
+ },
180
+ ]);
181
+
182
+ const host = ts.createCompilerHost(standardCompilerOptions);
183
+ const adapter = attachCompilerHostAdapter({
184
+ ts,
185
+ compilerHost: host,
186
+ resolver: manager,
187
+ projectRoot: dir,
188
+ });
189
+
190
+ try {
191
+ const program = ts.createProgram([entryPath], standardCompilerOptions, host);
192
+ const diagnostics = ts.getPreEmitDiagnostics(program);
193
+ expect(diagnostics).toHaveLength(0);
194
+ expect(program.getSourceFiles().some((sf) => sf.fileName.includes("__virtual_"))).toBe(
195
+ true,
196
+ );
197
+ } finally {
198
+ adapter.dispose();
199
+ }
200
+ },
201
+ );
202
+
203
+ it(
204
+ "loads plugin from disk and resolves virtual module via Language Service",
205
+ { timeout: 15_000 },
206
+ () => {
207
+ const dir = createTempDir();
208
+ writeFileSync(
209
+ join(dir, "plugin.mjs"),
210
+ `export default {
211
+ name: "virtual",
212
+ shouldResolve: (id) => id === "virtual:foo",
213
+ build: () => "export interface Foo { n: number }"
214
+ };
215
+ `,
216
+ "utf8",
217
+ );
218
+ const { entryPath } = createProjectWithVirtualEntry(
219
+ dir,
220
+ 'import type { Foo } from "virtual:foo";\nexport const value: Foo = { n: 1 };\n',
221
+ );
222
+
223
+ const loader = new NodeModulePluginLoader();
224
+ const loadResult = loader.load({ specifier: "./plugin.mjs", baseDir: dir });
225
+ expect(loadResult.status).toBe("loaded");
226
+ if (loadResult.status !== "loaded") return;
227
+
228
+ const manager = new PluginManager([loadResult.plugin]);
229
+
230
+ const host: ts.LanguageServiceHost = {
231
+ getCompilationSettings: () => standardCompilerOptions,
232
+ getScriptFileNames: () => [entryPath],
233
+ getScriptVersion: () => "1",
234
+ getScriptSnapshot: (fileName: string) => {
235
+ const content = ts.sys.readFile(fileName);
236
+ return content != null ? ts.ScriptSnapshot.fromString(content) : undefined;
237
+ },
238
+ getCurrentDirectory: () => dir,
239
+ getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
240
+ fileExists: (fileName) => ts.sys.fileExists(fileName),
241
+ readFile: (fileName) => ts.sys.readFile(fileName),
242
+ readDirectory: (...args: Parameters<typeof ts.sys.readDirectory>) =>
243
+ ts.sys.readDirectory(...args),
244
+ };
245
+
246
+ const languageService = ts.createLanguageService(host);
247
+ const adapter = attachLanguageServiceAdapter({
248
+ ts,
249
+ languageService,
250
+ languageServiceHost: host,
251
+ resolver: manager,
252
+ projectRoot: dir,
253
+ });
254
+
255
+ try {
256
+ const diagnostics = languageService.getSemanticDiagnostics(entryPath);
257
+ expect(diagnostics).toHaveLength(0);
258
+ const program = languageService.getProgram();
259
+ expect(program).toBeDefined();
260
+ expect(program!.getSourceFiles().some((sf) => sf.fileName.includes("__virtual_"))).toBe(
261
+ true,
262
+ );
263
+ } finally {
264
+ adapter.dispose();
265
+ }
266
+ },
267
+ );
268
+ });
@@ -0,0 +1,170 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { NodeModulePluginLoader } from "./NodeModulePluginLoader.js";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ const createTempDir = (): string => {
10
+ const dir = mkdtempSync(join(tmpdir(), "typed-virtual-modules-"));
11
+ tempDirs.push(dir);
12
+ return dir;
13
+ };
14
+
15
+ afterEach(() => {
16
+ while (tempDirs.length > 0) {
17
+ const dir = tempDirs.pop();
18
+ if (dir) {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ }
21
+ }
22
+ });
23
+
24
+ describe("NodeModulePluginLoader", () => {
25
+ it("accepts preloaded plugin objects", () => {
26
+ const loader = new NodeModulePluginLoader();
27
+ const result = loader.load({
28
+ name: "preloaded",
29
+ shouldResolve: () => true,
30
+ build: () => "export const x = 1;",
31
+ });
32
+
33
+ expect(result.status).toBe("loaded");
34
+ if (result.status !== "loaded") return;
35
+ expect(result.plugin.name).toBe("preloaded");
36
+ });
37
+
38
+ it("loads an ESM module by explicit path", () => {
39
+ const dir = createTempDir();
40
+ const filePath = join(dir, "plugin.mjs");
41
+ writeFileSync(
42
+ filePath,
43
+ `export default { name: "esm", shouldResolve: () => true, build: () => "export const esm = true;" };`,
44
+ "utf8",
45
+ );
46
+
47
+ const loader = new NodeModulePluginLoader();
48
+ const result = loader.load({
49
+ specifier: "./plugin.mjs",
50
+ baseDir: dir,
51
+ });
52
+
53
+ expect(result.status).toBe("loaded");
54
+ if (result.status !== "loaded") return;
55
+ expect(result.plugin.name).toBe("esm");
56
+ expect(result.resolvedPath.endsWith("plugin.mjs")).toBe(true);
57
+ });
58
+
59
+ it("loads a synchronous ESM module with default export", () => {
60
+ const dir = createTempDir();
61
+ const filePath = join(dir, "plugin.mjs");
62
+ writeFileSync(
63
+ filePath,
64
+ `export default { name: "esm-default", shouldResolve: () => true, build: () => "export const esm = true;" };`,
65
+ "utf8",
66
+ );
67
+
68
+ const loader = new NodeModulePluginLoader();
69
+ const result = loader.load({
70
+ specifier: "./plugin.mjs",
71
+ baseDir: dir,
72
+ });
73
+
74
+ expect(result.status).toBe("loaded");
75
+ if (result.status !== "loaded") return;
76
+ expect(result.plugin.name).toBe("esm-default");
77
+ expect(result.resolvedPath.endsWith("plugin.mjs")).toBe(true);
78
+ });
79
+
80
+ it("loads a synchronous ESM module with named plugin export", () => {
81
+ const dir = createTempDir();
82
+ const filePath = join(dir, "plugin-named.mjs");
83
+ writeFileSync(
84
+ filePath,
85
+ `export const plugin = { name: "esm-named", shouldResolve: () => true, build: () => "export const esm = true;" };`,
86
+ "utf8",
87
+ );
88
+
89
+ const loader = new NodeModulePluginLoader();
90
+ const result = loader.load({
91
+ specifier: "./plugin-named.mjs",
92
+ baseDir: dir,
93
+ });
94
+
95
+ expect(result.status).toBe("loaded");
96
+ if (result.status !== "loaded") return;
97
+ expect(result.plugin.name).toBe("esm-named");
98
+ });
99
+
100
+ it("returns module-load-failed for async ESM (top-level await)", () => {
101
+ const dir = createTempDir();
102
+ const filePath = join(dir, "plugin-async.mjs");
103
+ writeFileSync(
104
+ filePath,
105
+ `await Promise.resolve(); export default { name: "esm-async", shouldResolve: () => true, build: () => "export const esm = true;" };`,
106
+ "utf8",
107
+ );
108
+
109
+ const loader = new NodeModulePluginLoader();
110
+ const result = loader.load({
111
+ specifier: "./plugin-async.mjs",
112
+ baseDir: dir,
113
+ });
114
+
115
+ expect(result.status).toBe("error");
116
+ if (result.status !== "error") return;
117
+ expect(result.code).toBe("module-load-failed");
118
+ expect(result.message.toLowerCase()).toContain("await");
119
+ });
120
+
121
+ it("returns structured error for invalid exports", () => {
122
+ const dir = createTempDir();
123
+ writeFileSync(join(dir, "bad.mjs"), `export default { nope: true };`, "utf8");
124
+
125
+ const loader = new NodeModulePluginLoader();
126
+ const result = loader.load({
127
+ specifier: "./bad.mjs",
128
+ baseDir: dir,
129
+ });
130
+
131
+ expect(result.status).toBe("error");
132
+ if (result.status !== "error") return;
133
+ expect(result.code).toBe("invalid-plugin-export");
134
+ });
135
+
136
+ it("returns invalid-request for empty baseDir", () => {
137
+ const dir = createTempDir();
138
+ writeFileSync(
139
+ join(dir, "p.mjs"),
140
+ `export default { name: "p", shouldResolve: () => true, build: () => "" };`,
141
+ "utf8",
142
+ );
143
+ const loader = new NodeModulePluginLoader();
144
+ const result = loader.load({ specifier: "./p.mjs", baseDir: "" });
145
+ expect(result.status).toBe("error");
146
+ if (result.status !== "error") return;
147
+ expect(result.code).toBe("invalid-request");
148
+ });
149
+
150
+ it("returns invalid-request for empty specifier", () => {
151
+ const dir = createTempDir();
152
+ const loader = new NodeModulePluginLoader();
153
+ const result = loader.load({ specifier: "", baseDir: dir });
154
+ expect(result.status).toBe("error");
155
+ if (result.status !== "error") return;
156
+ expect(result.code).toBe("invalid-request");
157
+ });
158
+
159
+ it("returns module-not-found for non-existent specifier", () => {
160
+ const dir = createTempDir();
161
+ const loader = new NodeModulePluginLoader();
162
+ const result = loader.load({
163
+ specifier: "./does-not-exist-12345.mjs",
164
+ baseDir: dir,
165
+ });
166
+ expect(result.status).toBe("error");
167
+ if (result.status !== "error") return;
168
+ expect(result.code).toBe("module-not-found");
169
+ });
170
+ });