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