1// ## caching
2test("a step is reused until a file it read changes, across restarts", async () => {
3 await using p = await project({ "source.txt": "first" });
4 let runs = 0;
5 const read = (graph: incremental.Graph) =>
6 build(graph, (ref) =>
7 ref.step(async (b) => {
8 runs++;
9 return await b.readFile("source.txt").text();
10 }).read());
11 const graph = p.graph();
12 assert.equal(await read(graph), "first");
13 assert.equal(await read(graph), "first");
14 assert.equal(await read(p.graph()), "first");
15 assert.equal(runs, 1);
16
17 await p.write("source.txt", "second");
18 assert.equal(await read(graph), "second");
19 assert.equal(runs, 2);
20});
21
22test("a project that moves keeps what its database cached", async () => {
23 await using a = await project({
24 "package.json": JSON.stringify({ type: "module" }),
25 // the steps are started by code of the project, like a build script is
26 "pipeline.ts": [
27 "export const page = (ref: any, ran: () => void) =>",
28 " ref.step(async (b: any) => {",
29 " ran();",
30 " const { default: render } = await b.import('page.ts');",
31 " return render(await b.readFile('name.txt').text());",
32 " });",
33 ].join("\n"),
34 "page.ts": "import { shout } from './shout.ts'; export default (text: string) => shout(text);",
35 "shout.ts": "export const shout = (text: string) => text.toUpperCase();",
36 "name.txt": "clover",
37 });
38 let runs = 0;
39 type Pipeline = { page(ref: incremental.Ref, ran: () => void): incremental.Step<string> };
40 const site = async (p: typeof a) => {
41 const { page }: Pipeline = await import(url.pathToFileURL(p.path("pipeline.ts")).href);
42 return build(p.graph(), async (ref) => {
43 const step = page(ref, () => runs++);
44 await ref.writeFile("out/page.txt", step);
45 return await step.read();
46 });
47 };
48 assert.equal(await site(a), "CLOVER");
49
50 // nothing may still point at where the project was
51 await using b = await project();
52 await fs.cp(a.dir, b.dir, { recursive: true });
53 await fs.rm(a.dir, { recursive: true });
54 assert.equal(await site(b), "CLOVER");
55 assert.equal(runs, 1);
56 assert.equal(await fs.readFile(b.path("out/page.txt"), "utf-8"), "CLOVER");
57
58 // the modules it found before are loaded from where they are now
59 const loaded = await build(
60 b.graph(),
61 (ref) =>
62 ref.step(async (b) => (await b.import<{ default(text: string): string }>("page.ts")).default("moved")).read(),
63 );
64 assert.equal(loaded, "MOVED");
65});
66
67test("a rewrite with the same size and mtime is noticed by its ctime", async () => {
68 await using p = await project({ "source.txt": "first" });
69 // whole seconds, so restoring the mtime restores it exactly
70 const mtime = 1_700_000_000;
71 await fs.utimes(p.path("source.txt"), mtime, mtime);
72 let runs = 0;
73 const graph = p.graph();
74 const read = () =>
75 build(graph, (ref) =>
76 ref.step(async (b) => {
77 runs++;
78 return await b.readFile("source.txt").text();
79 }).read());
80 assert.equal(await read(), "first");
81
82 await p.write("source.txt", "other");
83 await fs.utimes(p.path("source.txt"), mtime, mtime);
84 assert.equal((await fs.stat(p.path("source.txt"))).mtimeMs, mtime * 1000);
85 assert.equal(await read(), "other");
86 assert.equal(runs, 2);
87});
88
89test("a step that re-runs with the same value doesn't re-run its readers", async () => {
90 await using p = await project({
91 "config.json": JSON.stringify({ title: "hello", unrelated: 1 }),
92 });
93 const runs = { title: 0, page: 0 };
94 const graph = p.graph();
95 const site = () =>
96 build(graph, (ref) => {
97 const title = ref.step(async (b) => {
98 runs.title++;
99 return (await b.readFile("config.json").json<{ title: string }>()).title;
100 });
101 return ref.step(async (b) => {
102 runs.page++;
103 return `<h1>${await b.readStep(title)}</h1>`;
104 }).read();
105 });
106 assert.equal(await site(), "<h1>hello</h1>");
107
108 await p.write("config.json", JSON.stringify({ title: "hello", unrelated: 2 }));
109 assert.equal(await site(), "<h1>hello</h1>");
110 assert.deepEqual(runs, { title: 2, page: 1 });
111
112 await p.write("config.json", JSON.stringify({ title: "world", unrelated: 2 }));
113 assert.equal(await site(), "<h1>world</h1>");
114 assert.deepEqual(runs, { title: 3, page: 2 });
115});
116
117test("steps can be passed to other steps as arguments", async () => {
118 await using p = await project({ "name.txt": "clover" });
119 const graph = p.graph();
120 const name = await build(graph, async (ref) => {
121 const read = ref.step((b) => b.readFile("name.txt").text());
122 return ref.step(async (b, step) => (await b.readStep(step)).toUpperCase(), read).read();
123 });
124 assert.equal(name, "CLOVER");
125});
126
127test("results and arguments can be typed by interfaces", async () => {
128 interface Post {
129 title: string;
130 date: Date;
131 }
132 await using p = await project();
133 const post: Post = { title: "hello", date: new Date(0) };
134 const result = await build(p.graph(), (ref) => {
135 // @ts-expect-error a function can't be serialized
136 const _ = () => ref.step(async () => ({ title: "", render: () => "" }));
137 return ref.step(async (_, post: Post): Promise<Post> => ({ ...post, title: post.title + "!" }), post).read();
138 });
139 assert.deepEqual(result, { title: "hello!", date: new Date(0) });
140});
141
142test("editing a module the step's file imports re-runs the step", async () => {
143 await using p = await project({
144 "package.json": JSON.stringify({ type: "module" }),
145 "site.ts": [
146 "import { shout } from './shout.ts';",
147 "export default (ref: any) => ref.step(async (b: any) =>",
148 " shout(await b.readFile('name.txt').text())).read();",
149 ].join("\n"),
150 "shout.ts": "export const shout = (text: string) => text.toUpperCase();",
151 "unrelated.ts": "export const unrelated = 1;",
152 "name.txt": "clover",
153 });
154 const graph = p.graph();
155 const site = async () => {
156 const mod = await graph.import<{ default: (ref: incremental.Ref) => Promise<string> }>("site.ts");
157 return await build(graph, mod.default);
158 };
159 assert.equal(await site(), "CLOVER");
160 assert.equal(await site(), "CLOVER");
161
162 await p.write("unrelated.ts", "export const unrelated = 2;");
163 assert.equal(await site(), "CLOVER");
164
165 await p.write("shout.ts", "export const shout = (text: string) => text.toUpperCase() + '!';");
166 assert.equal(await site(), "CLOVER!");
167});
168
169test("starting the same step twice in a build is an error", async () => {
170 await using p = await project();
171 const error = await build(p.graph(), async (ref) => {
172 const one = () => 1;
173 for (let i = 0; i < 2; i++) ref.step(one);
174 }).catch((error) => error);
175 assert.ok(error instanceof incremental.BuildError);
176 assert.match(error.errors[0]!.message, /started twice with the same arguments/);
177});
178
179test("arguments that can't be hashed faithfully are rejected", async () => {
180 await using p = await project();
181 const error = await build(p.graph(), async (ref) => {
182 ref.step((_, value) => String(value), new URL("https://example.com") as never);
183 }).catch((error) => error);
184 assert.match(error.errors[0]!.message, /a URL can not be hashed/);
185});
186
187test("reading a step within another step makes it a dependency", async () => {
188 await using p = await project({ "a.txt": "1" });
189 let runs = 0;
190 const graph = p.graph();
191 const site = () =>
192 build(graph, (ref) => {
193 const a = ref.step(async (b) => await b.readFile("a.txt").text());
194 // captured rather than passed, so only reading it ties the steps together
195 return ref.step(async () => {
196 runs++;
197 return "got " + await a.read();
198 }).read();
199 });
200 assert.equal(await site(), "got 1");
201 assert.equal(await site(), "got 1");
202 assert.equal(runs, 1);
203 await p.write("a.txt", "2");
204 assert.equal(await site(), "got 2");
205 assert.equal(runs, 2);
206});
207
208test("a step that read a step that couldn't be cached runs again", async () => {
209 let down = false;
210 const flaky = incremental.source({
211 protocol: "flaky",
212 read: () => {
213 if (down) throw new Error("source is down");
214 return { value: "up" };
215 },
216 });
217 await using p = await project();
218 const graph = new incremental.Graph({ db: ".clover/incremental.db", root: p.dir, sources: [flaky] });
219 let runs = 0;
220 const site = () =>
221 build(graph, (ref) => {
222 const first = ref.step(async (b) => {
223 await flaky.query(b, {}).catch(() => null);
224 return "same";
225 });
226 return ref.step(async (b, first) => {
227 runs++;
228 return "read " + await b.readStep(first);
229 }, first).read();
230 });
231 assert.equal(await site(), "read same");
232 down = true;
233 assert.equal(await site(), "read same");
234 assert.equal(await site(), "read same");
235 assert.equal(runs, 3);
236});
237
238test("a cached step's dependencies are checked in order, until one changed", async () => {
239 await using p = await project({
240 "package.json": JSON.stringify({ type: "module" }),
241 "page.ts": "import text from './x.txt'; export default () => text;",
242 "x.txt": "hello",
243 });
244 let loads = 0;
245 const loaders: Record<string, incremental.LoaderFunction> = {
246 ".txt": ({ source }) => (loads++, { format: "serializable", content: source }),
247 };
248 const site = () =>
249 build(
250 new incremental.Graph({ db: ".clover/incremental.db", root: p.dir, loaders }),
251 (ref) => ref.step(async (b) => (await b.import<{ default(): string }>("page.ts")).default()).read(),
252 );
253 assert.equal(await site(), "hello");
254 assert.equal(loads, 1);
255 // page.ts changed first, so x.txt is never checked, let alone loaded
256 await p.write("page.ts", "export default () => 'alone';");
257 await p.write("x.txt", "changed");
258 assert.equal(await site(), "alone");
259 assert.equal(loads, 1);
260});
261
262test("a step whose read failed isn't cached", async () => {
263 await using p = await project({ "secret.txt": "shh" });
264 await fs.chmod(p.path("secret.txt"), 0o000);
265 let runs = 0;
266 const graph = p.graph();
267 const site = () =>
268 build(graph, (ref) =>
269 ref.step(async (b) => {
270 runs++;
271 return await b.readFile("secret.txt").text().catch(() => "denied");
272 }).read());
273 assert.equal(await site(), "denied");
274 assert.equal(await site(), "denied");
275 assert.equal(runs, 2);
276});
277
278// ## errors
279test("a step that throws caches its error until its inputs change", async () => {
280 await using p = await project({ "page.md": "" });
281 let runs = 0;
282 const graph = p.graph();
283 const site = () =>
284 build(graph, (ref) =>
285 ref.step(async function render(b) {
286 runs++;
287 const text = await b.readFile("page.md").text();
288 if (!text) throw Object.assign(new Error("page.md is empty"), { file: "page.md" });
289 return text;
290 }).read().catch(() => "fallback"));
291
292 const first = await site().catch((error) => error) as incremental.BuildError;
293 assert.ok(first instanceof incremental.BuildError);
294 assert.equal(first.value, "fallback");
295 assert.equal(Object.getOwnPropertyDescriptor(first, "value")?.enumerable, false);
296 const [error] = first.errors as Array<Error & { steps: string[]; fatal: boolean; cached: boolean }>;
297 assert.equal(error!.message, "page.md is empty");
298 assert.equal(error!.fatal, true);
299 assert.equal(error!.cached, false);
300 assert.match(error!.steps[0]!, /^render at /);
301 // said once, where it is about, and then the step it was thrown in, without the frames that ran the step
302 const printed = stack.format(first);
303 assert.match(
304 printed,
305 /^error: page\.md is empty\npage\.md\nnote: thrown in render\n[^\n]*incremental\/steps\.test\.ts/,
306 );
307 assert.doesNotMatch(printed, /^\s+file: "|steps:|\/incremental\.ts:/m);
308 assert.match(printed, /build has 1 error\n$/);
309
310 const second = await site().catch((error) => error) as incremental.BuildError;
311 const [replayed] = second.errors as Array<Error & { cached: boolean; file: string }>;
312 assert.equal(replayed!.message, "page.md is empty");
313 assert.equal(replayed!.cached, true);
314 assert.equal(replayed!.file, "page.md");
315 assert.equal(runs, 1);
316
317 await p.write("page.md", "# hello");
318 assert.equal(await site(), "# hello");
319 assert.equal(runs, 2);
320});
321
322test("a step catching another step's error is cached against that error", async () => {
323 await using p = await project({ "data.txt": "" });
324 const runs = { parse: 0, page: 0 };
325 const graph = p.graph();
326 const site = () =>
327 build(graph, (ref) => {
328 const parse = ref.step(async (b) => {
329 runs.parse++;
330 const text = await b.readFile("data.txt").text();
331 if (!text) throw new Error("no data");
332 return text;
333 });
334 return ref.step(async (b) => {
335 runs.page++;
336 return await b.readStep(parse).catch(() => "no data yet");
337 }).read();
338 }).catch((error: incremental.BuildError) => error.value);
339 assert.equal(await site(), "no data yet");
340 assert.equal(await site(), "no data yet");
341 assert.deepEqual(runs, { parse: 1, page: 1 });
342
343 await p.write("data.txt", "data");
344 assert.equal(await site(), "data");
345 assert.deepEqual(runs, { parse: 2, page: 2 });
346});
347
348test("warnings fail the build without stopping the step", async () => {
349 await using p = await project({ "post.md": "teh cat" });
350 let runs = 0;
351 const graph = p.graph();
352 const site = () =>
353 build(graph, (ref) =>
354 ref.step(async function spellcheck(b) {
355 runs++;
356 const text = await b.readFile("post.md").text();
357 const col = text.indexOf("teh");
358 if (col !== -1) {
359 b.warn(Object.assign(new Error("misspelled 'the'"), { file: "post.md", line: 1, col: col + 1 }));
360 }
361 return text.toUpperCase();
362 }).read());
363
364 for (const cached of [false, true]) {
365 const error = await site().catch((error) => error) as incremental.BuildError;
366 assert.equal(error.value, "TEH CAT");
367 const [lint] = error.errors as Array<Error & { fatal: boolean; cached: boolean; line: number }>;
368 assert.equal(lint!.message, "misspelled 'the'");
369 assert.equal(lint!.fatal, false);
370 assert.equal(lint!.cached, cached);
371 assert.equal(lint!.line, 1);
372 // traced from where it was warned
373 const printed = stack.format(error);
374 assert.match(printed, /^warning: misspelled 'the'\npost\.md:1:1\nnote: warned in spellcheck( \(cached\))?\n/);
375 assert.match(printed, /steps\.test\.ts:\d+:\d+ at spellcheck\n\s*b\.warn\(/);
376 assert.match(printed, /build has 1 warning\n$/);
377 }
378 assert.equal(runs, 1);
379
380 await p.write("post.md", "the cat");
381 assert.equal(await site(), "THE CAT");
382});
383
384test("errors thrown from the same place are grouped, and identical ones merge", async () => {
385 await using p = await project();
386 const error = await build(p.graph(), async (ref) => {
387 const check = (_: incremental.Build, name: string, message: string) => {
388 throw new Error(`${message} ${name}`);
389 };
390 await Promise.allSettled([
391 ref.step(check, "a", "broken").read(),
392 ref.step(check, "b", "broken").read(),
393 ref.step(check, "a", "other").read(),
394 ]);
395 }).catch((error) => error) as incremental.BuildError;
396 assert.equal(error.errors.length, 1);
397 const group = error.errors[0];
398 assert.ok(group instanceof AggregateError);
399 assert.equal(group.errors.length, 3);
400 const messages = group.errors.map((error: Error & { steps: string[] }) => error.message).sort();
401 assert.deepEqual(messages, ["broken a", "broken b", "other a"]);
402});
403
404test("warnings about other places are said apart, even when reported from the same line", async () => {
405 await using p = await project({ "post.md": "teh cat teh" });
406 const error = await build(p.graph(), (ref) =>
407 ref.step(async function spellcheck(b) {
408 const text = await b.readFile("post.md").text();
409 for (const match of text.matchAll(/teh/g)) {
410 b.warn(Object.assign(new Error("misspelled 'the'"), { file: "post.md", line: 1, col: match.index + 1 }));
411 }
412 }).read()).catch((error) => error) as incremental.BuildError;
413 assert.deepEqual(error.errors.map((warning) => (warning as Error & { col: number }).col), [1, 9]);
414 assert.equal(error.message, "build has 2 warnings");
415});
416
417test("files an error points into are inputs, so its locations stay right", async () => {
418 await using p = await project({ "a.md": "# title", "b.md": "# title" });
419 let runs = 0;
420 const graph = p.graph();
421 const site = () =>
422 build(graph, (ref) =>
423 ref.step(async (b) => {
424 runs++;
425 await b.readFile("a.md").text();
426 b.warn(Object.assign(new Error("duplicate heading"), {
427 file: "a.md",
428 line: 1,
429 col: 1,
430 notes: [{ text: "first defined here", file: "b.md", line: 1, col: 1 }],
431 }));
432 }).read()).catch(() => {});
433 await site();
434 await site();
435 assert.equal(runs, 1);
436
437 await p.write("b.md", "\n# title");
438 await site();
439 assert.equal(runs, 2);
440});
441
442test("a build reports errors only from the steps it still reads", async () => {
443 await using p = await project({
444 "package.json": JSON.stringify({ type: "module" }),
445 "page.ts": "import { x } from './old.ts'; export default () => 'page ' + x;",
446 "old.ts": "export const x = 1;",
447 });
448 const site = () =>
449 build(
450 p.graph(),
451 (ref) => ref.step(async (b) => (await b.import<{ default(): string }>("page.ts")).default()).read(),
452 );
453 assert.equal(await site(), "page 1");
454 await p.write("page.ts", "export default () => 'page alone';");
455 await fs.rm(p.path("old.ts"));
456 assert.equal(await site(), "page alone");
457});
458
459test("rerunErrors reports a step whose outcome changed", async () => {
460 await using p = await project();
461 let fail = true;
462 const graph = p.graph();
463 const site = (rerunErrors: boolean) =>
464 graph.incrementalBuild({
465 progress: new progress.Root(),
466 rerunErrors,
467 buildAndWriteOutput: (ref) =>
468 ref.step(async () => {
469 if (fail) throw new Error("flaky");
470 return "ok";
471 }).read().catch(() => "failed"),
472 }).catch((error: incremental.BuildError) => error.errors.map((error) => error.message));
473
474 assert.deepEqual(await site(false), ["flaky"]);
475 fail = false;
476 assert.deepEqual(await site(false), ["flaky"]);
477 const [message] = await site(true) as string[];
478 assert.match(message!, /had a different result when re-run/);
479 assert.equal(await site(false), "ok");
480});
481
482// ## inputs
483test("readDir marks directories with a slash, and re-runs when entries change", async () => {
484 await using p = await project({ "pages/a.md": "", "pages/nested/b.md": "" });
485 let runs = 0;
486 const graph = p.graph();
487 const site = () =>
488 build(graph, (ref) =>
489 ref.step(async (b) => {
490 runs++;
491 return await b.readDir("pages");
492 }).read());
493 assert.deepEqual(await site(), ["a.md", "nested/"]);
494
495 await p.write("pages/a.md", "edited");
496 assert.deepEqual(await site(), ["a.md", "nested/"]);
497 assert.equal(runs, 1);
498
499 await p.write("pages/c.md", "");
500 assert.deepEqual(await site(), ["a.md", "c.md", "nested/"]);
501 assert.equal(runs, 2);
502});
503
504test("listing a file fails until a directory is there, without re-running in between", async () => {
505 await using p = await project({ "pages": "a file" });
506 let runs = 0;
507 const graph = p.graph();
508 const site = () =>
509 build(graph, (ref) =>
510 ref.step(async (b) => {
511 runs++;
512 return await b.readDir("pages").catch((error) => error.code);
513 }).read());
514 assert.equal(await site(), "ENOTDIR");
515 assert.equal(await site(), "ENOTDIR");
516 assert.equal(runs, 1);
517
518 await fs.rm(p.path("pages"));
519 await p.write("pages/a.md", "");
520 assert.deepEqual(await site(), ["a.md"]);
521 assert.equal(runs, 2);
522});
523
524test("readDirRecursive lists nested directories, and re-runs when any of them change", async () => {
525 await using p = await project({ "pages/a.md": "", "pages/nested/b.md": "", "pages/nested/deeper/c.md": "" });
526 let runs = 0;
527 const graph = p.graph();
528 const site = () =>
529 build(graph, (ref) =>
530 ref.step(async (b) => {
531 runs++;
532 return await b.readDirRecursive("pages");
533 }).read());
534 assert.deepEqual(await site(), ["a.md", "nested/", "nested/b.md", "nested/deeper/", "nested/deeper/c.md"]);
535
536 await p.write("pages/nested/deeper/d.md", "");
537 assert.deepEqual(await site(), [
538 "a.md",
539 "nested/",
540 "nested/b.md",
541 "nested/deeper/",
542 "nested/deeper/c.md",
543 "nested/deeper/d.md",
544 ]);
545 assert.equal(runs, 2);
546});
547
548test("sources are read again to check a cached step", async () => {
549 await using p = await project();
550 const rows = new Map([["hello", "world"]]);
551 let reads = 0;
552 let version = 1;
553 const kv = incremental.source({
554 protocol: "kv",
555 read: ({ key }: { key: string }) => {
556 reads++;
557 return { value: rows.get(key) ?? null, hash: `${key}@${version}` };
558 },
559 unchanged: (_, input) => input.hash.endsWith(`@${version}`),
560 });
561 const graph = new incremental.Graph({ db: null, root: p.dir, sources: [kv] });
562 let runs = 0;
563 const site = () =>
564 build(graph, (ref) =>
565 ref.step(async (b) => {
566 runs++;
567 return await kv.query(b, { key: "hello" });
568 }).read());
569 assert.equal(await site(), "world");
570 assert.equal(await site(), "world");
571 assert.deepEqual({ runs, reads }, { runs: 1, reads: 1 });
572
573 rows.set("hello", "there");
574 version++;
575 assert.equal(await site(), "there");
576 assert.deepEqual({ runs, reads }, { runs: 2, reads: 2 });
577});
578
579test("what a step read from a source by itself is checked like a query", async () => {
580 await using p = await project();
581 let value = "one";
582 const memory = incremental.source({ protocol: "memory", read: () => ({ value, hash: value }) });
583 const graph = new incremental.Graph({ db: null, root: p.dir, sources: [memory] });
584 let runs = 0;
585 // code that reads without being passed a `Build` finds the step it runs in
586 const read = () => {
587 memory.observe(UNWRAP(incremental.Build.active), {}, value);
588 return value;
589 };
590 const site = () =>
591 build(graph, (ref) =>
592 ref.step(() => {
593 runs++;
594 return read();
595 }).read());
596 assert.equal(incremental.Build.active, undefined);
597 assert.equal(await site(), "one");
598 assert.equal(await site(), "one");
599 assert.equal(runs, 1);
600
601 value = "two";
602 assert.equal(await site(), "two");
603 assert.equal(runs, 2);
604});
605
606test("a source that changed between two reads of a step is not trusted", async () => {
607 await using p = await project();
608 const memory = incremental.source({ protocol: "memory", read: () => ({ value: null, hash: "one" }) });
609 const graph = new incremental.Graph({ db: null, root: p.dir, sources: [memory] });
610 let runs = 0;
611 const site = () =>
612 build(graph, (ref) =>
613 ref.step((b) => {
614 runs++;
615 memory.observe(b, {}, "one");
616 memory.observe(b, {}, "two");
617 }).read());
618 await site();
619 await site();
620 assert.equal(runs, 2);
621});
622
623test("code that outlives its step is no longer running in it", async () => {
624 await using p = await project();
625 let late: Promise<incremental.Build | undefined> | undefined;
626 await build(p.graph(), (ref) =>
627 ref.step(() => {
628 late = new Promise((resolve) => setTimeout(() => resolve(incremental.Build.active)));
629 }).read());
630 assert.equal(await late, undefined);
631});
632
633test("a step can't read from a source the graph doesn't have", async () => {
634 await using p = await project();
635 const memory = incremental.source({ protocol: "memory", read: () => ({ value: null }) });
636 const error = await build(p.graph(), (ref) => ref.step((b) => memory.observe(b, {}, "one")).read())
637 .catch((error) => error);
638 assert.match(error.errors[0]!.message, /no source is registered for memory:/);
639});
640
641test("a build shares the thread with the rest of the process", async () => {
642 await using p = await project();
643 // turns of the event loop, which pass only while the build lets go of the thread
644 let turn = 0;
645 let building = true;
646 const count = () => {
647 turn++;
648 if (building) setImmediate(count);
649 };
650 setImmediate(count);
651 const started = new Set<number>();
652 const waited = new Set<number>();
653 const render = (turns: Set<number>) => {
654 turns.add(turn);
655 for (const end = performance.now() + 2; performance.now() < end;);
656 };
657 const all = (step: (i: number) => incremental.Step<number>) =>
658 Promise.all(Array.from({ length: 40 }, (_, i) => step(i).read()));
659 await build(p.graph(), async (ref) => {
660 await all((i) => ref.step((_, i) => (render(started), i), i));
661 // these all wait for one step, so they could all resume at once
662 const shared = ref.step(() => new Promise<null>((resolve) => setTimeout(resolve, 20, null)));
663 await all((i) => ref.step(async (b, i) => (await b.readStep(shared), render(waited), i), i));
664 });
665 building = false;
666 assert.ok(started.size >= 8, `steps started in ${started.size} turns`);
667 assert.ok(waited.size >= 8, `steps resumed in ${waited.size} turns`);
668});
669
670// ## outputs
671test("outputs skip unchanged writes, repair tampering, and are deleted when unused", async () => {
672 await using p = await project({ "static/a.txt": "a" });
673 let pages = ["index", "about"];
674 const graph = p.graph();
675 const site = () =>
676 build(graph, async (ref) => {
677 await ref.copyFile("out/a.txt", "static/a.txt");
678 for (const page of pages) {
679 const html = ref.step((_, name) => `<p>${name}</p>`, page);
680 await ref.writeFile(`out/${page}.html`, html);
681 }
682 });
683 await site();
684 assert.equal(await fs.readFile(p.path("out/index.html"), "utf-8"), "<p>index</p>");
685 assert.equal(await fs.readFile(p.path("out/a.txt"), "utf-8"), "a");
686 const written = await fs.stat(p.path("out/index.html"));
687
688 await site();
689 assert.equal((await fs.stat(p.path("out/index.html"))).ctimeMs, written.ctimeMs);
690
691 await fs.writeFile(p.path("out/index.html"), "tampered");
692 await site();
693 assert.equal(await fs.readFile(p.path("out/index.html"), "utf-8"), "<p>index</p>");
694
695 pages = ["index"];
696 await site();
697 await assert.rejects(fs.stat(p.path("out/about.html")), { code: "ENOENT" });
698
699 pages = [];
700 await p.write("static/a.txt", "b");
701 await site();
702 assert.deepEqual(await fs.readdir(p.path("out")), ["a.txt"]);
703 assert.equal(await fs.readFile(p.path("out/a.txt"), "utf-8"), "b");
704});
705
706test("an output can replace a directory of old outputs, and the other way around", async () => {
707 await using p = await project();
708 const graph = p.graph();
709 await build(graph, (ref) => ref.writeFile("out/a/index.html", "in a directory"));
710 await build(graph, (ref) => ref.writeFile("out/a", "a file"));
711 assert.equal(await fs.readFile(p.path("out/a"), "utf-8"), "a file");
712 await build(graph, (ref) => ref.writeFile("out/a/index.html", "in a directory again"));
713 assert.equal(await fs.readFile(p.path("out/a/index.html"), "utf-8"), "in a directory again");
714});
715
716test("a build deletes old outputs even when writing another one failed", async () => {
717 await using p = await project({ "out/b/kept.txt": "not an output" });
718 const graph = p.graph();
719 await build(graph, (ref) => ref.writeFile("out/c.txt", "c"));
720 const failing = build(graph, async (ref) => {
721 await ref.writeFile("out/a.txt", "a");
722 await ref.writeFile("out/b", "a file where a directory is").catch(() => {});
723 });
724 await assert.rejects(failing, { name: "BuildError" });
725 await assert.rejects(fs.stat(p.path("out/c.txt")), { code: "ENOENT" });
726});
727
728test("a step's files are kept by the builds that reuse it, until one is not as it left it", async () => {
729 await using p = await project({ "a.txt": "1" });
730 let runs = 0;
731 const site = () =>
732 build(p.graph(), async (ref) => {
733 await ref.step(async (b) => {
734 runs++;
735 await b.writeFile("out/a.html", `<p>${await b.readFile("a.txt").text()}</p>`);
736 await b.writeFile("out/b.html", "b");
737 }).read();
738 });
739 await site();
740 await site();
741 assert.equal(runs, 1);
742 assert.equal(await fs.readFile(p.path("out/a.html"), "utf-8"), "<p>1</p>");
743
744 await fs.rm(p.path("out/b.html"));
745 await site();
746 assert.equal(runs, 2);
747 assert.equal(await fs.readFile(p.path("out/b.html"), "utf-8"), "b");
748
749 await fs.writeFile(p.path("out/a.html"), "tampered");
750 await site();
751 assert.equal(runs, 3);
752 assert.equal(await fs.readFile(p.path("out/a.html"), "utf-8"), "<p>1</p>");
753});
754
755test("the files a step stops writing are deleted, and so are the files of a step no build uses", async () => {
756 await using p = await project({ "pages.txt": "a b" });
757 let used = true;
758 const graph = p.graph();
759 const site = () =>
760 build(graph, async (ref) => {
761 if (!used) return;
762 await ref.step(async (b) => {
763 for (const page of (await b.readFile("pages.txt").text()).split(" ")) {
764 await b.writeFile(`out/${page}.html`, page);
765 }
766 }).read();
767 });
768 await site();
769 assert.deepEqual(await fs.readdir(p.path("out")), ["a.html", "b.html"]);
770 await p.write("pages.txt", "a");
771 await site();
772 assert.deepEqual(await fs.readdir(p.path("out")), ["a.html"]);
773 used = false;
774 await site();
775 await assert.rejects(fs.stat(p.path("out")), { code: "ENOENT" });
776});
777
778test("a file a reused step wrote can't be written by anything else, even the same way", async () => {
779 await using p = await project();
780 const graph = p.graph();
781 const writer = (ref: incremental.Ref) => ref.step(async (b) => void (await b.writeFile("out.txt", "same"))).read();
782 await build(graph, writer);
783 const error = await build(graph, async (ref) => {
784 await ref.writeFile("out.txt", "same");
785 await writer(ref);
786 }).catch((error) => error);
787 assert.match(error.errors[0]!.message, /out\.txt is written twice/);
788});
789
790// ## builds
791test("a build deletes the steps it did not use", async () => {
792 await using p = await project();
793 let both = true;
794 const graph = p.graph();
795 const site = () =>
796 build(graph, async (ref) => {
797 await ref.step(function kept() {
798 return 1;
799 }).read();
800 if (both) {
801 await ref.step(function dropped() {
802 return 2;
803 }).read();
804 }
805 });
806 const labels = () =>
807 (graph.db.sql.prepare("select label from steps where label like '% at %'").all() as Array<{ label: string }>)
808 .map((row) => row.label.split(" ")[0]).sort();
809 await site();
810 assert.deepEqual(labels(), ["dropped", "kept"]);
811 both = false;
812 await site();
813 assert.deepEqual(labels(), ["kept"]);
814});
815
816test("an aborted build keeps only what finished before the abort", async () => {
817 await using p = await project();
818 const runs = { fast: 0, slow: 0 };
819 const graph = p.graph();
820 const site = (controller?: AbortController) =>
821 graph.incrementalBuild({
822 progress: new progress.Root(),
823 signal: controller?.signal,
824 async buildAndWriteOutput(ref) {
825 await ref.step(function fast() {
826 runs.fast++;
827 return 1;
828 }).read();
829 await ref.step(async function slow() {
830 runs.slow++;
831 controller?.abort();
832 await new Promise((resolve) => setTimeout(resolve, 10));
833 return 2;
834 }).read();
835 },
836 });
837 await assert.rejects(site(new AbortController()), { name: "AbortError" });
838 await site();
839 assert.deepEqual(runs, { fast: 1, slow: 2 });
840});
841
842test("a build waits for another process holding the database", async () => {
843 await using p = await project();
844 const graph = p.graph();
845 const holder = child_process.spawn("sleep", ["30"]);
846 try {
847 graph.db.sql.prepare("update incremental set lock = ?").run(UNWRAP(holder.pid));
848 let built = false;
849 const done = build(graph, async () => void (built = true));
850 await new Promise((resolve) => setTimeout(resolve, 400));
851 assert.equal(built, false);
852 holder.kill();
853 await done;
854 assert.equal(built, true);
855 } finally {
856 holder.kill();
857 }
858});
859
860test("watching rebuilds once an input of the last build changes", async () => {
861 await using p = await project({ "a.txt": "1" });
862 const results = watch(p.graph(), async (ref) => {
863 const text = await ref.step(async (b) => await b.readFile("a.txt").text()).read();
864 if (text === "bad") throw new Error("bad");
865 return text;
866 });
867 const { ok, changed: cause } = await next(results);
868 assert.deepEqual({ ok, cause }, { ok: true, cause: [] });
869 assert.equal(
870 await changed(results, p.path("a.txt"), () => p.write("a.txt", "bad")).catch(String),
871 "BuildError: build has 1 error",
872 );
873 assert.equal(await changed(results, p.path("a.txt"), () => p.write("a.txt", "2")), "2");
874 await results.return();
875});
876
877test("a watched build says what changed, what it did, and what it watches", async () => {
878 await using p = await project({ "a.txt": "1", "b.txt": "x" });
879 const causes: string[][] = [];
880 const pipeline = async (ref: incremental.Ref, changed: readonly string[]) => {
881 causes.push([...changed]);
882 const a = ref.step(async (b) => await b.readFile("a.txt").text());
883 const b = ref.step(async (b) => await b.readFile("b.txt").text());
884 await ref.writeFile("out.txt", a);
885 return await a.read() + await b.read();
886 };
887 const work = ({ stats }: incremental.WatchResult<string>) => [stats.ran, stats.reused, stats.written];
888
889 const cold = watch(p.graph(), pipeline);
890 const built = await next(cold);
891 assert.deepEqual(work(built), [2, 0, 1]);
892 assert.deepEqual(Object.keys(built.watching), ["file"]);
893 assert.ok(built.watching.file >= 2);
894 assert.equal(built.statusLine, `Watching ${built.watching.file} files`);
895 assert.ok(built.stats.cacheBytes > 0);
896 assert.equal(built.stats.outputBytes, 1);
897 await cold.return();
898
899 // a new process finds nothing to do
900 const results = watch(p.graph(), pipeline);
901 const reused = await next(results);
902 assert.deepEqual(work(reused), [0, 2, 0]);
903 assert.equal(reused.statusLine, `Build is up to date. Watching ${reused.watching.file} files`);
904
905 await new Promise((resolve) => setTimeout(resolve, 200));
906 await p.write("a.txt", "2");
907 const href = url.pathToFileURL(p.path("a.txt")).href;
908 let rebuilt = await next(results);
909 while (!rebuilt.changed.includes(href)) rebuilt = await next(results);
910 assert.deepEqual(work(rebuilt), [1, 1, 1]);
911 assert.deepEqual(causes.at(-1), rebuilt.changed);
912 assert.equal(rebuilt.statusLine, `Watching ${rebuilt.watching.file} files`);
913 assert.ok(rebuilt.stats.duration > 0);
914 await results.return();
915});
916
917test("watching trusts only inputs that were watched for the whole build", async () => {
918 await using p = await project({ "a.txt": "1", "b.txt": "x" });
919 let edit = true;
920 const results = watch(p.graph(), async (ref) => {
921 const a = await ref.step(async (b) => await b.readFile("a.txt").text()).read();
922 if (edit) {
923 // nothing is watched during the first build, so this goes unseen
924 edit = false;
925 await p.write("a.txt", "edited");
926 await new Promise((resolve) => setTimeout(resolve, 500));
927 }
928 return a + await ref.step(async (b) => await b.readFile("b.txt").text()).read();
929 });
930 assert.equal(await changed(results), "1x");
931 assert.equal(await changed(results, p.path("b.txt"), () => p.write("b.txt", "y")), "editedy");
932 await results.return();
933});
934
935test("while watching, a source that can't report changes is read every build", async () => {
936 let value = "one";
937 const memory = incremental.source({ protocol: "memory", read: () => ({ value }) });
938 await using p = await project({ "a.txt": "1" });
939 const graph = new incremental.Graph({ db: ".clover/incremental.db", root: p.dir, sources: [memory] });
940 const results = watch(graph, async (ref) => {
941 const fromSource = ref.step(async (b) => await memory.query(b, {}));
942 const fromFile = ref.step(async (b) => await b.readFile("a.txt").text());
943 return `${await fromSource.read()} ${await fromFile.read()}`;
944 });
945 assert.equal(await changed(results), "one 1");
946 assert.equal(await changed(results, p.path("a.txt"), () => p.write("a.txt", "2")), "one 2");
947 value = "two";
948 assert.equal(await changed(results, p.path("a.txt"), () => p.write("a.txt", "3")), "two 3");
949 await results.return();
950});
951
952test("watching without files trusts what was read, and still rebuilds for a source", async () => {
953 let value = "one";
954 let report = () => {};
955 const memory = incremental.source({
956 protocol: "memory",
957 read: () => ({ value }),
958 watch: (_, onChange) => {
959 report = onChange;
960 return { [Symbol.dispose]() {} };
961 },
962 });
963 await using p = await project({ "a.txt": "1" });
964 const graph = new incremental.Graph({ db: ".clover/incremental.db", root: p.dir, sources: [memory] });
965 const results = graph.incrementalWatch({
966 progress: new progress.Root(),
967 debounce: 10,
968 files: false,
969 buildAndWriteOutput: async (ref) => {
970 const fromSource = ref.step(async (b) => await memory.query(b, {}));
971 const fromFile = ref.step(async (b) => await b.readFile("a.txt").text());
972 return `${await fromSource.read()} ${await fromFile.read()}`;
973 },
974 });
975 assert.equal(await changed(results), "one 1");
976
977 await p.write("a.txt", "2");
978 value = "two";
979 report();
980 assert.equal(await changed(results), "two 1");
981 await results.return();
982});
983
984test("replacing a watched directory changes what was read in it", async () => {
985 await using p = await project({ "content/a.txt": "old", "staging/a.txt": "new", "z.txt": "" });
986 const results = watch(
987 p.graph(),
988 (ref) =>
989 ref.step(async (b) => (await b.readFile("content/a.txt").text()) + await b.readFile("z.txt").text()).read(),
990 );
991 assert.equal(await changed(results), "old");
992 assert.equal(await changed(results, p.path("z.txt"), () => p.write("z.txt", "!")), "old!");
993 const replace = async () => {
994 await fs.rename(p.path("content"), p.path("trash"));
995 await fs.rename(p.path("staging"), p.path("content"));
996 };
997 assert.equal(await changed(results, p.path("content/a.txt"), replace), "new!");
998 await results.return();
999});
1000
1001test("a copy's source stays watched while the copy exists", async () => {
1002 await using p = await project({ "static/a.css": "a", "z.txt": "" });
1003 const results = watch(p.graph(), async (ref) => {
1004 await ref.step(async (b) => await b.readFile("z.txt").text()).read();
1005 await ref.copyFile("out/a.css", "static/a.css");
1006 });
1007 await changed(results);
1008 // builds that trust the source don't check it, which must not forget it
1009 for (const z of ["1", "2"]) await changed(results, p.path("z.txt"), () => p.write("z.txt", z));
1010 await changed(results, p.path("static/a.css"), () => p.write("static/a.css", "b"));
1011 assert.equal(await fs.readFile(p.path("out/a.css"), "utf-8"), "b");
1012 await results.return();
1013});
1014
1015test("the later builds of a watch reuse what they trust without reading the database", async () => {
1016 await using p = await project({ "layout.txt": "-", "a.txt": "1", "b.txt": "x" });
1017 const graph = p.graph();
1018 const results = watch(graph, async (ref) => {
1019 const layout = ref.step(async (b) => await b.readFile("layout.txt").text());
1020 await ref.step(async (b) => void (await b.writeFile("out/layout.txt", await b.readStep(layout)))).read();
1021 const pages = ["a.txt", "b.txt"].map((file) =>
1022 ref.step(async (b, file) => await b.readStep(layout) + await b.readFile(file).text(), file)
1023 );
1024 await Promise.all(pages.map((page, i) => ref.writeFile(`out/${i}.txt`, page)));
1025 return (await Promise.all(pages.map((page) => page.read()))).join(" ");
1026 });
1027 assert.equal(await changed(results), "-1 -x");
1028 // the second build checks everything the first read, which nothing watched yet
1029 assert.equal(await changed(results, p.path("a.txt"), () => p.write("a.txt", "2")), "-2 -x");
1030
1031 const reads = vi.spyOn(graph.db, "readStep");
1032 const stats = vi.spyOn(graph.fs, "stat");
1033 onTestFinished(() => stats.mockRestore());
1034 await new Promise((resolve) => setTimeout(resolve, 200));
1035 await p.write("a.txt", "3");
1036 const href = url.pathToFileURL(p.path("a.txt")).href;
1037 let rebuilt = await next(results);
1038 while (!rebuilt.changed.includes(href)) rebuilt = await next(results);
1039 assert.ok(rebuilt.ok);
1040 assert.equal(rebuilt.value, "-3 -x");
1041 assert.deepEqual([rebuilt.stats.ran, rebuilt.stats.reused, rebuilt.stats.written], [1, 3, 1]);
1042 assert.equal(reads.mock.calls.length, 0);
1043 // the edited file and the output it changes, and none of the paths that
1044 // resolving this file's imports looked for
1045 assert.deepEqual(new Set(stats.mock.calls.flat()), new Set([p.path("a.txt"), p.path("out/0.txt")]));
1046 assert.equal(await fs.readFile(p.path("out/0.txt"), "utf-8"), "-3");
1047 assert.equal(await fs.readFile(p.path("out/layout.txt"), "utf-8"), "-");
1048 await results.return();
1049});
1050
1051test("a watch hands the next build a value that did not change as the same object", async () => {
1052 await using p = await project({ "a.txt": "1", "z.txt": "" });
1053 const graph = p.graph();
1054 const results = watch(graph, async (ref) => {
1055 await ref.step(async (b) => await b.readFile("z.txt").text()).read();
1056 return await ref.step(async (b) => ({ text: await b.readFile("a.txt").text() })).read();
1057 });
1058 const first = await changed(results);
1059 const reads = vi.spyOn(graph.db, "readValue");
1060 for (const z of ["1", "2"]) assert.equal(await changed(results, p.path("z.txt"), () => p.write("z.txt", z)), first);
1061 assert.equal(reads.mock.calls.length, 0);
1062 assert.deepEqual(await changed(results, p.path("a.txt"), () => p.write("a.txt", "2")), { text: "2" });
1063 await results.return();
1064});
1065
1066test("each instance of a json module gets its own copy of the value a watch hands on", async () => {
1067 const code =
1068 "import data from './data.json' with { type: 'json' }; data.items.push('b'); export default data.items.length;";
1069 await using p = await project({
1070 "package.json": JSON.stringify({ type: "module" }),
1071 "data.json": JSON.stringify({ items: ["a"] }),
1072 "m.ts": code,
1073 });
1074 const results = watch(
1075 p.graph(),
1076 (ref) => ref.step(async (b) => (await b.import<{ default: number }>("m.ts")).default).read(),
1077 );
1078 assert.equal(await changed(results), 2);
1079 for (const edit of ["// 1", "// 2"]) {
1080 assert.equal(await changed(results, p.path("m.ts"), () => p.write("m.ts", code + edit)), 2);
1081 }
1082 await results.return();
1083});
1084
1085test("a watch lets go of the values of the builds before the last", async () => {
1086 v8.setFlagsFromString("--expose-gc");
1087 const gc = vm.runInNewContext("gc") as () => void;
1088 await using p = await project({ "a.txt": "1" });
1089 const results = watch(p.graph(), (ref) => ref.step(async (b) => ({ text: await b.readFile("a.txt").text() })).read());
1090 const first = new WeakRef(await changed(results));
1091 for (const a of ["2", "3"]) await changed(results, p.path("a.txt"), () => p.write("a.txt", a));
1092 gc();
1093 assert.equal(first.deref(), undefined);
1094 await results.return();
1095});
1096
1097test("a watch runs a step again when a file it wrote could not be written", async () => {
1098 await using p = await project({ "out/b/kept.txt": "in the way", "z.txt": "" });
1099 const results = watch(p.graph(), async (ref) => {
1100 await ref.step(async (b) => await b.readFile("z.txt").text()).read();
1101 await ref.step(async (b) => void (await b.writeFile("out/b", "b"))).read();
1102 });
1103 await assert.rejects(changed(results), { name: "BuildError" });
1104 await fs.rm(p.path("out/b"), { recursive: true });
1105 await changed(results, p.path("z.txt"), () => p.write("z.txt", "1"));
1106 assert.equal(await fs.readFile(p.path("out/b"), "utf-8"), "b");
1107 await results.return();
1108});
1109
1110test("a watch keeps reporting the errors of the steps it reuses", async () => {
1111 await using p = await project({ "z.txt": "" });
1112 let builds = 0;
1113 const results = watch(p.graph(), async (ref) => {
1114 await ref.step(async (b) => await b.readFile("z.txt").text()).read();
1115 // only the first build starts the step that the other one read
1116 const careful = builds++ === 0 ? ref.step((b) => b.warn(new Error("careful"))) : null;
1117 await ref.step(async (b) => void (await b.readStep(UNWRAP(careful)))).read();
1118 });
1119 assert.equal(await changed(results).catch(String), "BuildError: build has 1 warning");
1120 for (const z of ["1", "2", "3"]) {
1121 const rebuilt = changed(results, p.path("z.txt"), () => p.write("z.txt", z));
1122 assert.equal(await rebuilt.catch(String), "BuildError: build has 1 warning");
1123 }
1124 await results.return();
1125});
1126
1127test("a watch deletes the steps and outputs a build stops using", async () => {
1128 await using p = await project({ "which.txt": "both", "z.txt": "" });
1129 const graph = p.graph();
1130 let runs = 0;
1131 const results = watch(graph, async (ref) => {
1132 const which = await ref.step(async (b) => await b.readFile("which.txt").text()).read();
1133 await ref.step(async (b) => await b.readFile("z.txt").text()).read();
1134 if (which !== "both") return;
1135 const dropped = ref.step(function dropped() {
1136 runs++;
1137 return "x";
1138 });
1139 await ref.writeFile("out.txt", dropped);
1140 });
1141 const rows = () => graph.db.sql.prepare("select label from steps where label like 'dropped at %'").all().length;
1142 await changed(results);
1143 for (const z of ["1", "2"]) await changed(results, p.path("z.txt"), () => p.write("z.txt", z));
1144 assert.equal(rows(), 1);
1145
1146 await changed(results, p.path("which.txt"), () => p.write("which.txt", "one"));
1147 assert.equal(rows(), 0);
1148 await assert.rejects(fs.stat(p.path("out.txt")));
1149
1150 await changed(results, p.path("which.txt"), () => p.write("which.txt", "both"));
1151 assert.equal(runs, 2);
1152 assert.equal(await fs.readFile(p.path("out.txt"), "utf-8"), "x");
1153 await results.return();
1154});
1155
1156test("a watch reads the database again once another process has built with it", async () => {
1157 await using p = await project({ "a.txt": "1", "z.txt": "" });
1158 let runs = 0;
1159 const results = watch(p.graph(), async (ref) => {
1160 const a = ref.step(async (b) => {
1161 runs++;
1162 return await b.readFile("a.txt").text();
1163 });
1164 await ref.writeFile("out.txt", a);
1165 return await a.read() + await ref.step(async (b) => await b.readFile("z.txt").text()).read();
1166 });
1167 await changed(results);
1168 for (const z of ["1", "2"]) await changed(results, p.path("z.txt"), () => p.write("z.txt", z));
1169 assert.equal(runs, 1);
1170
1171 // a build of something else deletes the steps and outputs it has no use for
1172 await build(p.graph(), async () => {});
1173 await assert.rejects(fs.stat(p.path("out.txt")));
1174 assert.equal(await changed(results, p.path("z.txt"), () => p.write("z.txt", "3")), "13");
1175 assert.equal(runs, 2);
1176 assert.equal(await fs.readFile(p.path("out.txt"), "utf-8"), "1");
1177 await results.return();
1178});
1179
1180test("a watch stops watching a file once no step reads it", async () => {
1181 await using p = await project({ "which.txt": "ab", "a.txt": "1", "b.txt": "x" });
1182 const results = watch(p.graph(), async (ref) => {
1183 const which = await ref.step(async (b) => await b.readFile("which.txt").text()).read();
1184 const files = which === "ab" ? ["a.txt", "b.txt"] : ["a.txt"];
1185 const texts = files.map((file) => ref.step(async (b, file) => await b.readFile(file).text(), file).read());
1186 return (await Promise.all(texts)).join("");
1187 });
1188 const href = (file: string) => url.pathToFileURL(p.path(file)).href;
1189 const first = await next(results);
1190
1191 await new Promise((resolve) => setTimeout(resolve, 200));
1192 await p.write("which.txt", "a");
1193 let narrowed = await next(results);
1194 while (!narrowed.changed.includes(href("which.txt"))) narrowed = await next(results);
1195 assert.equal(narrowed.watching.file, first.watching.file - 1);
1196
1197 await p.write("b.txt", "y");
1198 await new Promise((resolve) => setTimeout(resolve, 200));
1199 await p.write("a.txt", "2");
1200 let rebuilt = await next(results);
1201 for (; !rebuilt.changed.includes(href("a.txt")); rebuilt = await next(results)) {
1202 assert.ok(!rebuilt.changed.includes(href("b.txt")));
1203 }
1204 assert.deepEqual(rebuilt.changed.filter((changed) => changed === href("b.txt")), []);
1205 await results.return();
1206});
1207
1208test("a file that a later build of a watch starts to read is checked by the next", async () => {
1209 await using p = await project({ "which.txt": "ab", "a.txt": "1", "b.txt": "x" });
1210 let edit = false;
1211 const results = watch(p.graph(), async (ref) => {
1212 const which = await ref.step(async (b) => await b.readFile("which.txt").text()).read();
1213 const a = await ref.step(async (b) => await b.readFile("a.txt").text()).read();
1214 if (which === "a") return a;
1215 const b = await ref.step(async (b) => await b.readFile("b.txt").text()).read();
1216 if (edit) {
1217 // the watcher reports the files that the last build read, so this goes unseen
1218 edit = false;
1219 await p.write("b.txt", "edited");
1220 await new Promise((resolve) => setTimeout(resolve, 500));
1221 }
1222 return a + b;
1223 });
1224 assert.equal(await changed(results), "1x");
1225 // by now the file is trusted, until no step reads it
1226 assert.equal(await changed(results, p.path("a.txt"), () => p.write("a.txt", "2")), "2x");
1227 assert.equal(await changed(results, p.path("which.txt"), () => p.write("which.txt", "a")), "2");
1228
1229 edit = true;
1230 assert.equal(await changed(results, p.path("which.txt"), () => p.write("which.txt", "ab")), "2x");
1231 assert.equal(await changed(results, p.path("a.txt"), () => p.write("a.txt", "3")), "3edited");
1232 await results.return();
1233});
1234
1235test("a watch reads a module again after an import between its builds reloaded it", async () => {
1236 await using p = await project({ "mod.ts": "export default 'one';" });
1237 const graph = p.graph();
1238 const results = watch(
1239 graph,
1240 (ref) => ref.step(async (b) => (await b.import<{ default: string }>("mod.ts")).default).read(),
1241 );
1242 const imported = async () => (await graph.import<{ default: string }>("mod.ts")).default;
1243 assert.equal(await changed(results), "one");
1244
1245 await p.write("mod.ts", "export default 'two';");
1246 assert.equal(await imported(), "two");
1247 await p.write("mod.ts", "export default 'one';");
1248 assert.equal(await changed(results), "one");
1249 assert.equal(await imported(), "one");
1250 await results.return();
1251});
1252
1253test("an import between the builds of a watch reuses what the last build checked", async () => {
1254 await using p = await project({ "mod.ts": "export default 'one';", "z.txt": "" });
1255 const graph = p.graph();
1256 const results = watch(graph, async (ref) => {
1257 await ref.step(async (b) => await b.readFile("z.txt").text()).read();
1258 return await ref.step(async (b) => (await b.import<{ default: string }>("mod.ts")).default).read();
1259 });
1260 await changed(results);
1261 await changed(results, p.path("z.txt"), () => p.write("z.txt", "1"));
1262 const reads = vi.spyOn(graph.db, "readStep");
1263 assert.equal((await graph.import<{ default: string }>("mod.ts")).default, "one");
1264 assert.equal(reads.mock.calls.length, 0);
1265 await results.return();
1266});
1267
1268test("a watch builds again once code that only an import between its builds loaded changes", async () => {
1269 await using p = await project({
1270 "package.json": JSON.stringify({ type: "module" }),
1271 "server.ts": "export default ;",
1272 "z.txt": "",
1273 });
1274 const graph = p.graph();
1275 const results = watch(graph, (ref) => ref.step(async (b) => await b.readFile("z.txt").text()).read());
1276 const imported = async () => (await graph.import<{ default: string }>("server.ts")).default;
1277 await changed(results);
1278 // like a server that loads its code once a build is done, which can fail to load
1279 await assert.rejects(imported());
1280 await changed(results, p.path("server.ts"), () => p.write("server.ts", "export default 'one';"));
1281 assert.equal(await imported(), "one");
1282 await changed(results, p.path("server.ts"), () => p.write("server.ts", "export default 'two';"));
1283 assert.equal(await imported(), "two");
1284 await results.return();
1285});
1286
1287test("a watch trusts the contents of a file that a module also looked for", async () => {
1288 await using p = await project({
1289 "package.json": JSON.stringify({ type: "module", imports: { "#dep": "./dep.ts" } }),
1290 "mod.ts": "import dep from './dep.ts'; export default dep + import.meta.resolve('#dep').slice(-6);",
1291 "dep.ts": "export default 'dep ';",
1292 "z.txt": "",
1293 });
1294 const graph = p.graph();
1295 const results = watch(graph, async (ref) => {
1296 await ref.step(async (b) => await b.readFile("z.txt").text()).read();
1297 return await ref.step(async (b) => (await b.import<{ default: string }>("mod.ts")).default).read();
1298 });
1299 assert.equal(await changed(results), "dep dep.ts");
1300 for (const z of ["1", "2"]) await changed(results, p.path("z.txt"), () => p.write("z.txt", z));
1301
1302 const stats = vi.spyOn(graph.fs, "stat");
1303 onTestFinished(() => stats.mockRestore());
1304 for (const z of ["3", "4"]) await changed(results, p.path("z.txt"), () => p.write("z.txt", z));
1305 assert.deepEqual(new Set(stats.mock.calls.flat()), new Set([p.path("z.txt")]));
1306 await results.return();
1307});
1308
1309test("a watch notices a file appearing where a step looked for one", async () => {
1310 await using p = await project({ "sub/mod.js": "module.exports = 1;", "z.txt": "" });
1311 const results = watch(p.graph(), async (ref) => {
1312 await ref.step(async (b) => await b.readFile("z.txt").text()).read();
1313 const formats = ref.step(async (b) => (await b.trackModule("sub/mod.js")).map((node) => node.format));
1314 return (await formats.read()).join();
1315 });
1316 assert.equal(await changed(results), "cjs");
1317 // by now, the package.json that is missing is trusted to stay missing
1318 for (const z of ["1", "2"]) await changed(results, p.path("z.txt"), () => p.write("z.txt", z));
1319 const made = () => p.write("sub/package.json", JSON.stringify({ type: "module" }));
1320 assert.equal(await changed(results, p.path("sub/package.json"), made), "js");
1321 await results.return();
1322});
1323
1324// ## lazy steps
1325test("a lazy step runs when it is first asked for, and is reused until what it read changes", async () => {
1326 await using p = await project({ "a.txt": "1" });
1327 let runs = 0;
1328 const page = (graph: incremental.Graph) =>
1329 graph.lazy(async (b, name) => {
1330 runs++;
1331 return name + await b.readFile("a.txt").text();
1332 }, "page").read();
1333 assert.equal(await page(p.graph()), "page1");
1334 // like after a restart
1335 assert.equal(await page(p.graph()), "page1");
1336 assert.equal(runs, 1);
1337 await p.write("a.txt", "2");
1338 assert.equal(await page(p.graph()), "page2");
1339 assert.equal(runs, 2);
1340});
1341
1342test("calls for a lazy step that is running wait for it", async () => {
1343 await using p = await project();
1344 const graph = p.graph();
1345 let runs = 0;
1346 const slow = () =>
1347 graph.lazy(async () => {
1348 runs++;
1349 await new Promise((resolve) => setTimeout(resolve, 50));
1350 return "slow";
1351 }).read();
1352 assert.deepEqual(await Promise.all([slow(), slow()]), ["slow", "slow"]);
1353 assert.equal(runs, 1);
1354});
1355
1356test("a lazy step that throws runs again on the next call", async () => {
1357 await using p = await project();
1358 let runs = 0;
1359 const picture = (graph: incremental.Graph) =>
1360 graph.lazy(async () => {
1361 runs++;
1362 if (runs === 1) throw new Error("the browser did not start");
1363 return "picture";
1364 }).read();
1365 const graph = p.graph();
1366 await assert.rejects(picture(graph), /the browser did not start/);
1367 assert.equal(await picture(graph), "picture");
1368 // like after a restart
1369 assert.equal(await picture(p.graph()), "picture");
1370 assert.equal(runs, 2);
1371});
1372
1373test("a build keeps lazy steps, and the steps they read, which it does not use", async () => {
1374 await using p = await project({ "layout.txt": "-" });
1375 const runs = { layout: 0, page: 0 };
1376 const site = (graph: incremental.Graph, layout: boolean) =>
1377 build(graph, async (ref) =>
1378 layout
1379 ? ref.step(async (b) => {
1380 runs.layout++;
1381 return await b.readFile("layout.txt").text();
1382 })
1383 : null);
1384 const page = (graph: incremental.Graph, layout: incremental.Step<string>) =>
1385 graph.lazy(async (b, layout) => {
1386 runs.page++;
1387 return await b.readStep(layout) + "page";
1388 }, layout).read();
1389 const graph = p.graph();
1390 assert.equal(await page(graph, UNWRAP(await site(graph, true))), "-page");
1391 await site(graph, false);
1392
1393 const restarted = p.graph();
1394 assert.equal(await page(restarted, UNWRAP(await site(restarted, true))), "-page");
1395 assert.deepEqual(runs, { layout: 1, page: 1 });
1396});
1397
1398test("a build that changes a step a lazy step read deletes it, and the lazy steps that read it", async () => {
1399 await using p = await project({ "style.css": "a" });
1400 const site = (graph: incremental.Graph) =>
1401 build(graph, async (ref) => ref.step(async (b) => await b.readFile("style.css").text()));
1402 const lazy = (graph: incremental.Graph) => graph.db.readGraph().steps.filter((step) => step.lazy).length;
1403 const graph = p.graph();
1404 const page = graph.lazy(async (b, style) => "page " + await b.readStep(style), await site(graph));
1405 assert.equal(await graph.lazy(async (b, page) => await b.readStep(page) + "!", page).read(), "page a!");
1406 await site(graph);
1407 assert.equal(lazy(graph), 2);
1408
1409 await p.write("style.css", "b");
1410 const style = await site(graph);
1411 assert.equal(lazy(graph), 0);
1412 assert.equal(await graph.lazy(async (b, style) => "page " + await b.readStep(style), style).read(), "page b");
1413});
1414
1415test("lazy steps past the graph's bytes go, the ones used least recently first", async () => {
1416 await using p = await project();
1417 const graph = new incremental.Graph({ db: ".clover/incremental.db", root: p.dir, lazyBytes: 2500 });
1418 const runs = new Map<number, number>();
1419 const page = (n: number) =>
1420 graph.lazy((_, n) => {
1421 runs.set(n, (runs.get(n) ?? 0) + 1);
1422 return String(n).repeat(1000);
1423 }, n).read();
1424 await page(1);
1425 await page(2);
1426 await page(1);
1427 // over the budget, and the second page was used least recently
1428 await page(3);
1429 for (const n of [1, 3, 2]) await page(n);
1430 assert.deepEqual(Object.fromEntries(runs), { 1: 1, 2: 2, 3: 1 });
1431});
1432
1433test("a lazy step goes with the lazy steps it read", async () => {
1434 await using p = await project();
1435 const graph = new incremental.Graph({ db: ".clover/incremental.db", root: p.dir, lazyBytes: 2500 });
1436 const runs = { inner: 0, outer: 0, big: 0 };
1437 const inner = () =>
1438 graph.lazy(function inner() {
1439 runs.inner++;
1440 return "inner";
1441 });
1442 const outer = () =>
1443 graph.lazy(async function outer(b, inner) {
1444 runs.outer++;
1445 return await b.readStep(inner) + "!";
1446 }, inner());
1447 assert.equal(await outer().read(), "inner!");
1448 // used later than the inner step, which it read
1449 await outer().read();
1450 await graph.lazy(function big() {
1451 runs.big++;
1452 return "x".repeat(2500);
1453 }).read();
1454 assert.equal(await outer().read(), "inner!");
1455 assert.deepEqual(runs, { inner: 2, outer: 2, big: 1 });
1456});
1457
1458test("a step's hash stands for its value", async () => {
1459 await using p = await project();
1460 const graph = p.graph();
1461 const [a, b, c] = ["same", "same", "other"].map((value, i) =>
1462 graph.lazy((_, value: string, _i: number) => value, value, i)
1463 );
1464 assert.equal(await UNWRAP(a).hash(), await UNWRAP(b).hash());
1465 assert.notEqual(await UNWRAP(a).hash(), await UNWRAP(c).hash());
1466 // a step reads the value instead, which makes it a dependency
1467 const inside = graph.lazy(async (_, other) => await other.hash(), UNWRAP(c));
1468 await assert.rejects(inside.read(), /can't read another step's hash/);
1469});
1470
1471test("a lazy step can't write files, which builds delete", async () => {
1472 await using p = await project();
1473 const step = p.graph().lazy(async (b) => void (await b.writeFile("out.txt", "x")));
1474 await assert.rejects(step.read(), /a lazy step can't write files/);
1475});
1476
1477test("while watching, a lazy step trusts what it read once it is watched, until it changes", async () => {
1478 await using p = await project({ "a.txt": "1", "z.txt": "" });
1479 const graph = p.graph();
1480 const results = watch(graph, async (ref) => await ref.step(async (b) => await b.readFile("z.txt").text()).read());
1481 await changed(results);
1482 let runs = 0;
1483 const page = () =>
1484 graph.lazy(async (b) => {
1485 runs++;
1486 return await b.readFile("a.txt").text();
1487 }).read();
1488 // first read before the watcher has the file, then checked again once it does
1489 assert.equal(await page(), "1");
1490 assert.equal(await page(), "1");
1491 const stats = vi.spyOn(graph.fs, "stat");
1492 onTestFinished(() => stats.mockRestore());
1493 assert.equal(await page(), "1");
1494 assert.deepEqual(stats.mock.calls, []);
1495 assert.equal(runs, 1);
1496
1497 await changed(results, p.path("a.txt"), () => p.write("a.txt", "2"));
1498 assert.equal(await page(), "2");
1499 assert.equal(runs, 2);
1500 await results.return();
1501});
1502
1503/**
1504 * the value of the next build, or with `file`, of the first build after `act`
1505 * changes it. a failed build throws its error.
1506 */
1507async function changed<T>(
1508 results: AsyncGenerator<incremental.WatchResult<T>, void>,
1509 file?: string,
1510 act?: () => Promise<unknown>,
1511): Promise<T> {
1512 const href = file && url.pathToFileURL(file).href;
1513 let acted = !act;
1514 const seen = (async () => {
1515 // this test file's imports are inputs too, so other changes can come first.
1516 // so can a second event for an earlier write to the same file.
1517 for (let next = await results.next(); !next.done; next = await results.next()) {
1518 if (!acted || (href && !next.value.changed.includes(href))) continue;
1519 if (!next.value.ok) throw next.value.error;
1520 return next.value.value;
1521 }
1522 throw new Error("stopped watching");
1523 })();
1524 if (act) {
1525 await new Promise((resolve) => setTimeout(resolve, 200));
1526 acted = true;
1527 await act();
1528 }
1529 return await seen;
1530}
1531
1532/** a project in a temporary directory, removed once disposed */
1533// ## observing
1534test("an observer hears each step run, checked, reused, and kept", async () => {
1535 await using p = await project({ "a.txt": "a", "b.txt": "b" });
1536 async function readA(b: incremental.Build) {
1537 return await b.readFile("a.txt").text();
1538 }
1539 async function readB(b: incremental.Build) {
1540 return await b.readFile("b.txt").text();
1541 }
1542 const pipeline = async (ref: incremental.Ref) => [await ref.step(readA).read(), await ref.step(readB).read()];
1543 const heard = (graph: incremental.Graph) => {
1544 const states: Record<string, string[]> = { readA: [], readB: [] };
1545 const observer = graph.observe((event) => {
1546 const name = event.label.split(" ")[0]!;
1547 if (!(name in states)) return;
1548 const read = "inputs" in event ? event.inputs.map((input) => path.basename(input.url)).join() : "";
1549 states[name]!.push(read ? `${event.state} ${read}` : event.state);
1550 });
1551 return { states, observer };
1552 };
1553
1554 const graph = p.graph();
1555 const first = heard(graph);
1556 const results = watch(graph, pipeline);
1557 await next(results);
1558 assert.deepEqual(first.states, {
1559 readA: ["checking", "running", "ran a.txt"],
1560 readB: ["checking", "running", "ran b.txt"],
1561 });
1562
1563 // inputs are trusted once they were watched for all of a build, so a.txt is checked once more
1564 for (const [contents, readA] of [["changed", ["checking", "reused"]], ["again", ["kept"]]] as const) {
1565 first.states.readA = [];
1566 first.states.readB = [];
1567 await p.write("b.txt", contents);
1568 await next(results);
1569 assert.deepEqual(first.states, { readA, readB: ["checking", "running", "ran b.txt"] });
1570 }
1571 first.observer[Symbol.dispose]();
1572 await results.return();
1573
1574 const restarted = p.graph();
1575 const second = heard(restarted);
1576 await build(restarted, pipeline);
1577 assert.deepEqual(second.states, { readA: ["checking", "reused"], readB: ["checking", "reused"] });
1578});
1579
1580test("an observer hears a lazy step as lazy, and the database lists it as lazy", async () => {
1581 await using p = await project({ "a.txt": "a" });
1582 const graph = p.graph();
1583 const heard: incremental.StepEvent[] = [];
1584 using _ = graph.observe((event) => void heard.push(event));
1585 async function readLazily(b: incremental.Build) {
1586 return await b.readFile("a.txt").text();
1587 }
1588 assert.equal(await graph.lazy(readLazily).read(), "a");
1589 const ran = UNWRAP(heard.find((event) => event.label.startsWith("readLazily") && event.state === "ran"));
1590 assert.equal(ran.lazy, true);
1591 assert.ok("inputs" in ran && ran.inputs.some((input) => input.kind === "file" && input.url.endsWith("/a.txt")));
1592 assert.equal(UNWRAP(graph.db.readGraph().steps.find((step) => step.label.startsWith("readLazily"))).lazy, true);
1593});
1594
1595test("an observer hears the files a step wrote, and the database lists them", async () => {
1596 await using p = await project();
1597 const graph = p.graph();
1598 const heard: incremental.StepEvent[] = [];
1599 using _ = graph.observe((event) => void heard.push(event));
1600 async function writePage(b: incremental.Build) {
1601 await b.writeFile("out/page.html", "page");
1602 }
1603 await build(graph, (ref) => ref.step(writePage).read());
1604 const written = [url.pathToFileURL(p.path("out/page.html")).href];
1605 const ran = UNWRAP(heard.find((event) => event.label.startsWith("writePage") && event.state === "ran"));
1606 assert.deepEqual("outputs" in ran && ran.outputs, written);
1607 assert.deepEqual(
1608 UNWRAP(graph.db.readGraph().steps.find((step) => step.label.startsWith("writePage"))).outputs,
1609 written,
1610 );
1611});
1612
1613test("builds of one graph can't overlap, even while one waits for the lock", async () => {
1614 await using p = await project();
1615 const graph = p.graph();
1616 const pipeline = async () => void (await new Promise((resolve) => setTimeout(resolve, 20)));
1617 const [first, second] = await Promise.allSettled([build(graph, pipeline), build(graph, pipeline)]);
1618 assert.equal(first.status, "fulfilled");
1619 assert.match(String(second.status === "rejected" && second.reason), /builds can not overlap/);
1620});
1621
1622test("a build can't write a file outside of its project", async () => {
1623 await using p = await project();
1624 const error = await build(p.graph(), (ref) => ref.writeFile("../outside.txt", "x")).catch((error) => error);
1625 assert.match(error.errors[0].message, /is outside of/);
1626});
1627
1628test("a watch waiting for a change stops once its signal aborts", async () => {
1629 await using p = await project({ "a.txt": "a" });
1630 const aborted = new AbortController();
1631 const results = p.graph().incrementalWatch({
1632 progress: new progress.Root(),
1633 signal: aborted.signal,
1634 buildAndWriteOutput: (ref) => ref.step(async (b) => await b.readFile("a.txt").text()).read(),
1635 });
1636 await next(results);
1637 const waiting = results.next();
1638 aborted.abort();
1639 await assert.rejects(waiting, { name: "AbortError" });
1640});
1641
1642test("a watch rebuilds once a missing file is made, where nothing else is watched", async () => {
1643 await using p = await project({ "content/other.txt": "" });
1644 const results = watch(p.graph(), (ref) =>
1645 ref.step(async (b) => {
1646 try {
1647 return await b.readFile("content/optional.txt").text();
1648 } catch {
1649 return "absent";
1650 }
1651 }).read());
1652 assert.equal((await next(results)).value, "absent");
1653 await p.write("content/optional.txt", "present");
1654 assert.equal((await next(results)).value, "present");
1655 await results.return();
1656});
1657
1658test("a source that reports params equal to the ones it watches rebuilds what read them", async () => {
1659 await using p = await project();
1660 let value = "one";
1661 let report = (_?: Array<{ key: string }>) => {};
1662 const kv = incremental.source({
1663 protocol: "kv",
1664 read: ({ key }: { key: string }) => ({ value: `${key}=${value}`, hash: value }),
1665 watch: (_, onChange) => {
1666 report = onChange;
1667 return { [Symbol.dispose]: () => {} };
1668 },
1669 });
1670 const graph = new incremental.Graph({ db: null, root: p.dir, sources: [kv] });
1671 const results = watch(graph, (ref) => ref.step(async (b) => await kv.query(b, { key: "a" })).read());
1672 assert.equal((await next(results)).value, "a=one");
1673 value = "two";
1674 report([{ key: "a" }]);
1675 assert.equal((await next(results)).value, "a=two");
1676 await results.return();
1677});
1678
1679test("a cached lazy step can be read after another lazy value evicts its row", async () => {
1680 await using p = await project();
1681 // checking the cached step waits here, while another one is made
1682 let open = Promise.resolve();
1683 let waiting = Promise.withResolvers<void>();
1684 const gate = incremental.source({
1685 protocol: "gate",
1686 async read() {
1687 waiting.resolve();
1688 await open;
1689 return { value: 1 };
1690 },
1691 });
1692 const graph = new incremental.Graph({ db: ".clover/incremental.db", root: p.dir, lazyBytes: 1500, sources: [gate] });
1693 async function big(b: incremental.Build, name: string) {
1694 if (name === "a") await gate.query(b, null);
1695 return name.repeat(1000);
1696 }
1697 // a lazy step is keyed by where it is asked for too
1698 const get = (name: string) => graph.lazy(big, name);
1699 await get("a").read();
1700 const opened = Promise.withResolvers<void>();
1701 open = opened.promise;
1702 waiting = Promise.withResolvers<void>();
1703 const cached = get("a");
1704 await waiting.promise;
1705 await get("b").read();
1706 opened.resolve();
1707 assert.equal(await cached.read(), "a".repeat(1000));
1708});
1709
1710test("a lazy step that read a file the watch saw change while it ran doesn't vouch for it", async () => {
1711 await using p = await project({ "a.txt": "1" });
1712 const graph = p.graph();
1713 const results = watch(graph, (ref) => ref.step(async (b) => await b.readFile("a.txt").text()).read());
1714 await next(results);
1715 let finish = () => {};
1716 const finished = new Promise<void>((resolve) => finish = resolve);
1717 async function read(b: incremental.Build, slow: boolean) {
1718 const text = await b.readFile("a.txt").text();
1719 if (slow) await finished;
1720 return text;
1721 }
1722 const page = (slow: boolean) => graph.lazy(read, slow).read();
1723 assert.equal(await page(false), "1");
1724 const slow = page(true);
1725 await new Promise((resolve) => setTimeout(resolve, 50));
1726 await p.write("a.txt", "2");
1727 assert.equal((await next(results)).value, "2");
1728 finish();
1729 assert.equal(await slow, "1");
1730 assert.equal(await page(false), "2");
1731 await results.return();
1732});
1733
1734async function project(files: Record<string, string> = {}) {
1735 const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "incremental-")));
1736 const write = async (name: string, contents: string) => {
1737 await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true });
1738 await fs.writeFile(path.join(dir, name), contents);
1739 };
1740 for (const [name, contents] of Object.entries(files)) await write(name, contents);
1741 return {
1742 dir,
1743 write,
1744 path: (name: string) => path.join(dir, name),
1745 /** a graph on the project's database, like a new process would make */
1746 graph: () => new incremental.Graph({ db: ".clover/incremental.db", root: dir }),
1747 [Symbol.asyncDispose]: () => fs.rm(dir, { recursive: true, force: true }),
1748 };
1749}
1750
1751function build<T>(
1752 graph: incremental.Graph,
1753 buildAndWriteOutput: (ref: incremental.Ref) => Promise<T>,
1754): Promise<T> {
1755 return graph.incrementalBuild({ progress: new progress.Root(), buildAndWriteOutput });
1756}
1757
1758/** the next build of a watch, which goes on until it is stopped */
1759async function next<T>(results: AsyncGenerator<incremental.WatchResult<T>, void>) {
1760 const result = await results.next();
1761 assert.ok(!result.done);
1762 return result.value;
1763}
1764
1765function watch<T>(
1766 graph: incremental.Graph,
1767 buildAndWriteOutput: (ref: incremental.Ref, changed: readonly string[]) => Promise<T>,
1768): AsyncGenerator<incremental.WatchResult<T>, void> {
1769 return graph.incrementalWatch({ progress: new progress.Root(), buildAndWriteOutput, debounce: 10 });
1770}
1771
1772import assert from "node:assert/strict";
1773import * as child_process from "node:child_process";
1774import * as fs from "node:fs/promises";
1775import * as os from "node:os";
1776import * as path from "node:path";
1777import * as url from "node:url";
1778import * as v8 from "node:v8";
1779import * as vm from "node:vm";
1780import { onTestFinished, test, vi } from "vitest";
1781import { UNWRAP } from "../assert.ts";
1782import * as incremental from "../incremental.ts";
1783import * as stack from "../log/stack.ts";
1784import * as progress from "../progress.ts";