1import { afterEach, describe, expect, it } from "bun:test";
2import { spawnSync } from "node:child_process";
3import {
4 mkdirSync,
5 mkdtempSync,
6 realpathSync,
7 rmSync,
8 writeFileSync,
9} from "node:fs";
10import os from "node:os";
11import path from "node:path";
12import { pathToFileURL } from "node:url";
13
14const temporaryDirectories: string[] = [];
15
16afterEach(() => {
17 for (const directory of temporaryDirectories.splice(0)) {
18 rmSync(directory, { force: true, recursive: true });
19 }
20});
21
22describe("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});