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