| 1 | import { afterEach, describe, expect, it } from "bun:test"; |
| 2 | import { spawnSync } from "node:child_process"; |
| 3 | import { |
| 4 | mkdirSync, |
| 5 | mkdtempSync, |
| 6 | realpathSync, |
| 7 | rmSync, |
| 8 | writeFileSync, |
| 9 | } from "node:fs"; |
| 10 | import os from "node:os"; |
| 11 | import path from "node:path"; |
| 12 | import { pathToFileURL } from "node:url"; |
| 13 | |
| 14 | const temporaryDirectories: string[] = []; |
| 15 | |
| 16 | afterEach(() => { |
| 17 | for (const directory of temporaryDirectories.splice(0)) { |
| 18 | rmSync(directory, { force: true, recursive: true }); |
| 19 | } |
| 20 | }); |
| 21 | |
| 22 | describe("watchImport", () => { |
| 23 | it("transforms TypeScript, tracks dependencies, and reloads the whole graph", () => { |
| 24 | const temporaryDirectory = realpathSync( |
| 25 | mkdtempSync(path.join(os.tmpdir(), "alchemy-import-")), |
| 26 | ); |
| 27 | temporaryDirectories.push(temporaryDirectory); |
| 28 | const directory = path.join(temporaryDirectory, "project"); |
| 29 | mkdirSync(directory); |
| 30 | writeFileSync( |
| 31 | path.join(directory, "entry.ts"), |
| 32 | [ |
| 33 | 'import { count, text } from "./dependency.js";', |
| 34 | 'import { externalCount } from "../external.js";', |
| 35 | "interface Result { count: number; externalCount: number; text: string }", |
| 36 | "export const result: Result = { count, externalCount, text };", |
| 37 | ].join("\n"), |
| 38 | ); |
| 39 | writeFileSync( |
| 40 | path.join(directory, "dependency.ts"), |
| 41 | [ |
| 42 | "const state = globalThis as typeof globalThis & { calls?: number };", |
| 43 | "export const count = state.calls = (state.calls ?? 0) + 1;", |
| 44 | 'export const text: string = "__VALUE__";', |
| 45 | ].join("\n"), |
| 46 | ); |
| 47 | const external = path.join(temporaryDirectory, "external.ts"); |
| 48 | writeFileSync( |
| 49 | external, |
| 50 | [ |
| 51 | "const state = globalThis as typeof globalThis & { externalCalls?: number };", |
| 52 | "export const externalCount = state.externalCalls = (state.externalCalls ?? 0) + 1;", |
| 53 | ].join("\n"), |
| 54 | ); |
| 55 | |
| 56 | const moduleUrl = pathToFileURL( |
| 57 | path.resolve(import.meta.dir, "../src/watch-import.ts"), |
| 58 | ).href; |
| 59 | const registerUrl = pathToFileURL( |
| 60 | path.resolve(import.meta.dir, "../src/register-oxc.ts"), |
| 61 | ).href; |
| 62 | const script = ` |
| 63 | import { writeFile } from "node:fs/promises"; |
| 64 | import { fileURLToPath, pathToFileURL } from "node:url"; |
| 65 | import { watchImport } from ${JSON.stringify(moduleUrl)}; |
| 66 | const { registerOxc } = await import(${JSON.stringify(registerUrl)}); |
| 67 | registerOxc(); |
| 68 | |
| 69 | const directory = process.argv[1]; |
| 70 | const dependency = directory + "/dependency.ts"; |
| 71 | const external = fileURLToPath(new URL("../external.ts", pathToFileURL(directory + "/"))); |
| 72 | const watcher = watchImport("./entry.ts", { |
| 73 | parentURL: pathToFileURL(directory + "/runner.mjs").href, |
| 74 | debounceMs: 10, |
| 75 | shouldInvalidate: url => |
| 76 | url.startsWith("file:") && fileURLToPath(url).startsWith(process.argv[2] + "/"), |
| 77 | }); |
| 78 | const first = await watcher.import(); |
| 79 | if (first.value.result.count !== 1) throw new Error("first graph was not evaluated"); |
| 80 | if (first.value.result.externalCount !== 1) throw new Error("external module was not evaluated"); |
| 81 | if (!first.dependencies.has(directory + "/entry.ts")) throw new Error("entry was not tracked"); |
| 82 | if (!first.dependencies.has(dependency)) throw new Error("dependency was not tracked"); |
| 83 | if (!first.dependencies.has(external)) throw new Error("sibling project module was not tracked"); |
| 84 | |
| 85 | let unchangedEvents = 0; |
| 86 | const unsubscribeUnchanged = watcher.subscribe(() => unchangedEvents++); |
| 87 | await new Promise(resolve => setTimeout(resolve, 100)); |
| 88 | await writeFile(dependency, [ |
| 89 | "const state = globalThis as typeof globalThis & { calls?: number };", |
| 90 | "export const count = state.calls = (state.calls ?? 0) + 1;", |
| 91 | 'export const text: string = "__VALUE__";', |
| 92 | ].join("\\n")); |
| 93 | await new Promise(resolve => setTimeout(resolve, 100)); |
| 94 | unsubscribeUnchanged(); |
| 95 | if (unchangedEvents !== 0) throw new Error("unchanged contents triggered a reload"); |
| 96 | |
| 97 | const changed = new Promise(resolve => watcher.subscribe(resolve)); |
| 98 | await writeFile(dependency, [ |
| 99 | "const state = globalThis as typeof globalThis & { calls?: number };", |
| 100 | "export const count = state.calls = (state.calls ?? 0) + 1;", |
| 101 | 'export const text: string = "changed";', |
| 102 | ].join("\\n")); |
| 103 | let timeout; |
| 104 | const event = await Promise.race([ |
| 105 | changed, |
| 106 | new Promise((_, reject) => { |
| 107 | timeout = setTimeout(() => reject(new Error("watch timed out")), 3000); |
| 108 | }), |
| 109 | ]); |
| 110 | clearTimeout(timeout); |
| 111 | if (!event.paths.has(dependency)) throw new Error("change path was not reported"); |
| 112 | |
| 113 | const second = await watcher.import(); |
| 114 | if (second.value.result.count !== 2) throw new Error("dependency graph was not cache busted"); |
| 115 | if (second.value.result.externalCount !== 2) throw new Error("sibling project module was not cache busted"); |
| 116 | if (second.value.result.text !== "changed") throw new Error("changed module was not loaded"); |
| 117 | if (first.namespace === second.namespace) throw new Error("generation namespace was reused"); |
| 118 | await watcher.close(); |
| 119 | `; |
| 120 | |
| 121 | const result = spawnSync( |
| 122 | "node", |
| 123 | [ |
| 124 | "--no-warnings", |
| 125 | "--input-type=module", |
| 126 | "-e", |
| 127 | script, |
| 128 | directory, |
| 129 | temporaryDirectory, |
| 130 | ], |
| 131 | { encoding: "utf8", timeout: 10_000 }, |
| 132 | ); |
| 133 | |
| 134 | expect(result.status, result.stderr || result.stdout).toBe(0); |
| 135 | }); |
| 136 | }); |