|
| 1 | +import { afterAll, mock } from "bun:test"; |
| 2 | +import { fileURLToPath } from "node:url"; |
| 3 | + |
| 4 | +// `mock.module` keys its registry by filesystem path, not by `file://` URL -- |
| 5 | +// registering under a URL string silently fails to intercept a module |
| 6 | +// resolved elsewhere by relative specifier, once anything else in the |
| 7 | +// process has already loaded the real module. `import.meta.resolve` returns |
| 8 | +// a `file://` URL for relative specifiers, so normalize it back to a path. |
| 9 | +function toModulePath(path: string): string { |
| 10 | + return path.startsWith("file://") ? fileURLToPath(path) : path; |
| 11 | +} |
| 12 | + |
| 13 | +async function captureModule<T extends object>(path: string): Promise<T> { |
| 14 | + // Bun mutates the imported namespace object in place when a module is |
| 15 | + // mocked, so the capture must be a shallow copy taken before any mock |
| 16 | + // installs -- holding onto the live namespace would silently turn into |
| 17 | + // the mocked exports as soon as mock.module runs. |
| 18 | + return { ...(await import(path)) } as T; |
| 19 | +} |
| 20 | + |
| 21 | +/** |
| 22 | + * Mocks a module for the rest of this test file and registers its own |
| 23 | + * `afterAll` restore, so correctness never depends on remembering to add |
| 24 | + * one. Bun runs every test file in a single process, so an un-restored |
| 25 | + * `mock.module` silently replaces the real module for every file that runs |
| 26 | + * after this one -- this is the only sanctioned way to call `mock.module` |
| 27 | + * at file scope. |
| 28 | + * |
| 29 | + * `impl` receives the captured real module so mocks can spread it |
| 30 | + * (`...real`) without a separate capture line. |
| 31 | + * |
| 32 | + * Pass `path` as `import.meta.resolve("./relative/path.js")` from the |
| 33 | + * calling file, not a bare relative specifier -- both `import()` and |
| 34 | + * `mock.module` inside this helper resolve relative specifiers against |
| 35 | + * this file's own location, not the caller's. |
| 36 | + */ |
| 37 | +export async function withMockedModule<T extends object>( |
| 38 | + path: string, |
| 39 | + impl: (real: T) => object, |
| 40 | +): Promise<T> { |
| 41 | + const modulePath = toModulePath(path); |
| 42 | + const real = await captureModule<T>(modulePath); |
| 43 | + mock.module(modulePath, () => impl(real)); |
| 44 | + afterAll(() => { |
| 45 | + mock.module(modulePath, () => real); |
| 46 | + }); |
| 47 | + return real; |
| 48 | +} |
| 49 | + |
| 50 | +/** |
| 51 | + * Mocks a module only for the duration of `run`, restoring it immediately |
| 52 | + * afterward -- even if `run` throws -- rather than leaving it mocked for |
| 53 | + * the rest of the file. Use this when a mock only needs to apply around a |
| 54 | + * single call. |
| 55 | + * |
| 56 | + * Pass `path` as `import.meta.resolve("./relative/path.js")` -- see |
| 57 | + * `withMockedModule` above. |
| 58 | + */ |
| 59 | +export async function withMockedModuleDuring<T extends object, R>( |
| 60 | + path: string, |
| 61 | + impl: (real: T) => object, |
| 62 | + run: () => Promise<R>, |
| 63 | +): Promise<R> { |
| 64 | + const modulePath = toModulePath(path); |
| 65 | + const real = await captureModule<T>(modulePath); |
| 66 | + mock.module(modulePath, () => impl(real)); |
| 67 | + try { |
| 68 | + return await run(); |
| 69 | + } finally { |
| 70 | + mock.module(modulePath, () => real); |
| 71 | + } |
| 72 | +} |
0 commit comments