1// ## loading
2test("loads a typescript module graph from a virtual filesystem", async () => {
3 const graph = new incremental.Graph({
4 db: null,
5 root: "/app",
6 fs: virtualFs({
7 "/app/package.json": JSON.stringify({ type: "module" }),
8 "/app/main.ts": [
9 "import { value } from './dep.ts';",
10 "export const total: number = value + 1;",
11 "export default total;",
12 ].join("\n"),
13 "/app/dep.ts": [
14 "export interface Thing { value: number }",
15 "export const value: number = 41;",
16 ].join("\n"),
17 }),
18 });
19 const mod = await graph.import<{ total: number; default: number }>("main.ts");
20 assert.equal(mod.total, 42);
21 assert.equal(mod.default, 42);
22});
23
24test("package imports resolve while linking", async () => {
25 const graph = new incremental.Graph({
26 db: null,
27 root: "/app",
28 fs: virtualFs({
29 "/app/package.json": JSON.stringify({ type: "module", imports: { "#dep": "./dep.ts" } }),
30 "/app/main.ts": "import { value } from '#dep'; export const loaded = value;",
31 "/app/dep.ts": "export const value: number = 7;",
32 }),
33 });
34 assert.equal((await graph.import<{ loaded: number }>("main.ts")).loaded, 7);
35});
36
37test("CommonJS loads through require, including .cts and .ts in a commonjs package", async () => {
38 await using p = await project({
39 "main.cjs": "module.exports = { value: 42, extra: true };",
40 "types.cts": "const value: number = 7; module.exports = { value };",
41 "legacy/package.json": JSON.stringify({ type: "commonjs" }),
42 "legacy/main.ts": "module.exports = { value: 9 };",
43 });
44 const graph = p.graph();
45 const cjs = await graph.import<{ default: object; value: number }>("main.cjs");
46 assert.deepEqual(cjs.default, { value: 42, extra: true });
47 assert.equal(cjs.value, 42);
48 assert.equal((await graph.import<{ value: number }>("types.cts")).value, 7);
49 assert.equal((await graph.import<{ value: number }>("legacy/main.ts")).value, 9);
50});
51
52test("ES modules import CommonJS default and named exports", async () => {
53 await using p = await project({
54 "package.json": JSON.stringify({ type: "module" }),
55 "main.ts": [
56 "import dep, { named } from './dep.cjs';",
57 "export const total = dep.named + named;",
58 ].join("\n"),
59 "dep.cjs": "module.exports = { named: 5, other: 1 };",
60 });
61 assert.equal((await p.graph().import<{ total: number }>("main.ts")).total, 10);
62});
63
64test("require handles json, builtins, and sloppy package resolution", async () => {
65 await using p = await project({
66 "package.json": JSON.stringify({ type: "commonjs" }),
67 "main.js": [
68 "const dep = require('./dep');",
69 "const dir = require('./dir');",
70 "const data = require('./data.json');",
71 "const pkg = require('pkg-sloppy');",
72 "const { sep } = require('node:path');",
73 "module.exports = { total: dep.value + dir.value + data.value + pkg.value, sep };",
74 ].join("\n"),
75 "dep.js": "module.exports = { value: 1 };",
76 "dir/index.js": "module.exports = { value: 2 };",
77 "data.json": JSON.stringify({ value: 3 }),
78 "node_modules/pkg-sloppy/package.json": JSON.stringify({ name: "pkg-sloppy", main: "./dist/index" }),
79 "node_modules/pkg-sloppy/dist/index.js": "module.exports = { value: 4 };",
80 });
81 const mod = await p.graph().import<{ default: { total: number; sep: string } }>("main.js");
82 assert.equal(mod.default.total, 10);
83 assert.equal(mod.default.sep, path.sep);
84});
85
86test("CommonJS cycles work through require's cache", async () => {
87 await using p = await project({
88 "a.cjs": [
89 "module.exports = { name: 'a' };",
90 "const b = require('./b.cjs');",
91 "module.exports.fromB = b.name;",
92 ].join("\n"),
93 "b.cjs": [
94 "module.exports = { name: 'b' };",
95 "const a = require('./a.cjs');",
96 "module.exports.fromA = a.name;",
97 ].join("\n"),
98 });
99 const graph = p.graph();
100 const a = await graph.import<{ default: object }>("a.cjs");
101 const b = await graph.import<{ default: object }>("b.cjs");
102 assert.deepEqual(a.default, { name: "a", fromB: "b" });
103 assert.deepEqual(b.default, { name: "b", fromA: "a" });
104});
105
106test(".js files without a type pick a format by their syntax, like node.js", async () => {
107 await using p = await project({
108 "esm.js": "export const format = 'esm';",
109 "cjs.js": "module.exports = { format: 'cjs' };",
110 });
111 const graph = p.graph();
112 assert.equal((await graph.import<{ format: string }>("esm.js")).format, "esm");
113 assert.equal((await graph.import<{ format: string }>("cjs.js")).format, "cjs");
114});
115
116test(".ts files without a type pick a format by their syntax, like node.js", async () => {
117 await using p = await project({
118 "esm.ts": "export const format: string = 'esm';",
119 "cjs.ts": "const format: string = 'cjs'; module.exports = { format };",
120 });
121 const graph = p.graph();
122 assert.equal((await graph.import<{ format: string }>("esm.ts")).format, "esm");
123 assert.equal((await graph.import<{ format: string }>("cjs.ts")).format, "cjs");
124});
125
126test("json imports need `type: \"json\"`, like node.js", async () => {
127 await using p = await project({
128 "package.json": JSON.stringify({ type: "module" }),
129 "good.ts": "import data from './data.json' with { type: 'json' }; export default data;",
130 "bad.ts": "import data from './data.json'; export default data;",
131 "data.json": JSON.stringify({ hello: "world" }),
132 });
133 const graph = p.graph();
134 assert.deepEqual((await graph.import<{ default: object }>("good.ts")).default, { hello: "world" });
135 await assert.rejects(graph.import("bad.ts"), { code: "ERR_IMPORT_ATTRIBUTE_MISSING" });
136});
137
138test("a syntax error fails the step that imports it, pointing at the file", async () => {
139 await using p = await project({
140 "package.json": JSON.stringify({ type: "module" }),
141 "broken.ts": "export const = 1;",
142 });
143 const error = await build(p.graph(), (ref) => ref.step((b) => b.import("broken.ts").then(() => "loaded")).read())
144 .catch((error) => error);
145 assert.ok(error instanceof incremental.BuildError);
146 const [syntax] = error.errors as Array<Error & { file: string; line: number; col: number }>;
147 assert.equal(syntax!.name, "SyntaxError");
148 assert.equal(syntax!.file, p.path("broken.ts"));
149 // node says where only in the stack it prints
150 assert.deepEqual([syntax!.line, syntax!.col], [1, 14]);
151});
152
153// ## loaders
154test("custom loaders compile other languages, and trackModule returns their data", async () => {
155 await using p = await project({
156 "package.json": JSON.stringify({ type: "module" }),
157 "main.ts": "import words from './words.txt'; import './style.css'; export default words;",
158 "words.txt": "hello world",
159 "style.css": "body { color: red }",
160 });
161 let compiles = 0;
162 const graph = new incremental.Graph({
163 db: ".clover/incremental.db",
164 root: p.dir,
165 loaders: {
166 ".txt": ({ source }) => {
167 compiles++;
168 return { format: "js", content: `export default ${JSON.stringify(source.split(" "))};` };
169 },
170 ".css": ({ source }) => ({ format: "serializable", content: null, data: { css: source } }),
171 },
172 });
173 const site = () =>
174 build(graph, (ref) =>
175 ref.step(async (b) => {
176 const { default: words } = await b.import<{ default: string[] }>("main.ts");
177 const css = (await b.trackModule("main.ts")).filter((node) => node.url.endsWith(".css"));
178 return { words, css: css.map((node) => node.data) };
179 }).read());
180 assert.deepEqual(await site(), { words: ["hello", "world"], css: [{ css: "body { color: red }" }] });
181 await site();
182 assert.equal(compiles, 1);
183
184 await p.write("style.css", "body { color: blue }");
185 assert.deepEqual(await site(), { words: ["hello", "world"], css: [{ css: "body { color: blue }" }] });
186 assert.equal(compiles, 1);
187});
188
189test("loaders can't replace the defaults", () => {
190 assert.throws(
191 () => new incremental.Graph({ db: null, loaders: { ".ts": () => ({ format: "cjs" }) } }),
192 /the \.ts loader can't be replaced/,
193 );
194});
195
196test("source maps from loaders point stack traces at the original file", async () => {
197 await using p = await project({
198 "package.json": JSON.stringify({ type: "module" }),
199 "page.tmpl": "line one",
200 });
201 const graph = new incremental.Graph({
202 db: null,
203 root: p.dir,
204 loaders: {
205 // the generated line maps to line 10, column 6 of the original
206 ".tmpl": () => ({
207 format: "js",
208 content: "export function render() { throw new Error('bad template'); }",
209 map: { version: 3, sources: ["page.tmpl"], names: [], mappings: "AASK" },
210 }),
211 },
212 });
213 const error = await build(graph, (ref) =>
214 ref.step(async (b) => {
215 const { render } = await b.import<{ render(): string }>("page.tmpl");
216 return render();
217 }).read()).catch((error) => error) as incremental.BuildError;
218 const [top] = UNWRAP(stack.parse(error.errors[0] as Error));
219 assert.equal(top!.file, url.pathToFileURL(p.path("page.tmpl")).href);
220 assert.equal(top!.line, 10);
221 assert.equal(top!.col, 6);
222});
223
224test("a project under a symlink loads each module once, from its real path", async () => {
225 await using p = await project({
226 "real/package.json": JSON.stringify({ type: "module" }),
227 "real/a.ts": "export const state = {};",
228 "real/b.ts": "export { state } from './a.ts';",
229 });
230 await fs.symlink(p.path("real"), p.path("link"));
231 const graph = p.graph({ root: p.path("link") });
232 assert.equal(graph.root, p.path("real"));
233 const { state } = await graph.import<{ state: object }>(p.path("link/a.ts"));
234 assert.equal((await graph.import<{ state: object }>("b.ts")).state, state);
235
236 // a missing module is looked for where it was asked for, so it loads once it is there
237 const site = () => build(graph, (ref) => ref.step(async (b) => (await b.import<{ v: number }>("c.ts")).v).read());
238 await assert.rejects(site(), incremental.BuildError);
239 await p.write("real/c.ts", "export const v = 1;");
240 assert.equal(await site(), 1);
241});
242
243// ## reloading
244test("changed code recreates every module instance", async () => {
245 await using p = await project({
246 "package.json": JSON.stringify({ type: "module" }),
247 "a.ts": "export const id = Math.random();",
248 "b.ts": "export const id = Math.random();",
249 });
250 const graph = p.graph();
251 const a = await graph.import<{ id: number }>("a.ts");
252 const b = await graph.import<{ id: number }>("b.ts");
253 assert.equal(await graph.import("a.ts"), a);
254 assert.equal(await graph.import("b.ts"), b);
255
256 await p.write("a.ts", "export const id = Math.random(); // edited");
257 assert.notEqual((await graph.import<{ id: number }>("a.ts")).id, a.id);
258 assert.notEqual((await graph.import<{ id: number }>("b.ts")).id, b.id);
259});
260
261test("a lazy step imports a module's code as it is now, even after it changed", async () => {
262 await using p = await project({
263 "package.json": JSON.stringify({ type: "module" }),
264 "m.ts": "export const v = 1;",
265 });
266 const page = (graph: incremental.Graph) => graph.lazy(async (b) => (await b.import<{ v: number }>("m.ts")).v).read();
267 const graph = p.graph();
268 assert.equal(await page(graph), 1);
269 await p.write("m.ts", "export const v = 2;");
270 assert.equal(await page(graph), 2);
271 // like after a restart
272 assert.equal(await page(p.graph()), 2);
273});
274
275test("a lazy step imports code as it is now, even code that loaded modules share", async () => {
276 await using p = await project({
277 "package.json": JSON.stringify({ type: "module" }),
278 "lib.ts": "export const v = 1;",
279 "a.ts": "export { v } from './lib.ts';",
280 "c.ts": "export { v } from './lib.ts';",
281 });
282 const page = (graph: incremental.Graph, file: string) =>
283 graph.lazy(async (b, file) => (await b.import<{ v: number }>(file)).v, file).read();
284 const graph = p.graph();
285 assert.equal(await page(graph, "a.ts"), 1);
286 await p.write("lib.ts", "export const v = 2;");
287 assert.equal(await page(graph, "c.ts"), 2);
288 assert.equal(await page(graph, "a.ts"), 2);
289});
290
291test("imports that each find the loaded code outdated discard it once", async () => {
292 const lib = (v: number) =>
293 [
294 `import { onDispose } from ${JSON.stringify(url.fileURLToPath(new URL("../incremental.ts", import.meta.url)))};`,
295 "onDispose(import.meta, () => { (globalThis as any).libDisposals = ((globalThis as any).libDisposals ?? 0) + 1; });",
296 `export const v = ${v};`,
297 ].join("\n");
298 await using p = await project({
299 "package.json": JSON.stringify({ type: "module" }),
300 "lib.ts": lib(1),
301 "a.ts": "export { v } from './lib.ts';",
302 "b.ts": "export { v } from './lib.ts';",
303 "c.ts": "export { v } from './lib.ts';",
304 });
305 const page = (graph: incremental.Graph, ...files: string[]) =>
306 graph.lazy(async (b, files) => {
307 const modules = await Promise.all(files.map((file) => b.import<{ v: number }>(file)));
308 return modules.map((module) => module.v);
309 }, files).read();
310 const graph = p.graph();
311 assert.deepEqual(await page(graph, "a.ts"), [1]);
312 await p.write("lib.ts", lib(2));
313 assert.deepEqual(await page(graph, "b.ts", "b.ts", "c.ts"), [2, 2, 2]);
314 assert.equal((globalThis as { libDisposals?: number }).libDisposals, 1);
315});
316
317test("a lazy step imports a module again once one of its imports resolves elsewhere", async () => {
318 const imports = (target: string) => JSON.stringify({ type: "module", imports: { "#dep": target } });
319 await using p = await project({
320 "package.json": imports("./one.ts"),
321 "a.ts": "export { v } from '#dep';",
322 "one.ts": "export const v = 1;",
323 "two.ts": "export const v = 2;",
324 });
325 const page = (graph: incremental.Graph) => graph.lazy(async (b) => (await b.import<{ v: number }>("a.ts")).v).read();
326 const graph = p.graph();
327 assert.equal(await page(graph), 1);
328 await p.write("package.json", imports("./two.ts"));
329 assert.equal(await page(graph), 2);
330});
331
332test("code from a generation the graph discarded runs steps it doesn't keep, and isn't pinned", async () => {
333 type Page = { render(graph: incremental.Graph): Promise<string> };
334 const page = (v: string) =>
335 `const v = '${v}'; export const render = (graph: any) => graph.lazy(async () => v).read();`;
336 await using p = await project({
337 "package.json": JSON.stringify({ type: "module" }),
338 "page.ts": page("one"),
339 "other.ts": "export {};",
340 });
341 const graph = p.graph();
342 const old = await graph.import<Page>("page.ts");
343 await p.write("page.ts", page("two"));
344 // discards every instance without making page.ts again
345 await graph.import("other.ts");
346 // like a server that runs its old code until the new code has loaded
347 assert.equal(await old.render(graph), "one");
348 assert.equal(await (await graph.import<Page>("page.ts")).render(graph), "two");
349 await p.write("page.ts", page("three"));
350 assert.equal(await (await graph.import<Page>("page.ts")).render(graph), "three");
351});
352
353test("dispose callbacks run when a module's generation is discarded", async () => {
354 await using p = await project({
355 "package.json": JSON.stringify({ type: "module" }),
356 "server.ts": [
357 `import { onDispose } from ${JSON.stringify(url.fileURLToPath(new URL("../incremental.ts", import.meta.url)))};`,
358 "const timer = setInterval(() => {}, 1000);",
359 "onDispose(import.meta, () => { clearInterval(timer); (globalThis as any).disposed = true; });",
360 ].join("\n"),
361 });
362 const graph = p.graph();
363 await graph.import("server.ts");
364 await p.write("server.ts.txt", "unrelated");
365 await graph.import("server.ts");
366 assert.equal((globalThis as { disposed?: boolean }).disposed, undefined);
367
368 await p.write("server.ts", (await fs.readFile(p.path("server.ts"), "utf-8")) + "\n// edited");
369 await graph.import("server.ts");
370 assert.equal((globalThis as { disposed?: boolean }).disposed, true);
371});
372
373test("editing a module node.js loaded requires a restart", async () => {
374 await using p = await project({
375 "package.json": JSON.stringify({ type: "module" }),
376 "native.ts": "export const x = 1;",
377 "main.ts": "export { x } from './native.ts';",
378 });
379 const graph = new incremental.Graph({
380 db: null,
381 root: p.dir,
382 external: (file) => file.endsWith("native.ts"),
383 });
384 assert.equal((await graph.import<{ x: number }>("main.ts")).x, 1);
385 await p.write("native.ts", "export const x = 2;");
386 await assert.rejects(graph.import("main.ts"), incremental.RestartRequired);
387});
388
389test("the main module keeps running, so changing it requires a restart", async () => {
390 await using p = await project({
391 "package.json": JSON.stringify({ type: "module" }),
392 "main.ts": "import './helper.ts'; export const main = import.meta.main;",
393 "helper.ts": "export {};",
394 "page.ts": "export const page = 1; export const main = import.meta.main;",
395 });
396 const graph = p.graph();
397 assert.equal((await graph.import<{ main: boolean }>("main.ts", { main: true })).main, true);
398 assert.deepEqual({ ...await graph.import<object>("page.ts") }, { page: 1, main: false });
399
400 await p.write("page.ts", "export const page = 2;");
401 assert.equal((await graph.import<{ page: number }>("page.ts")).page, 2);
402
403 await p.write("helper.ts", "export const changed = true;");
404 await assert.rejects(graph.import("page.ts"), incremental.RestartRequired);
405});
406
407test("a build runs the code it records, even when a file changes during it", async () => {
408 await using p = await project({
409 "package.json": JSON.stringify({ type: "module" }),
410 "page.ts": "import { v } from './lib.ts'; export default (n: number) => v + n;",
411 "lib.ts": "export const v = 'old';",
412 });
413 const graph = p.graph();
414 const render = (ref: incremental.Ref, n: number) =>
415 ref.step(async (b, n) => (await b.import<{ default(n: number): string }>("page.ts")).default(n), n).read();
416 assert.equal(await build(graph, (ref) => render(ref, 0)), "old0");
417 // the build already checked the loaded code when this edit lands
418 assert.equal(
419 await build(graph, async (ref) => {
420 await p.write("lib.ts", "export const v = 'new';");
421 return render(ref, 1);
422 }),
423 "old1",
424 );
425 assert.equal(await build(graph, (ref) => render(ref, 1)), "new1");
426 assert.equal(await build(p.graph(), (ref) => render(ref, 1)), "new1");
427});
428
429test("what a CommonJS module requires is an input of the steps that import it", async () => {
430 await using p = await project({
431 "package.json": JSON.stringify({ type: "module" }),
432 "a.cjs": "module.exports = { v: require('./b.cjs').v };",
433 "b.cjs": "exports.v = 'one';",
434 });
435 const site = (graph: incremental.Graph) =>
436 build(graph, (ref) => ref.step(async (b) => (await b.import<{ v: string }>("a.cjs")).v).read());
437 const graph = p.graph();
438 assert.equal(await site(graph), "one");
439 await p.write("b.cjs", "exports.v = 'two';");
440 assert.equal(await site(graph), "two");
441 assert.equal(await site(p.graph()), "two");
442});
443
444test("a CommonJS module that threw runs again once what it requires is fixed", async () => {
445 await using p = await project({
446 "main.cjs": "module.exports = { v: require('./child.cjs').v };",
447 "child.cjs": "throw new Error('broken child');",
448 });
449 const site = (graph: incremental.Graph) =>
450 build(graph, (ref) => ref.step(async (b) => (await b.import<{ v: string }>("main.cjs")).v).read());
451 const graph = p.graph();
452 await assert.rejects(site(graph), incremental.BuildError);
453 await p.write("child.cjs", "exports.v = 'fixed';");
454 assert.equal(await site(graph), "fixed");
455});
456
457test("a CommonJS module that threw because of what it required runs again once that is fixed", async () => {
458 await using p = await project({
459 "main.cjs": [
460 "const { v } = require('./config.cjs');",
461 "if (v === 'broken') throw new Error('broken config');",
462 "module.exports = { v };",
463 ].join("\n"),
464 "config.cjs": "exports.v = 'broken';",
465 });
466 const site = (graph: incremental.Graph) =>
467 build(graph, (ref) => ref.step(async (b) => (await b.import<{ v: string }>("main.cjs")).v).read());
468 const graph = p.graph();
469 await assert.rejects(site(graph), incremental.BuildError);
470 await p.write("config.cjs", "exports.v = 'fixed';");
471 assert.equal(await site(graph), "fixed");
472 assert.equal(await site(p.graph()), "fixed");
473});
474
475test("what an external module imports is loaded by node.js too, so it can't reload", async () => {
476 await using p = await project({
477 "package.json": JSON.stringify({ type: "module" }),
478 "external.ts": "import { v } from './helper.ts'; export const get = () => v;",
479 "helper.ts": "export const v = 'one';",
480 "page.ts": "import { get } from './external.ts'; export default () => get();",
481 });
482 const graph = p.graph({ external: (file) => file.endsWith("external.ts") });
483 assert.equal((await graph.import<{ default(): string }>("page.ts")).default(), "one");
484 await p.write("helper.ts", "export const v = 'two';");
485 await assert.rejects(graph.import("page.ts"), incremental.RestartRequired);
486});
487
488test("code that starts steps is pinned, even when every step is cached", async () => {
489 const lib = (file: string) => JSON.stringify(url.fileURLToPath(new URL(file, import.meta.url)));
490 await using p = await project({
491 "package.json": JSON.stringify({ type: "module" }),
492 "helper.ts": "export const shout = (s: string) => s + '!';",
493 "main.ts": [
494 `import * as incremental from ${lib("../incremental.ts")};`,
495 `import * as progress from ${lib("../progress.ts")};`,
496 "import * as fs from 'node:fs';",
497 "import { shout } from './helper.ts';",
498 "const graph = new incremental.Graph({ db: '.clover/incremental.db', root: import.meta.dirname });",
499 "const build = () => graph.incrementalBuild({",
500 " progress: progress.nullNode,",
501 " buildAndWriteOutput: (ref) => ref.step(async () => shout('hi')).read(),",
502 "});",
503 "console.log(await build());",
504 "if (process.argv[2] === 'edit') {",
505 " fs.writeFileSync(import.meta.dirname + '/helper.ts', `export const shout = (s: string) => s + '?';`);",
506 " console.log(await build().catch((error) => error.name));",
507 "}",
508 ].join("\n"),
509 });
510 const run = (...args: string[]) =>
511 child_process.execFileSync(
512 process.execPath,
513 ["--experimental-vm-modules", "--no-warnings", p.path("main.ts"), ...args],
514 { encoding: "utf-8" },
515 ).trim();
516 assert.equal(run(), "hi!");
517 assert.equal(run("edit"), "hi!\nRestartRequired");
518 assert.equal(run(), "hi?");
519});
520
521test("supervise runs main in a new process when it requires a restart", async () => {
522 await using p = await project({
523 "package.json": JSON.stringify({ type: "module" }),
524 "main.ts": [
525 `import * as incremental from ${
526 JSON.stringify(url.fileURLToPath(new URL("../incremental.ts", import.meta.url)))
527 };`,
528 "import * as fs from 'node:fs';",
529 "await incremental.supervise(async () => {",
530 " const marker = import.meta.dirname + '/restarted';",
531 " const restarted = fs.existsSync(marker);",
532 " console.log(process.execArgv.includes('--experimental-vm-modules'), restarted);",
533 " if (restarted) return;",
534 " fs.writeFileSync(marker, '');",
535 " throw new incremental.RestartRequired(['main.ts']);",
536 "});",
537 ].join("\n"),
538 });
539 const { stdout, status } = child_process.spawnSync(process.execPath, [p.path("main.ts")], { encoding: "utf-8" });
540 assert.equal(status, 0);
541 assert.match(stdout, /^true false\n.*restart.*: main\.ts\ntrue true\n$/);
542});
543
544test("import.meta.resolve is traced", async () => {
545 await using p = await project({
546 "package.json": JSON.stringify({ type: "module" }),
547 "find.ts": "export const find = () => import.meta.resolve('dep');",
548 });
549 let runs = 0;
550 const graph = p.graph();
551 const site = () =>
552 build(graph, (ref) =>
553 ref.step(async (b) => {
554 runs++;
555 const { find } = await b.import<{ find(): string }>("find.ts");
556 try {
557 return path.relative(p.dir, url.fileURLToPath(find()));
558 } catch {
559 return "missing";
560 }
561 }).read());
562 assert.equal(await site(), "missing");
563 assert.equal(await site(), "missing");
564 assert.equal(runs, 1);
565
566 await p.write("node_modules/dep/package.json", JSON.stringify({ name: "dep", exports: "./main.js" }));
567 await p.write("node_modules/dep/main.js", "export {};");
568 assert.equal(await site(), path.join("node_modules", "dep", "main.js"));
569 assert.equal(runs, 2);
570});
571
572test("like node.js, import.meta.resolve of a path doesn't look at the filesystem", async () => {
573 await using p = await project({
574 "package.json": JSON.stringify({ type: "module" }),
575 "find.ts": "export default ['./missing.js', './a%20b.js', '/abs.js'].map((s) => import.meta.resolve(s));",
576 });
577 const { default: found } = await p.graph().import<{ default: string[] }>("find.ts");
578 assert.deepEqual(found, [
579 url.pathToFileURL(p.path("missing.js")).href,
580 url.pathToFileURL(p.path("a b.js")).href,
581 "file:///abs.js",
582 ]);
583});
584
585test("dynamic imports inside a step are traced", async () => {
586 await using p = await project({
587 "package.json": JSON.stringify({ type: "module" }),
588 "main.ts": "export const load = () => import('./dep.ts');",
589 "dep.ts": "export const value = 1;",
590 });
591 let runs = 0;
592 const graph = p.graph();
593 const site = () =>
594 build(graph, (ref) =>
595 ref.step(async (b) => {
596 runs++;
597 const { load } = await b.import<{ load(): Promise<{ value: number }> }>("main.ts");
598 return (await load()).value;
599 }).read());
600 assert.equal(await site(), 1);
601 assert.equal(await site(), 1);
602 assert.equal(runs, 1);
603
604 await p.write("dep.ts", "export const value = 2;");
605 assert.equal(await site(), 2);
606 assert.equal(runs, 2);
607});
608
609test("import() depends on how its specifier resolves", async () => {
610 await using p = await project({
611 "package.json": JSON.stringify({ type: "module", imports: { "#data": "./a.ts" } }),
612 "page.ts": "export default async () => (await import('#data')).v;",
613 "a.ts": "export const v = 'A';",
614 "b.ts": "export const v = 'B';",
615 });
616 const site = (graph: incremental.Graph) =>
617 build(
618 graph,
619 (ref) => ref.step(async (b) => (await b.import<{ default(): Promise<string> }>("page.ts")).default()).read(),
620 );
621 const graph = p.graph();
622 assert.equal(await site(graph), "A");
623 await p.write("package.json", JSON.stringify({ type: "module", imports: { "#data": "./b.ts" } }));
624 assert.equal(await site(graph), "B");
625 assert.equal(await site(p.graph()), "B");
626});
627
628test("a module's top-level import() belongs to every step that imports the module", async () => {
629 await using p = await project({
630 "package.json": JSON.stringify({ type: "module" }),
631 "page.ts": "const { v } = await import('./dyn.ts'); export default (n: number) => v + n;",
632 "dyn.ts": "export const v = 'one';",
633 });
634 const site = (graph: incremental.Graph) =>
635 build(
636 graph,
637 (ref) =>
638 Promise.all([1, 2].map((n) =>
639 ref.step(async (b, n) => (await b.import<{ default(n: number): string }>("page.ts")).default(n), n)
640 .read()
641 )),
642 );
643 assert.deepEqual(await site(p.graph()), ["one1", "one2"]);
644 await p.write("dyn.ts", "export const v = 'two';");
645 assert.deepEqual(await site(p.graph()), ["two1", "two2"]);
646});
647
648test("import() works in a lazy step, from a module's top level and from what the step calls", async () => {
649 await using p = await project({
650 "package.json": JSON.stringify({ type: "module" }),
651 "m.ts": "const { w } = await import('./w.ts'); export const get = async () => w + (await import('./y.ts')).v;",
652 "w.ts": "export const w = 'w';",
653 "y.ts": "export const v = 'y';",
654 });
655 const page = p.graph().lazy(async (b) => await (await b.import<{ get(): Promise<string> }>("m.ts")).get());
656 assert.equal(await page.read(), "wy");
657});
658
659test("a module can't read inputs while it loads, only in functions that steps call", async () => {
660 const lib = JSON.stringify(url.fileURLToPath(new URL("../incremental.ts", import.meta.url)));
661 await using p = await project({
662 "package.json": JSON.stringify({ type: "module" }),
663 "top.ts": `import * as incremental from ${lib}; export const step = incremental.Build.active;`,
664 "awaits.ts": "export const value = await (globalThis as any).shared.read();",
665 "called.ts": [
666 `import * as incremental from ${lib};`,
667 "export const inStep = () => incremental.Build.active !== undefined;",
668 // left behind by the top level, and run once the module has loaded
669 "export const later = new Promise((resolve) => setTimeout(() => resolve(inStep())));",
670 ].join("\n"),
671 });
672 const graph = p.graph();
673 const message = /a module can't read inputs while it loads/;
674 const load = (file: string) =>
675 build(graph, (ref) => {
676 (globalThis as { shared?: unknown }).shared = ref.step(() => "shared");
677 return ref.step((b, file) => b.import(file).then(() => null), file).read();
678 }).catch((error) => error.errors[0].message);
679 assert.match(await load("top.ts"), message);
680 assert.match(await load("awaits.ts"), message);
681 await assert.rejects(p.graph().import("top.ts"), message);
682
683 const called = await build(graph, (ref) =>
684 ref.step(async (b) => {
685 const { inStep, later } = await b.import<{ inStep(): boolean; later: Promise<boolean> }>("called.ts");
686 return { inStep: inStep(), later: await later };
687 }).read());
688 assert.deepEqual(called, { inStep: true, later: false });
689});
690
691test("a lazy loader is loaded by the graph, so it reloads when its code changes", async () => {
692 await using p = await project({
693 "package.json": JSON.stringify({ type: "module" }),
694 "page.ts": "import text from './x.txt'; export default () => text;",
695 "x.txt": "hello",
696 "loader.ts": "export default ({ source }: any) => ({ format: 'serializable', content: source.toUpperCase() });",
697 });
698 const graph = p.graph({ loaders: { ".txt": incremental.lazyLoader(p.path("loader.ts")) } });
699 const site = () =>
700 build(graph, (ref) => ref.step(async (b) => (await b.import<{ default(): string }>("page.ts")).default()).read());
701 assert.equal(await site(), "HELLO");
702
703 await p.write(
704 "loader.ts",
705 "export default ({ source }: any) => ({ format: 'serializable', content: source + '!' });",
706 );
707 assert.equal(await site(), "hello!");
708});
709
710test("graph options are inputs, so graphs with other conditions or loaders don't share results", async () => {
711 await using p = await project({
712 "package.json": JSON.stringify({ type: "module" }),
713 "node_modules/dual/package.json": JSON.stringify({
714 name: "dual",
715 exports: { development: "./dev.mjs", default: "./prod.mjs" },
716 }),
717 "node_modules/dual/dev.mjs": "export default 'dev';",
718 "node_modules/dual/prod.mjs": "export default 'prod';",
719 "page.ts": "import d from 'dual'; import t from './x.txt'; export default () => d + ' ' + t;",
720 "x.txt": "hello",
721 "upper.ts": "export default ({ source }: any) => ({ format: 'serializable', content: source.toUpperCase() });",
722 "lower.ts": "export default ({ source }: any) => ({ format: 'serializable', content: source + '?' });",
723 });
724 const site = (graph: incremental.Graph) =>
725 build(graph, (ref) => ref.step(async (b) => (await b.import<{ default(): string }>("page.ts")).default()).read());
726 const development = {
727 conditions: ["development"],
728 loaders: { ".txt": incremental.lazyLoader(p.path("upper.ts")) },
729 };
730 assert.equal(await site(p.graph(development)), "dev HELLO");
731 assert.equal(await site(p.graph({ loaders: { ".txt": incremental.lazyLoader(p.path("lower.ts")) } })), "prod hello?");
732 assert.equal(await site(p.graph(development)), "dev HELLO");
733});
734
735// ## globs
736test("a glob import has every file it matches, sorted, as static imports, and sees new files", async () => {
737 await using p = await project({
738 "package.json": JSON.stringify({ type: "module" }),
739 "index.ts": [
740 "import entries from 'glob:./entries/*/*.ts';",
741 "export default Object.entries(entries).map(([key, entry]) => key + '=' + entry.default);",
742 ].join("\n"),
743 "entries/25/12-dec.ts": "export default 'dec';",
744 "entries/25/04-apr.ts": "export default 'apr';",
745 "entries/26/01-jan.ts": "import './style.css'; export default 'jan';",
746 "entries/26/style.css": "",
747 "entries/26/notes.txt": "",
748 });
749 const graph = p.graph({ loaders: { ".css": () => ({ format: "serializable", content: null }) } });
750 const site = () =>
751 build(graph, (ref) =>
752 ref.step(async (b) => ({
753 entries: (await b.import<{ default: string[] }>("index.ts")).default,
754 css: (await b.trackModule("index.ts")).filter((node) => node.url.endsWith(".css")).length,
755 })).read());
756 assert.deepEqual(await site(), { entries: ["25/04-apr.ts=apr", "25/12-dec.ts=dec", "26/01-jan.ts=jan"], css: 1 });
757
758 await p.write("entries/26/02-feb.ts", "export default 'feb';");
759 await p.write("entries/27/01-jan.ts", "export default 'next year';");
760 assert.deepEqual((await site()).entries, [
761 "25/04-apr.ts=apr",
762 "25/12-dec.ts=dec",
763 "26/01-jan.ts=jan",
764 "26/02-feb.ts=feb",
765 "27/01-jan.ts=next year",
766 ]);
767});
768
769test("a glob's ** matches any depth outside hidden directories, and json files import as json", async () => {
770 await using p = await project({
771 "package.json": JSON.stringify({ type: "module" }),
772 "index.ts": [
773 "import data from 'glob:./data/**/*.json';",
774 "import all from 'glob:./data/**';",
775 "import none from 'glob:./missing/*.ts';",
776 "export default {",
777 " keys: Object.keys(data),",
778 " b: data['deep/b.json'].default.b,",
779 " all: Object.keys(all),",
780 " none: Object.keys(none),",
781 "};",
782 ].join("\n"),
783 "data/a.json": JSON.stringify({ a: 1 }),
784 "data/deep/b.json": JSON.stringify({ b: 2 }),
785 "data/.hidden/c.json": JSON.stringify({ c: 3 }),
786 });
787 // node.js can't load a glob, even from where modules are external
788 const graph = p.graph({ external: (file) => file.includes(`${path.sep}data${path.sep}`) });
789 const { default: found } = await graph.import<{ default: unknown }>("index.ts");
790 assert.deepEqual(found, { keys: ["a.json", "deep/b.json"], b: 2, all: ["a.json", "deep/b.json"], none: [] });
791});
792
793test("a glob's module still fits once the project has moved", async () => {
794 await using a = await project({
795 "package.json": JSON.stringify({ type: "module" }),
796 "index.ts":
797 "import entries from 'glob:./entries/*.ts'; export default Object.values(entries).map((e) => e.default);",
798 "entries/a.ts": "export default import.meta.filename;",
799 });
800 assert.deepEqual((await a.graph().import<{ default: string[] }>("index.ts")).default, [a.path("entries/a.ts")]);
801
802 await using b = await project();
803 await fs.cp(a.dir, b.dir, { recursive: true });
804 await fs.rm(a.dir, { recursive: true });
805 assert.deepEqual((await b.graph().import<{ default: string[] }>("index.ts")).default, [b.path("entries/a.ts")]);
806 // the pattern it lists again is where the project is now
807 await b.write("entries/b.ts", "export default import.meta.filename;");
808 const listed = (await b.graph().import<{ default: string[] }>("index.ts")).default;
809 assert.deepEqual(listed, [b.path("entries/a.ts"), b.path("entries/b.ts")]);
810});
811
812test("a glob's keys start at its wildcards, whatever the directories above them are called", async () => {
813 await using p = await project();
814 for (const dir of ["site [draft]", "a{b}"]) {
815 await p.write(`${dir}/package.json`, JSON.stringify({ type: "module" }));
816 await p.write(`${dir}/main.ts`, "import posts from 'glob:./posts/*.ts'; export default Object.keys(posts);");
817 await p.write(`${dir}/posts/a.ts`, "export default 1;");
818 assert.deepEqual((await p.graph().import<{ default: string[] }>(`${dir}/main.ts`)).default, ["a.ts"]);
819 }
820});
821
822test("a glob the main module imports requires a restart once another file matches", async () => {
823 await using p = await project({
824 "package.json": JSON.stringify({ type: "module" }),
825 "main.ts": "import posts from 'glob:./posts/*.json'; export const n = Object.keys(posts).length;",
826 "posts/a.json": "1",
827 });
828 const graph = p.graph();
829 assert.equal((await graph.import<{ n: number }>("main.ts", { main: true })).n, 1);
830 await p.write("posts/b.json", "2");
831 await assert.rejects(
832 build(graph, async () => {}),
833 (error) => error instanceof incremental.RestartRequired && error.files.includes(p.path("posts/*.json")),
834 );
835});
836
837test("v8's compiled code is cached in the database", async () => {
838 await using p = await project({
839 "package.json": JSON.stringify({ type: "module" }),
840 "main.ts": "export const answer = () => 42;",
841 });
842 const graph = p.graph();
843 assert.equal((await graph.import<{ answer(): number }>("main.ts")).answer(), 42);
844 const { count } = graph.db.sql.prepare("select count(*) as count from code").get() as { count: number };
845 assert.equal(count, 1);
846 assert.equal((await p.graph().import<{ answer(): number }>("main.ts")).answer(), 42);
847});
848
849/** a project in a temporary directory, removed once disposed */
850async function project(files: Record<string, string> = {}) {
851 const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "incremental-")));
852 const write = async (name: string, contents: string) => {
853 await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true });
854 await fs.writeFile(path.join(dir, name), contents);
855 };
856 for (const [name, contents] of Object.entries(files)) await write(name, contents);
857 return {
858 dir,
859 write,
860 path: (name: string) => path.join(dir, name),
861 /** a graph on the project's database, like a new process would make */
862 graph: (options: incremental.GraphOptions = {}) =>
863 new incremental.Graph({ db: ".clover/incremental.db", root: dir, ...options }),
864 [Symbol.asyncDispose]: () => fs.rm(dir, { recursive: true, force: true }),
865 };
866}
867
868function build<T>(
869 graph: incremental.Graph,
870 buildAndWriteOutput: (ref: incremental.Ref) => Promise<T>,
871): Promise<T> {
872 return graph.incrementalBuild({ progress: new progress.Root(), buildAndWriteOutput });
873}
874
875/** files in memory, keyed by absolute path */
876function virtualFs(files: Record<string, string>): incremental.Fs {
877 const contents = new Map(
878 Object.entries(files).map(([file, text]) => [file, new TextEncoder().encode(text)]),
879 );
880 const dirs = new Set(["/"]);
881 for (const file of contents.keys()) {
882 for (let dir = path.dirname(file); dir !== "/"; dir = path.dirname(dir)) dirs.add(dir);
883 }
884 const missing = (file: string) =>
885 Object.assign(new Error(`ENOENT: no such file or directory, '${file}'`), { code: "ENOENT" });
886 const stat = (file: string): incremental.Stat => {
887 const bytes = contents.get(file);
888 if (bytes) return { kind: "file", size: bytes.byteLength, time: 0 };
889 if (dirs.has(file)) return { kind: "directory", size: 0, time: 0 };
890 throw missing(file);
891 };
892 const read = (file: string) =>
893 contents.get(file) ?? (() => {
894 throw missing(file);
895 })();
896 const handles = new Map<incremental.FileHandle, string>();
897 let next = 1;
898 return new incremental.Fs({
899 async open(file) {
900 stat(file);
901 const handle = next++ as incremental.FileHandle;
902 handles.set(handle, file);
903 return handle;
904 },
905 statHandle: async (handle) => stat(UNWRAP(handles.get(handle))),
906 async readHandle(handle, offset, length, destination) {
907 const chunk = read(UNWRAP(handles.get(handle))).subarray(offset, offset + length);
908 destination.set(chunk);
909 return chunk.byteLength;
910 },
911 closeHandle: async (handle) => void handles.delete(handle),
912 async listDir(dir) {
913 if (!dirs.has(dir)) throw missing(dir);
914 const children = new Set<string>();
915 for (const file of [...contents.keys(), ...dirs]) {
916 if (path.dirname(file) === dir && file !== dir) {
917 children.add(path.basename(file) + (dirs.has(file) ? "/" : ""));
918 }
919 }
920 return Array.from(children);
921 },
922 statSync: stat,
923 readFileSync: read,
924 realPathSync: (file) => file,
925 });
926}
927
928import assert from "node:assert/strict";
929import * as child_process from "node:child_process";
930import * as fs from "node:fs/promises";
931import * as os from "node:os";
932import * as path from "node:path";
933import * as url from "node:url";
934import { test } from "vitest";
935import { UNWRAP } from "../assert.ts";
936import * as incremental from "../incremental.ts";
937import * as stack from "../log/stack.ts";
938import * as progress from "../progress.ts";