1import { expect, test } from "vitest";
2import * as inc from "../incremental.ts";
3
4test("finds node.js builtin module", async () => {
5 const fs = virtualFs({});
6
7 expect(inc.resolve({ specifier: "node:fs", resolveDir: "/", fs })).toEqual({
8 type: "success",
9 namespace: "node",
10 subPath: "fs",
11 files: [],
12 });
13 expect(inc.resolve({ specifier: "fs", resolveDir: "/", fs })).toEqual({
14 type: "success",
15 namespace: "node",
16 subPath: "fs",
17 files: [],
18 });
19 expect(inc.resolve({ specifier: "util/types", resolveDir: "/", fs })).toEqual({
20 type: "success",
21 namespace: "node",
22 subPath: "util/types",
23 files: [],
24 });
25 expect(inc.resolve({ specifier: "node:test", resolveDir: "/", fs })).toEqual({
26 type: "success",
27 namespace: "node",
28 subPath: "test",
29 files: [],
30 });
31 expect(inc.resolve({ specifier: "test", resolveDir: "/", fs })).toEqual({
32 type: "error",
33 error: expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }),
34 files: [
35 "/package.json",
36 "/node_modules/test",
37 ],
38 });
39});
40
41test("relative file path imports", async () => {
42 const fs = virtualFs({
43 "/src/a.js": "",
44 "/src/b.ts": "",
45 "/src/shared/util.js": "",
46 "/src/feature/main.js": "",
47 "/src/node_modules/a/index.js": "",
48 });
49
50 expect(inc.resolve({ specifier: "./a.js", resolveDir: "/src", fs })).toEqual({
51 type: "success",
52 namespace: "file",
53 subPath: "/src/a.js",
54 files: ["/src/a.js"],
55 });
56
57 expect(inc.resolve({ specifier: "./c.ts", resolveDir: "/src", fs })).toEqual({
58 type: "error",
59 error: expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }),
60 files: ["/src/c.ts"],
61 });
62
63 expect(
64 inc.resolve({ specifier: "../shared/util.js", resolveDir: "/src/feature", fs }),
65 ).toEqual({
66 type: "success",
67 namespace: "file",
68 subPath: "/src/shared/util.js",
69 files: ["/src/shared/util.js"],
70 });
71
72 expect(inc.resolve({ specifier: "./a", resolveDir: "/src", fs })).toEqual({
73 type: "error",
74 error: expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }),
75 files: ["/src/a"],
76 });
77 expect(inc.resolve({ specifier: "./node_modules/a", resolveDir: "/src", fs })).toEqual({
78 type: "error",
79 error: expect.objectContaining({ code: "ERR_UNSUPPORTED_DIR_IMPORT" }),
80 files: ["/src/node_modules/a"],
81 });
82 expect(inc.resolve({ specifier: "./node_modules/a/index", resolveDir: "/src", fs })).toEqual({
83 type: "error",
84 error: expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }),
85 files: ["/src/node_modules/a/index"],
86 });
87 expect(inc.resolve({ specifier: "./node_modules/a/index.js", resolveDir: "/src", fs })).toEqual({
88 type: "success",
89 namespace: "file",
90 subPath: "/src/node_modules/a/index.js",
91 files: ["/src/node_modules/a/index.js"],
92 });
93});
94
95test("sloppy resolution", async () => {
96 const fs = virtualFs({
97 "/src/a.js": "",
98 "/src/dir/index.js": "",
99 });
100
101 expect(inc.resolve({
102 specifier: "./a",
103 resolveDir: "/src",
104 fs,
105 sloppy: true,
106 })).toEqual({
107 type: "success",
108 namespace: "file",
109 subPath: "/src/a.js",
110 files: ["/src/a", "/src/a.js"],
111 });
112
113 expect(inc.resolve({
114 specifier: "./dir",
115 resolveDir: "/src",
116 fs,
117 sloppy: true,
118 })).toEqual({
119 type: "success",
120 namespace: "file",
121 subPath: "/src/dir/index.js",
122 files: [
123 "/src/dir",
124 "/src/dir.js",
125 "/src/dir.json",
126 "/src/dir.node",
127 "/src/dir/package.json",
128 "/src/dir/index.js",
129 ],
130 });
131
132 expect(
133 inc.resolve({ specifier: "./dir", resolveDir: "/src", fs, sloppy: false }),
134 ).toEqual({
135 type: "error",
136 error: expect.objectContaining({ code: "ERR_UNSUPPORTED_DIR_IMPORT" }),
137 files: ["/src/dir"],
138 });
139});
140
141test("package resolution handles exports and imports maps", async () => {
142 const fs = virtualFs({
143 "/app/src/main.js": "",
144 "/app/package.json": JSON.stringify({
145 imports: {
146 "#src/*": "./src/*.js",
147 },
148 }),
149 "/app/src/feature.js": "export const feature = true;",
150 "/app/node_modules/pkg/package.json": JSON.stringify({
151 name: "pkg",
152 exports: "./dist/index.js",
153 }),
154 "/app/node_modules/pkg/dist/index.js": "export const value = 1;",
155 "/app/node_modules/pkg/dist/feature.js": "export const feature = true;",
156 "/app/node_modules/pkg-subpath/package.json": JSON.stringify({
157 name: "pkg-subpath",
158 exports: {
159 "./feature": "./dist/feature.js",
160 },
161 }),
162 "/app/node_modules/pkg-subpath/dist/feature.js": "export const feature = true;",
163 });
164
165 expect(inc.resolve({ specifier: "pkg", resolveDir: "/app/src", fs })).toEqual({
166 type: "success",
167 namespace: "file",
168 subPath: "/app/node_modules/pkg/dist/index.js",
169 files: [
170 "/app/src/package.json",
171 "/app/package.json",
172 "/app/src/node_modules/pkg",
173 "/app/node_modules/pkg",
174 "/app/node_modules/pkg/package.json",
175 "/app/node_modules/pkg/dist/index.js",
176 ],
177 });
178
179 expect(inc.resolve({ specifier: "pkg-subpath/feature", resolveDir: "/app/src", fs }))
180 .toEqual({
181 type: "success",
182 namespace: "file",
183 subPath: "/app/node_modules/pkg-subpath/dist/feature.js",
184 files: [
185 "/app/src/package.json",
186 "/app/package.json",
187 "/app/src/node_modules/pkg-subpath",
188 "/app/node_modules/pkg-subpath",
189 "/app/node_modules/pkg-subpath/package.json",
190 "/app/node_modules/pkg-subpath/dist/feature.js",
191 ],
192 });
193
194 expect(inc.resolve({
195 specifier: "#src/feature",
196 resolveDir: "/app/src",
197 fs,
198 })).toEqual({
199 type: "success",
200 namespace: "file",
201 subPath: "/app/src/feature.js",
202 files: [
203 "/app/src/package.json",
204 "/app/package.json",
205 "/app/src/feature.js",
206 ],
207 });
208});
209
210test("package resolution reports export and import errors and supports sloppy main fallback", async () => {
211 const fs = virtualFs({
212 "/app/package.json": JSON.stringify({
213 imports: {
214 "#src/*": "./src/*.js",
215 },
216 }),
217 "/app/node_modules/pkg/package.json": JSON.stringify({
218 name: "pkg",
219 exports: {
220 "./feature": "./dist/feature.js",
221 },
222 }),
223 "/app/node_modules/pkg-sloppy/package.json": JSON.stringify({
224 name: "pkg-sloppy",
225 main: "./dist/index",
226 }),
227 "/app/node_modules/pkg-sloppy/dist/index.js": "export const value = 1;",
228 "/app/node_modules/pkg-legacy-main/package.json": JSON.stringify({
229 name: "pkg-legacy-main",
230 main: "lib/main.js",
231 }),
232 "/app/node_modules/pkg-legacy-main/lib/main.js": "module.exports = {};",
233 "/app/node_modules/pkg-legacy-module/package.json": JSON.stringify({
234 name: "pkg-legacy-module",
235 module: "lib/module.js",
236 }),
237 "/app/node_modules/pkg-legacy-module/lib/module.js": "module.exports = {};",
238 });
239
240 expect(inc.resolve({ specifier: "pkg/missing", resolveDir: "/app/src", fs }))
241 .toEqual({
242 type: "error",
243 error: expect.objectContaining({ code: "ERR_PACKAGE_PATH_NOT_EXPORTED" }),
244 files: [
245 "/app/src/package.json",
246 "/app/package.json",
247 "/app/src/node_modules/pkg",
248 "/app/node_modules/pkg",
249 "/app/node_modules/pkg/package.json",
250 ],
251 });
252
253 expect(inc.resolve({ specifier: "#missing", resolveDir: "/app/src", fs }))
254 .toEqual({
255 type: "error",
256 error: expect.objectContaining({ code: "ERR_PACKAGE_IMPORT_NOT_DEFINED" }),
257 files: ["/app/src/package.json", "/app/package.json"],
258 });
259
260 expect(inc.resolve({
261 specifier: "pkg-sloppy",
262 resolveDir: "/app/src",
263 fs,
264 sloppy: true,
265 })).toEqual({
266 type: "success",
267 namespace: "file",
268 subPath: "/app/node_modules/pkg-sloppy/dist/index.js",
269 files: [
270 "/app/src/package.json",
271 "/app/package.json",
272 "/app/src/node_modules/pkg-sloppy",
273 "/app/node_modules/pkg-sloppy",
274 "/app/node_modules/pkg-sloppy/package.json",
275 "/app/node_modules/pkg-sloppy/dist/index",
276 "/app/node_modules/pkg-sloppy/dist/index.js",
277 ],
278 });
279
280 expect(inc.resolve({
281 specifier: "pkg-legacy-main",
282 resolveDir: "/app/src",
283 fs,
284 sloppy: true,
285 })).toEqual({
286 type: "success",
287 namespace: "file",
288 subPath: "/app/node_modules/pkg-legacy-main/lib/main.js",
289 files: [
290 "/app/src/package.json",
291 "/app/package.json",
292 "/app/src/node_modules/pkg-legacy-main",
293 "/app/node_modules/pkg-legacy-main",
294 "/app/node_modules/pkg-legacy-main/package.json",
295 "/app/node_modules/pkg-legacy-main/lib/main.js",
296 ],
297 });
298
299 expect(inc.resolve({
300 specifier: "pkg-legacy-module",
301 resolveDir: "/app/src",
302 fs,
303 sloppy: true,
304 })).toEqual({
305 type: "error",
306 error: expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }),
307 files: [
308 "/app/src/package.json",
309 "/app/package.json",
310 "/app/src/node_modules/pkg-legacy-module",
311 "/app/node_modules/pkg-legacy-module",
312 "/app/node_modules/pkg-legacy-module/package.json",
313 "/app/node_modules/pkg-legacy-module/index.js",
314 "/app/node_modules/pkg-legacy-module/index.json",
315 "/app/node_modules/pkg-legacy-module/index.node",
316 ],
317 });
318});
319
320test("resolve reuses package.json cache for repeated imports lookups", async () => {
321 const fs = virtualFs({
322 "/app/package.json": JSON.stringify({
323 imports: {
324 "#dep": "./src/dep.js",
325 },
326 }),
327 "/app/src/dep.js": "export const dep = true;",
328 });
329 const packageJsonCache = new Map<string, inc.PackageMeta>();
330
331 expect(inc.resolve({
332 specifier: "#dep",
333 resolveDir: "/app",
334 fs,
335 packageJsonCache,
336 })).toMatchObject({
337 type: "success",
338 subPath: "/app/src/dep.js",
339 });
340 expect(fs.readCount("/app/package.json")).toBe(1);
341
342 expect(inc.resolve({
343 specifier: "#dep",
344 resolveDir: "/app",
345 fs,
346 packageJsonCache,
347 })).toMatchObject({
348 type: "success",
349 subPath: "/app/src/dep.js",
350 files: ["/app/package.json", "/app/src/dep.js"],
351 });
352 expect(fs.readCount("/app/package.json")).toBe(1);
353});
354
355test("resolve reuses package.json cache for repeated exports lookups", async () => {
356 const fs = virtualFs({
357 "/app/src/main.js": "",
358 "/app/node_modules/pkg/package.json": JSON.stringify({
359 name: "pkg",
360 exports: {
361 ".": "./dist/index.js",
362 },
363 }),
364 "/app/node_modules/pkg/dist/index.js": "export const value = true;",
365 });
366 const packageJsonCache = new Map<string, inc.PackageMeta>();
367
368 expect(inc.resolve({
369 specifier: "pkg",
370 resolveDir: "/app/src",
371 fs,
372 packageJsonCache,
373 })).toMatchObject({
374 type: "success",
375 subPath: "/app/node_modules/pkg/dist/index.js",
376 });
377 expect(fs.readCount("/app/node_modules/pkg/package.json")).toBe(1);
378
379 expect(inc.resolve({
380 specifier: "pkg",
381 resolveDir: "/app/src",
382 fs,
383 packageJsonCache,
384 })).toMatchObject({
385 type: "success",
386 subPath: "/app/node_modules/pkg/dist/index.js",
387 files: [
388 "/app/src/package.json",
389 "/app/package.json",
390 "/package.json",
391 "/app/src/node_modules/pkg",
392 "/app/node_modules/pkg",
393 "/app/node_modules/pkg/package.json",
394 "/app/node_modules/pkg/dist/index.js",
395 ],
396 });
397 expect(fs.readCount("/app/node_modules/pkg/package.json")).toBe(1);
398});
399
400test("package entry points resolve like node.js", () => {
401 const fs = virtualFs({
402 "/app/node_modules/sugar/package.json": JSON.stringify({ name: "sugar", exports: "./index.js" }),
403 "/app/node_modules/sugar/index.js": "",
404 "/app/node_modules/sugar/other.js": "",
405 "/app/node_modules/conditions/package.json": JSON.stringify({
406 name: "conditions",
407 exports: { import: "./i.mjs", default: "./d.js" },
408 }),
409 "/app/node_modules/conditions/i.mjs": "",
410 "/app/node_modules/conditions/sub.js": "",
411 "/app/node_modules/dual/package.json": JSON.stringify({ name: "dual", main: "./cjs.js", module: "./esm.mjs" }),
412 "/app/node_modules/dual/cjs.js": "",
413 "/app/node_modules/dual/esm.mjs": "",
414 "/app/node_modules/legacy/package.json": JSON.stringify({ name: "legacy", main: "lib/main" }),
415 "/app/node_modules/legacy/lib/main.js": "",
416 "/app/node_modules/no-package-json/index.js": "",
417 "/app/node_modules/mapped/package.json": JSON.stringify({ name: "mapped", imports: { "#dep": "sugar" } }),
418 "/app/a b/c.js": "",
419 });
420 const resolve = resolver(fs);
421 // a target without subpaths is only the package's main export
422 expect(resolve("sugar")).toBe("/app/node_modules/sugar/index.js");
423 expect(resolve("sugar/other.js")).toBe("ERR_PACKAGE_PATH_NOT_EXPORTED");
424 expect(resolve("conditions")).toBe("/app/node_modules/conditions/i.mjs");
425 expect(resolve("conditions/sub.js")).toBe("ERR_PACKAGE_PATH_NOT_EXPORTED");
426 // node.js reads `main`, which can leave off its extension, and never `module`
427 expect(resolve("dual")).toBe("/app/node_modules/dual/cjs.js");
428 expect(resolve("dual", "/app", true)).toBe("/app/node_modules/dual/cjs.js");
429 expect(resolve("legacy")).toBe("/app/node_modules/legacy/lib/main.js");
430 expect(resolve("no-package-json")).toBe("/app/node_modules/no-package-json/index.js");
431 // `imports` can map to another package
432 expect(resolve("#dep", "/app/node_modules/mapped")).toBe("/app/node_modules/sugar/index.js");
433 // es modules import urls
434 expect(resolve("./a%20b/c.js")).toBe("/app/a b/c.js");
435 expect(resolve("file:///app/a%20b/c.js")).toBe("/app/a b/c.js");
436});
437
438test("conditions are node.js's for import, or for require when sloppy", () => {
439 const fs = virtualFs({
440 "/app/node_modules/sync/package.json": JSON.stringify({
441 name: "sync",
442 exports: { "module-sync": "./sync.js", default: "./default.js" },
443 }),
444 "/app/node_modules/sync/sync.js": "",
445 "/app/node_modules/addons/package.json": JSON.stringify({
446 name: "addons",
447 exports: { "node-addons": "./addons.js", default: "./default.js" },
448 }),
449 "/app/node_modules/addons/addons.js": "",
450 "/app/node_modules/dual/package.json": JSON.stringify({
451 name: "dual",
452 exports: { import: "./import.js", require: "./require.js" },
453 }),
454 "/app/node_modules/dual/import.js": "",
455 "/app/node_modules/dual/require.js": "",
456 });
457 const resolve = resolver(fs);
458 expect(resolve("sync")).toBe("/app/node_modules/sync/sync.js");
459 expect(resolve("sync", "/app", true)).toBe("/app/node_modules/sync/sync.js");
460 expect(resolve("addons")).toBe("/app/node_modules/addons/addons.js");
461 expect(resolve("dual")).toBe("/app/node_modules/dual/import.js");
462 expect(resolve("dual", "/app", true)).toBe("/app/node_modules/dual/require.js");
463});
464
465test("package targets stay inside the package, and only imports map to packages", () => {
466 const fs = virtualFs({
467 "/secret.js": "",
468 "/app/secret.js": "",
469 "/app/node_modules/x.js": "",
470 "/app/package.json": JSON.stringify({
471 imports: { "#p/*": "./src/*", "#fs": "fs", "#up": "../secret.js", "#url": "file:///secret.js" },
472 }),
473 "/app/src/a.js": "",
474 "/app/node_modules/star/package.json": JSON.stringify({ name: "star", exports: { "./*": "./*" } }),
475 "/app/node_modules/star/a.js": "",
476 "/app/node_modules/up/package.json": JSON.stringify({ name: "up", exports: { ".": "./../../secret.js" } }),
477 "/app/node_modules/deps/package.json": JSON.stringify({ name: "deps", exports: { ".": "./NODE_MODULES/x.js" } }),
478 "/app/node_modules/deps/NODE_MODULES/x.js": "",
479 "/app/node_modules/hash/package.json": JSON.stringify({
480 name: "hash",
481 exports: { ".": "#x" },
482 imports: { "#x": "./x.js" },
483 }),
484 "/app/node_modules/hash/x.js": "",
485 });
486 const resolve = resolver(fs);
487 expect(resolve("star/a.js")).toBe("/app/node_modules/star/a.js");
488 expect(resolve("star/../../secret.js")).toBe("ERR_INVALID_MODULE_SPECIFIER");
489 expect(resolve("star/%2e%2e/%2E%2E/secret.js")).toBe("ERR_INVALID_MODULE_SPECIFIER");
490 expect(resolve("star/node_modules/x.js")).toBe("ERR_INVALID_MODULE_SPECIFIER");
491 expect(resolve("up")).toBe("ERR_INVALID_PACKAGE_TARGET");
492 expect(resolve("deps")).toBe("ERR_INVALID_PACKAGE_TARGET");
493 expect(resolve("hash")).toBe("ERR_INVALID_PACKAGE_TARGET");
494 expect(resolve("#p/a.js")).toBe("/app/src/a.js");
495 expect(resolve("#p/../secret.js")).toBe("ERR_INVALID_MODULE_SPECIFIER");
496 expect(resolve("#up")).toBe("ERR_INVALID_PACKAGE_TARGET");
497 expect(resolve("#url")).toBe("ERR_INVALID_PACKAGE_TARGET");
498 expect(inc.resolve({ specifier: "#fs", resolveDir: "/app", fs })).toMatchObject({ namespace: "node", subPath: "fs" });
499});
500
501test("package targets and subpaths are urls, which can be percent-encoded", () => {
502 const fs = virtualFs({
503 "/app/node_modules/mapped/package.json": JSON.stringify({
504 name: "mapped",
505 exports: { "./space": "./a%20b.js", "./*": "./*" },
506 }),
507 "/app/node_modules/mapped/a b.js": "",
508 "/app/node_modules/plain/a b.js": "",
509 "/app/node_modules/plain/a%20b.js": "",
510 });
511 const resolve = resolver(fs);
512 expect(resolve("mapped/space")).toBe("/app/node_modules/mapped/a b.js");
513 expect(resolve("mapped/a%20b.js")).toBe("/app/node_modules/mapped/a b.js");
514 expect(resolve("mapped/space", "/app", true)).toBe("/app/node_modules/mapped/a b.js");
515 expect(resolve("plain/a%20b.js")).toBe("/app/node_modules/plain/a b.js");
516 // like node.js, `require` only decodes what `exports` maps
517 expect(resolve("plain/a%20b.js", "/app", true)).toBe("/app/node_modules/plain/a%20b.js");
518});
519
520test("the longest pattern base wins, then the longest key, and keys have one `*`", () => {
521 const fs = virtualFs({
522 "/app/node_modules/b/package.json": JSON.stringify({
523 name: "b",
524 exports: { "./*": "./a/*", "./*.js": "./b/*.js" },
525 }),
526 "/app/node_modules/b/a/x.js": "",
527 "/app/node_modules/b/b/x.js": "",
528 "/app/node_modules/c/package.json": JSON.stringify({
529 name: "c",
530 exports: { "./a*": "./short/*", "./a*.js": "./long/*.js" },
531 }),
532 "/app/node_modules/c/short/b.js": "",
533 "/app/node_modules/c/long/b.js": "",
534 "/app/node_modules/stars/package.json": JSON.stringify({
535 name: "stars",
536 exports: { "./x/*/*": "./two/*", "./x/*": "./one/*" },
537 }),
538 "/app/node_modules/stars/one/1/*": "",
539 "/app/node_modules/stars/two/1": "",
540 });
541 const resolve = resolver(fs);
542 expect(resolve("b/x.js")).toBe("/app/node_modules/b/b/x.js");
543 expect(resolve("c/ab.js")).toBe("/app/node_modules/c/long/b.js");
544 expect(resolve("stars/x/1/*")).toBe("/app/node_modules/stars/one/1/*");
545});
546
547test("a package with exports imports itself by its name", () => {
548 const fs = virtualFs({
549 "/app/package.json": JSON.stringify({ name: "me", exports: { "./sub": "./sub.js" } }),
550 "/app/sub.js": "",
551 "/app/src/main.js": "",
552 "/other/package.json": JSON.stringify({ name: "other", main: "./main.js" }),
553 "/other/main.js": "",
554 });
555 const resolve = resolver(fs);
556 expect(resolve("me/sub", "/app/src")).toBe("/app/sub.js");
557 expect(resolve("me/sub", "/app/src", true)).toBe("/app/sub.js");
558 expect(resolve("me/main.js", "/app/src")).toBe("ERR_PACKAGE_PATH_NOT_EXPORTED");
559 expect(resolve("other", "/other")).toBe("ERR_MODULE_NOT_FOUND");
560});
561
562test("package mains and directory requires read only `main`, then the index", () => {
563 const fs = virtualFs({
564 "/app/node_modules/nested/package.json": JSON.stringify({ name: "nested", main: "./lib" }),
565 "/app/node_modules/nested/lib/package.json": JSON.stringify({ module: "./esm.js", exports: "./esm.js" }),
566 "/app/node_modules/nested/lib/esm.js": "",
567 "/app/node_modules/nested/lib/index.js": "",
568 "/app/node_modules/exact/package.json": JSON.stringify({ name: "exact", exports: { "./x": "./x" } }),
569 "/app/node_modules/exact/x.js": "",
570 "/app/dual/package.json": JSON.stringify({ main: "./cjs.js", module: "./esm.js" }),
571 "/app/dual/cjs.js": "",
572 "/app/dual/esm.js": "",
573 "/app/missing/package.json": JSON.stringify({ main: "./missing.js" }),
574 "/app/missing/index.js": "",
575 });
576 const resolve = resolver(fs);
577 expect(resolve("nested")).toBe("/app/node_modules/nested/lib/index.js");
578 expect(resolve("nested", "/app", true)).toBe("/app/node_modules/nested/lib/index.js");
579 expect(resolve("./dual", "/app", true)).toBe("/app/dual/cjs.js");
580 expect(resolve("./missing", "/app", true)).toBe("/app/missing/index.js");
581 // like node.js, `exports` targets are exact, even for `require`
582 expect(resolve("exact/x", "/app", true)).toBe("ERR_MODULE_NOT_FOUND");
583});
584
585/** the file or builtin a specifier resolves to, or the code of its error */
586function resolver(fs: inc.Fs) {
587 return (specifier: string, resolveDir = "/app", sloppy = false) => {
588 const result = inc.resolve({ specifier, resolveDir, fs, sloppy });
589 return result.subPath ?? (result.error as { code: string }).code;
590 };
591}
592
593function virtualFs(
594 files: Record<string, string>,
595): inc.Fs & { readCount(path: string): number } {
596 const fileMap = new Map(Object.entries(files));
597 const directories = new Set<string>(["/"]);
598 const readCounts = new Map<string, number>();
599 const handles = new Map<inc.FileHandle, string>();
600 let nextHandle = 1;
601
602 for (const file of fileMap.keys()) {
603 const parts = file.split("/").filter(Boolean);
604 let current = "";
605 for (let i = 0; i < parts.length - 1; i++) {
606 current += `/${parts[i]}`;
607 directories.add(current);
608 }
609 }
610
611 const bytesFor = (path: string) => new TextEncoder().encode(fileMap.get(path));
612 const error = (message: string, code: string) => Object.assign(new Error(message), { code });
613 const stat = (path: string): inc.Stat => {
614 const contents = fileMap.get(path);
615 if (contents !== undefined) {
616 return { kind: "file", size: new TextEncoder().encode(contents).byteLength, time: 0 };
617 }
618 if (directories.has(path)) return { kind: "directory", size: 0, time: 0 };
619 throw error(`ENOENT: no such file or directory, stat '${path}'`, "ENOENT");
620 };
621
622 return Object.assign(
623 new inc.Fs({
624 async open(path) {
625 if (!fileMap.has(path) && !directories.has(path)) {
626 throw error(`ENOENT: no such file, open '${path}'`, "ENOENT");
627 }
628 const handle = nextHandle++ as inc.FileHandle;
629 handles.set(handle, path);
630 return handle;
631 },
632 async statHandle(handle) {
633 const path = handles.get(handle);
634 if (!path) throw error("EBADF: bad file descriptor", "EBADF");
635 return stat(path);
636 },
637 async readHandle(handle, offset, length, destination) {
638 const path = handles.get(handle);
639 if (!path) throw error("EBADF: bad file descriptor", "EBADF");
640 if (directories.has(path)) {
641 throw error(`EISDIR: illegal operation on a directory, read`, "EISDIR");
642 }
643 const bytes = bytesFor(path);
644 const chunk = bytes.subarray(offset, offset + length);
645 destination.set(chunk);
646 return chunk.byteLength;
647 },
648 async closeHandle(handle) {
649 handles.delete(handle);
650 },
651 async listDir(path) {
652 if (!directories.has(path)) {
653 throw error(`ENOENT: no such directory, scandir '${path}'`, "ENOENT");
654 }
655 return [];
656 },
657 statSync: stat,
658 readFileSync(path) {
659 readCounts.set(path, (readCounts.get(path) ?? 0) + 1);
660 if (!fileMap.has(path)) throw error(`ENOENT: no such file, open '${path}'`, "ENOENT");
661 return bytesFor(path);
662 },
663 realPathSync: (path) => path,
664 }),
665 {
666 readCount(path: string): number {
667 return readCounts.get(path) ?? 0;
668 },
669 },
670 );
671}