| 1 | /** |
| 2 | * Per-file registration state. |
| 3 | * |
| 4 | * While a test file's module body is evaluating, `describe`/`test`/hook |
| 5 | * calls register nodes against that file's collector. The runner imports |
| 6 | * all test files in PARALLEL; attribution stays correct because the |
| 7 | * collector is carried by AsyncLocalStorage — the module loader propagates |
| 8 | * the async context of the `import()` call into the module's top-level |
| 9 | * evaluation (and into microtasks queued from it), so each file's |
| 10 | * registrations resolve to its own root no matter how many imports are in |
| 11 | * flight. |
| 12 | * |
| 13 | * The storage lives on `globalThis` so that a duplicated module instance |
| 14 | * (e.g. two resolutions of the package) still shares one registry. |
| 15 | */ |
| 16 | import { AsyncLocalStorage } from "node:async_hooks"; |
| 17 | import { makeFileSuite, type FileSuite, type Suite } from "./Model.ts"; |
| 18 | |
| 19 | interface FileContext { |
| 20 | /** Suite that `describe`/`test` calls currently attach to. */ |
| 21 | current: Suite; |
| 22 | } |
| 23 | |
| 24 | const key = Symbol.for("alchemy-test/registry"); |
| 25 | |
| 26 | const storage: AsyncLocalStorage<FileContext> = ((globalThis as any)[key] ??= |
| 27 | new AsyncLocalStorage<FileContext>()); |
| 28 | |
| 29 | /** |
| 30 | * Collect one file: run `f` (the file's dynamic import + microtask flush) |
| 31 | * with a fresh root as the ambient collector, and return the root. |
| 32 | */ |
| 33 | export const collect = async ( |
| 34 | file: string, |
| 35 | f: () => Promise<void>, |
| 36 | ): Promise<FileSuite> => { |
| 37 | const root = makeFileSuite(file); |
| 38 | await storage.run({ current: root }, f); |
| 39 | return root; |
| 40 | }; |
| 41 | |
| 42 | const currentContext = (): FileContext => { |
| 43 | const context = storage.getStore(); |
| 44 | if (context === undefined) { |
| 45 | throw new Error( |
| 46 | "alchemy-test: describe/test/hook called outside of a test file collection. " + |
| 47 | "Run tests with the `alchemy-test` CLI.", |
| 48 | ); |
| 49 | } |
| 50 | return context; |
| 51 | }; |
| 52 | |
| 53 | export const currentSuite = (): Suite => currentContext().current; |
| 54 | |
| 55 | /** |
| 56 | * The file currently being collected (path relative to the run root, e.g. |
| 57 | * `test/Cloudflare/R2/Bucket.test.ts`), or `undefined` when called outside |
| 58 | * of a collection (e.g. from a non-alchemy-test runner). Adapters use this |
| 59 | * at registration time to namespace per-test durable state by file. |
| 60 | */ |
| 61 | export const currentFile = (): string | undefined => { |
| 62 | let suite: Suite | undefined = storage.getStore()?.current; |
| 63 | while (suite?.parent !== undefined) suite = suite.parent; |
| 64 | return suite !== undefined && "file" in suite |
| 65 | ? (suite as FileSuite).file |
| 66 | : undefined; |
| 67 | }; |
| 68 | |
| 69 | /** Run `f` with `suite` as the current registration target. */ |
| 70 | export const withSuite = (suite: Suite, f: () => void): void => { |
| 71 | const context = currentContext(); |
| 72 | const previous = context.current; |
| 73 | context.current = suite; |
| 74 | try { |
| 75 | f(); |
| 76 | } finally { |
| 77 | context.current = previous; |
| 78 | } |
| 79 | }; |