| 1 | /** |
| 2 | * Core data model for the alchemy-test harness. |
| 3 | * |
| 4 | * A test file is collected into a tree of {@link Suite}s and {@link TestCase}s |
| 5 | * by evaluating its module body (describe/test calls register nodes). The |
| 6 | * runner then walks the tree and executes every test as an Effect. |
| 7 | */ |
| 8 | import type * as Effect from "effect/Effect"; |
| 9 | import { mergeTags, type Tags } from "./Tags.ts"; |
| 10 | |
| 11 | /** Execution mode attached to a suite or test at registration time. */ |
| 12 | export type Mode = "run" | "skip" | "only" | "todo"; |
| 13 | |
| 14 | /** A single log line captured from a test's Effect Logger or Console. */ |
| 15 | export interface LogEntry { |
| 16 | readonly level: string; |
| 17 | readonly message: string; |
| 18 | readonly time: Date; |
| 19 | } |
| 20 | |
| 21 | /** |
| 22 | * The body of a test. Always an Effect — plain-function tests are wrapped |
| 23 | * at registration time. The Effect must be self-contained (R = never); |
| 24 | * wrapping (scope, TestClock/TestConsole, shared layers) happens in the |
| 25 | * harness closures, NOT in the runner. |
| 26 | */ |
| 27 | export type TestBody = () => Effect.Effect<unknown, unknown, never>; |
| 28 | |
| 29 | /** A hook body. Same contract as {@link TestBody}. */ |
| 30 | export type HookBody = () => Effect.Effect<unknown, unknown, never>; |
| 31 | |
| 32 | export interface Hook { |
| 33 | readonly body: HookBody; |
| 34 | readonly timeout?: number | undefined; |
| 35 | /** |
| 36 | * Take the whole-process write lock for this hook. Use for beforeAll |
| 37 | * deploys / afterAll destroys that consume a scarce cloud quota so they |
| 38 | * never overlap exclusive tests. |
| 39 | */ |
| 40 | readonly exclusive?: boolean; |
| 41 | } |
| 42 | |
| 43 | export interface TestCase { |
| 44 | /** Deduplicated labels, including tags inherited from suites. */ |
| 45 | readonly tags: ReadonlyArray<string>; |
| 46 | /** Inherited tags requiring explicit selection. */ |
| 47 | readonly optInTags: ReadonlyArray<string>; |
| 48 | readonly type: "test"; |
| 49 | readonly name: string; |
| 50 | readonly mode: Mode; |
| 51 | /** Invert the result: the test passes only if the body fails. */ |
| 52 | readonly fails?: boolean; |
| 53 | /** |
| 54 | * Run this test with the whole-process write lock: no other test in the |
| 55 | * run executes concurrently with it. Required for tests that mutate |
| 56 | * process-global state (e.g. `process.env.PATH`) — the runner executes |
| 57 | * everything in ONE bun process, so such mutations are visible everywhere. |
| 58 | */ |
| 59 | readonly exclusive?: boolean; |
| 60 | /** Override the run-wide retry budget for this test. */ |
| 61 | readonly retry?: number | undefined; |
| 62 | readonly timeout?: number | undefined; |
| 63 | readonly body: TestBody | undefined; |
| 64 | readonly parent: Suite; |
| 65 | } |
| 66 | |
| 67 | export interface Suite { |
| 68 | /** Deduplicated labels, including tags inherited from parent suites. */ |
| 69 | readonly tags: ReadonlyArray<string>; |
| 70 | /** Inherited tags requiring explicit selection. */ |
| 71 | readonly optInTags: ReadonlyArray<string>; |
| 72 | readonly type: "suite"; |
| 73 | readonly name: string; |
| 74 | mode: Mode; |
| 75 | /** When true, children run one at a time (the default; describe.concurrent opts out). */ |
| 76 | sequential: boolean; |
| 77 | readonly children: Array<Suite | TestCase>; |
| 78 | readonly beforeAll: Array<Hook>; |
| 79 | readonly afterAll: Array<Hook>; |
| 80 | readonly beforeEach: Array<Hook>; |
| 81 | readonly afterEach: Array<Hook>; |
| 82 | readonly parent: Suite | undefined; |
| 83 | } |
| 84 | |
| 85 | /** The root suite of a single test file. */ |
| 86 | export interface FileSuite extends Suite { |
| 87 | /** Path relative to the run root, e.g. `test/Cloudflare/R2/Bucket.test.ts`. */ |
| 88 | readonly file: string; |
| 89 | } |
| 90 | |
| 91 | export const makeSuite = ( |
| 92 | name: string, |
| 93 | parent: Suite | undefined, |
| 94 | mode: Mode = "run", |
| 95 | tags?: Tags, |
| 96 | optInTags?: Tags, |
| 97 | ): Suite => ({ |
| 98 | tags: mergeTags(parent?.tags ?? [], tags), |
| 99 | optInTags: mergeTags(parent?.optInTags ?? [], optInTags), |
| 100 | type: "suite", |
| 101 | name, |
| 102 | mode, |
| 103 | sequential: true, |
| 104 | children: [], |
| 105 | beforeAll: [], |
| 106 | afterAll: [], |
| 107 | beforeEach: [], |
| 108 | afterEach: [], |
| 109 | parent, |
| 110 | }); |
| 111 | |
| 112 | export const makeFileSuite = (file: string): FileSuite => ({ |
| 113 | ...makeSuite(file, undefined), |
| 114 | file, |
| 115 | }); |
| 116 | |
| 117 | /** Full title path from the file root down to (and including) this node. */ |
| 118 | export const titlePath = (node: Suite | TestCase): ReadonlyArray<string> => { |
| 119 | const parts: Array<string> = []; |
| 120 | let current: Suite | TestCase | undefined = node; |
| 121 | while (current !== undefined) { |
| 122 | // Skip the file-root suite name (it's reported separately as `file`). |
| 123 | if (current.parent !== undefined || current.type === "test") { |
| 124 | parts.unshift(current.name); |
| 125 | } else if (current.parent === undefined && current.type === "suite") { |
| 126 | // file root — stop |
| 127 | } |
| 128 | current = current.parent; |
| 129 | } |
| 130 | return parts; |
| 131 | }; |
| 132 | |
| 133 | export const fullTitle = (node: Suite | TestCase): string => |
| 134 | titlePath(node).join(" > "); |
| 135 | |
| 136 | /** Walk every test in a suite subtree (depth-first, registration order). */ |
| 137 | export const forEachTest = ( |
| 138 | suite: Suite, |
| 139 | f: (test: TestCase) => void, |
| 140 | ): void => { |
| 141 | for (const child of suite.children) { |
| 142 | if (child.type === "test") f(child); |
| 143 | else forEachTest(child, f); |
| 144 | } |
| 145 | }; |
| 146 | |
| 147 | /** True if the subtree contains a node marked `only`. */ |
| 148 | export const containsOnly = (suite: Suite): boolean => { |
| 149 | if (suite.mode === "only") return true; |
| 150 | for (const child of suite.children) { |
| 151 | if (child.type === "test" && child.mode === "only") return true; |
| 152 | if (child.type === "suite" && containsOnly(child)) return true; |
| 153 | } |
| 154 | return false; |
| 155 | }; |