1import { afterEach, describe, expect, it } from "bun:test";
2import {
3 mkdirSync,
4 mkdtempSync,
5 realpathSync,
6 rmSync,
7 writeFileSync,
8} from "node:fs";
9import os from "node:os";
10import path from "node:path";
11import { trackBunImports } from "../src/watch-import-bun.ts";
12
13const temporaryDirectories: string[] = [];
14
15afterEach(() => {
16 for (const directory of temporaryDirectories.splice(0)) {
17 rmSync(directory, { force: true, recursive: true });
18 }
19});
20
21describe("trackBunImports", () => {
22 it("records project-local modules and reports changes to them", async () => {
23 const temporaryDirectory = realpathSync(
24 mkdtempSync(path.join(os.tmpdir(), "alchemy-import-bun-")),
25 );
26 temporaryDirectories.push(temporaryDirectory);
27 const directory = path.join(temporaryDirectory, "project");
28 mkdirSync(directory);
29 const entry = path.join(directory, "entry.ts");
30 const dependency = path.join(directory, "dependency.ts");
31 const external = path.join(temporaryDirectory, "external.ts");
32 writeFileSync(
33 entry,
34 [
35 'import { text } from "./dependency.ts";',
36 'import { externalText } from "../external.ts";',
37 "interface Result { text: string; externalText: string }",
38 "export const result: Result = { text, externalText };",
39 ].join("\n"),
40 );
41 writeFileSync(dependency, 'export const text: string = "original";\n');
42 writeFileSync(
43 external,
44 'export const externalText: string = "external";\n',
45 );
46
47 // The project root is wider than the entrypoint directory, as it is in a
48 // stack with `infra/alchemy.run.ts` importing from sibling `src/` trees.
49 const tracker = trackBunImports({
50 root: temporaryDirectory,
51 debounceMs: 10,
52 });
53 try {
54 const module = (await import(entry)) as {
55 result: { text: string; externalText: string };
56 };
57 expect(module.result).toEqual({
58 text: "original",
59 externalText: "external",
60 });
61 expect(tracker.dependencies).toEqual(
62 new Set([entry, dependency, external]),
63 );
64
65 const unchanged: ReadonlySet<string>[] = [];
66 const unsubscribeUnchanged = tracker.subscribe(({ paths }) =>
67 unchanged.push(paths),
68 );
69 // Give chokidar a moment to arm the file watchers before writing.
70 await new Promise((resolve) => setTimeout(resolve, 200));
71 writeFileSync(dependency, 'export const text: string = "original";\n');
72 await new Promise((resolve) => setTimeout(resolve, 100));
73 unsubscribeUnchanged();
74 expect(unchanged).toEqual([]);
75
76 const changed = new Promise<ReadonlySet<string>>((resolve) =>
77 tracker.subscribe(({ paths }) => resolve(paths)),
78 );
79 writeFileSync(dependency, 'export const text: string = "changed";\n');
80 const paths = await Promise.race([
81 changed,
82 new Promise<never>((_, reject) =>
83 setTimeout(() => reject(new Error("watch timed out")), 3000),
84 ),
85 ]);
86 expect(paths).toEqual(new Set([dependency]));
87 } finally {
88 await tracker.close();
89 }
90 });
91
92 it("leaves dependencies in the root's own node_modules alone", async () => {
93 const temporaryDirectory = realpathSync(
94 mkdtempSync(path.join(os.tmpdir(), "alchemy-import-bun-nm-")),
95 );
96 temporaryDirectories.push(temporaryDirectory);
97 const packageDirectory = path.join(
98 temporaryDirectory,
99 "node_modules",
100 "cjs-dep",
101 );
102 mkdirSync(packageDirectory, { recursive: true });
103 const dependency = path.join(packageDirectory, "index.js");
104 writeFileSync(
105 path.join(packageDirectory, "package.json"),
106 '{"name":"cjs-dep","version":"1.0.0","main":"index.js"}\n',
107 );
108 // CommonJS: Bun cannot hand this back through `onLoad` without losing its
109 // exports (oven-sh/bun#19279), so intercepting it breaks the import.
110 writeFileSync(
111 dependency,
112 'function hi() { return "hi"; }\nmodule.exports = hi;\n',
113 );
114 const entry = path.join(temporaryDirectory, "entry.ts");
115 writeFileSync(
116 entry,
117 [
118 'import hi from "cjs-dep";',
119 "export const greeting: string = hi();",
120 ].join("\n"),
121 );
122
123 const tracker = trackBunImports({
124 root: temporaryDirectory,
125 debounceMs: 10,
126 });
127 try {
128 const module = (await import(entry)) as { greeting: string };
129 expect(module.greeting).toBe("hi");
130 expect(tracker.dependencies).toEqual(new Set([entry]));
131 } finally {
132 await tracker.close();
133 }
134 });
135});