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 */
8import type * as Effect from "effect/Effect";
9import { mergeTags, type Tags } from "./Tags.ts";
10
11/** Execution mode attached to a suite or test at registration time. */
12export type Mode = "run" | "skip" | "only" | "todo";
13
14/** A single log line captured from a test's Effect Logger or Console. */
15export 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 */
27export type TestBody = () => Effect.Effect<unknown, unknown, never>;
28
29/** A hook body. Same contract as {@link TestBody}. */
30export type HookBody = () => Effect.Effect<unknown, unknown, never>;
31
32export 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
43export 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
67export 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. */
86export 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
91export 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
112export 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. */
118export 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
133export const fullTitle = (node: Suite | TestCase): string =>
134 titlePath(node).join(" > ");
135
136/** Walk every test in a suite subtree (depth-first, registration order). */
137export 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`. */
148export 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};