| 1 | // ## caching |
| 2 | test("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 | |
| 22 | test("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 | |
| 67 | test("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 | |
| 89 | test("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 | |
| 117 | test("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 | |
| 127 | test("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 | |
| 142 | test("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 | |
| 169 | test("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 | |
| 179 | test("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 | |
| 187 | test("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 | |
| 208 | test("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 | |
| 238 | test("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 | |
| 262 | test("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 |
| 279 | test("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 | |
| 322 | test("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 | |
| 348 | test("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 | |
| 384 | test("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 | |
| 404 | test("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 | |
| 417 | test("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 | |
| 442 | test("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 | |
| 459 | test("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 |
| 483 | test("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 | |
| 504 | test("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 | |
| 524 | test("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 | |
| 548 | test("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 | |
| 579 | test("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 | |
| 606 | test("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 | |
| 623 | test("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 | |
| 633 | test("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 | |
| 641 | test("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 |
| 671 | test("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 | |
| 706 | test("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 | |
| 716 | test("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 | |
| 728 | test("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 | |
| 755 | test("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 | |
| 778 | test("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 |
| 791 | test("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 | |
| 816 | test("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 | |
| 842 | test("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 | |
| 860 | test("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 | |
| 877 | test("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 | |
| 917 | test("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 | |
| 935 | test("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 | |
| 952 | test("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 | |
| 984 | test("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 | |
| 1001 | test("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 | |
| 1015 | test("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 | |
| 1051 | test("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 | |
| 1066 | test("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 | |
| 1085 | test("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 | |
| 1097 | test("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 | |
| 1110 | test("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 | |
| 1127 | test("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 | |
| 1156 | test("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 | |
| 1180 | test("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 | |
| 1208 | test("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 | |
| 1235 | test("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 | |
| 1253 | test("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 | |
| 1268 | test("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 | |
| 1287 | test("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 | |
| 1309 | test("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 |
| 1325 | test("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 | |
| 1342 | test("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 | |
| 1356 | test("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 | |
| 1373 | test("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 | |
| 1398 | test("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 | |
| 1415 | test("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 | |
| 1433 | test("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 | |
| 1458 | test("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 | |
| 1471 | test("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 | |
| 1477 | test("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 | */ |
| 1507 | async 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 |
| 1534 | test("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 | |
| 1580 | test("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 | |
| 1595 | test("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 | |
| 1613 | test("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 | |
| 1622 | test("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 | |
| 1628 | test("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 | |
| 1642 | test("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 | |
| 1658 | test("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 | |
| 1679 | test("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 | |
| 1710 | test("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 | |
| 1734 | async 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 | |
| 1751 | function 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 */ |
| 1759 | async 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 | |
| 1765 | function 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 | |
| 1772 | import assert from "node:assert/strict"; |
| 1773 | import * as child_process from "node:child_process"; |
| 1774 | import * as fs from "node:fs/promises"; |
| 1775 | import * as os from "node:os"; |
| 1776 | import * as path from "node:path"; |
| 1777 | import * as url from "node:url"; |
| 1778 | import * as v8 from "node:v8"; |
| 1779 | import * as vm from "node:vm"; |
| 1780 | import { onTestFinished, test, vi } from "vitest"; |
| 1781 | import { UNWRAP } from "../assert.ts"; |
| 1782 | import * as incremental from "../incremental.ts"; |
| 1783 | import * as stack from "../log/stack.ts"; |
| 1784 | import * as progress from "../progress.ts"; |