1/**
2 * The vitest-compatible registration API: `describe`, `it`/`test`, hooks and
3 * `layer`. Registration builds the {@link Model} tree; execution is the
4 * runner's job. Everything is Effect-first — plain-function tests are wrapped
5 * into Effects at registration time.
6 */
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9import * as Layer from "effect/Layer";
10import * as Scope from "effect/Scope";
11import * as TestClock from "effect/testing/TestClock";
12import * as TestConsole from "effect/testing/TestConsole";
13
14import type { Hook, Mode, TestBody } from "./Model.ts";
15import { makeSuite } from "./Model.ts";
16import { currentSuite, withSuite } from "./Registry.ts";
17import { mergeTags, type Tags } from "./Tags.ts";
18
19// ---------------------------------------------------------------------------
20// Shared option handling (vitest accepts `number | { timeout?: number, ... }`)
21// ---------------------------------------------------------------------------
22
23export type TestOptions =
24 | number
25 | {
26 /** Labels added to the tags inherited from enclosing suites. */
27 readonly tags?: Tags;
28 /** Tags that must each be explicitly named in the filter to enable this test or suite. */
29 readonly optInTags?: Tags;
30 readonly timeout?: number;
31 readonly retry?: number;
32 readonly repeats?: number;
33 /**
34 * Run this test with the whole-process write lock — no other test in
35 * the run executes concurrently with it. Use for tests that mutate
36 * process-global state (e.g. `process.env.PATH`): the runner executes
37 * everything in ONE bun process, so such mutations are visible to every
38 * other running test.
39 */
40 readonly exclusive?: boolean;
41 };
42
43export const timeoutOf = (options?: TestOptions): number | undefined =>
44 typeof options === "number" ? options : options?.timeout;
45
46export const retryOf = (options?: TestOptions): number | undefined =>
47 typeof options === "object" && options !== null ? options.retry : undefined;
48
49export const exclusiveOf = (options?: TestOptions): boolean =>
50 typeof options === "object" && options !== null && options.exclusive === true;
51
52export const tagsOf = (options?: TestOptions): Tags | undefined =>
53 typeof options === "object" ? options?.tags : undefined;
54
55export const optInTagsOf = (options?: TestOptions): Tags | undefined =>
56 typeof options === "object" ? options?.optInTags : undefined;
57
58// ---------------------------------------------------------------------------
59// describe
60// ---------------------------------------------------------------------------
61
62type DescribeBody = (() => void) | undefined;
63
64export interface DescribeOptions {
65 /** Labels inherited by every nested suite and test. */
66 readonly tags?: Tags;
67 /** Tags that must each be explicitly named in the filter to enable this test or suite. */
68 readonly optInTags?: Tags;
69 readonly concurrent?: boolean;
70 readonly sequential?: boolean;
71 readonly timeout?: number;
72}
73
74export interface DescribeFn {
75 (
76 name: string | (() => void),
77 body?: DescribeBody | DescribeOptions | number,
78 bodyOrTimeout?: DescribeBody | number,
79 ): void;
80 readonly sequential: DescribeFn;
81 readonly concurrent: DescribeFn;
82 readonly skip: DescribeFn;
83 readonly only: DescribeFn;
84 readonly todo: DescribeFn;
85 skipIf(condition: unknown): DescribeFn;
86 runIf(condition: unknown): DescribeFn;
87 each<T>(
88 cases: ReadonlyArray<T>,
89 ): (name: string, fn: (args: T) => void, options?: TestOptions) => void;
90}
91
92interface DescribeConfig {
93 readonly mode: Mode;
94 readonly sequential: boolean;
95}
96
97const makeDescribe = (config: DescribeConfig): DescribeFn => {
98 const fn = (
99 nameOrBody: string | (() => void),
100 second?: DescribeBody | DescribeOptions | number,
101 third?: DescribeBody | number,
102 ): void => {
103 // Normalize the vitest signatures:
104 // describe(name, body)
105 // describe(name, body, timeout)
106 // describe(name, options, body)
107 // describe(body) (via describe.skipIf(...)(() => {}))
108 let name: string;
109 let body: DescribeBody;
110 let sequential = config.sequential;
111 let tags: Tags | undefined;
112 let optInTags: Tags | undefined;
113 if (typeof nameOrBody === "function") {
114 name = "";
115 body = nameOrBody;
116 } else {
117 name = nameOrBody;
118 if (typeof second === "function") {
119 body = second;
120 } else if (typeof second === "object" && second !== null) {
121 tags = second.tags;
122 optInTags = second.optInTags;
123 if (second.concurrent === true || second.sequential === false) {
124 sequential = false;
125 } else if (second.concurrent === false || second.sequential === true) {
126 sequential = true;
127 }
128 body = typeof third === "function" ? third : undefined;
129 } else {
130 body = typeof third === "function" ? third : undefined;
131 }
132 }
133 const parent = currentSuite();
134 const suite = makeSuite(name, parent, config.mode, tags, optInTags);
135 suite.sequential = sequential;
136 parent.children.push(suite);
137 // `describe.skip` still collects its children (they're reported as
138 // skipped, matching vitest); only `todo` suites skip collection.
139 if (body !== undefined && config.mode !== "todo") {
140 withSuite(suite, body);
141 }
142 };
143 const withMethods = Object.assign(fn, {
144 each:
145 <T>(cases: ReadonlyArray<T>) =>
146 (name: string, eachBody: (args: T) => void, options?: TestOptions) => {
147 cases.forEach((args, index) => {
148 fn(
149 formatEachName(name, args, index),
150 { tags: tagsOf(options), optInTags: optInTagsOf(options) },
151 () => eachBody(args),
152 );
153 });
154 },
155 skipIf: (condition: unknown) =>
156 makeDescribe(condition ? { ...config, mode: "skip" } : config),
157 runIf: (condition: unknown) =>
158 makeDescribe(condition ? config : { ...config, mode: "skip" }),
159 });
160 // Lazy getters — Object.assign would evaluate them eagerly and recurse
161 // forever (each modifier constructs another DescribeFn).
162 Object.defineProperties(withMethods, {
163 sequential: { get: () => makeDescribe({ ...config, sequential: true }) },
164 concurrent: { get: () => makeDescribe({ ...config, sequential: false }) },
165 skip: { get: () => makeDescribe({ ...config, mode: "skip" }) },
166 only: { get: () => makeDescribe({ ...config, mode: "only" }) },
167 todo: { get: () => makeDescribe({ ...config, mode: "todo" }) },
168 });
169 return withMethods as DescribeFn;
170};
171
172export const describe: DescribeFn = makeDescribe({
173 mode: "run",
174 sequential: true,
175});
176
177// ---------------------------------------------------------------------------
178// Test registration primitives
179// ---------------------------------------------------------------------------
180
181export interface RegisterTestOptions {
182 readonly tags?: Tags;
183 /** Tags that must each be explicitly named in the filter to enable this test or suite. */
184 readonly optInTags?: Tags;
185 readonly name: string;
186 readonly mode: Mode;
187 readonly fails?: boolean;
188 readonly exclusive?: boolean;
189 readonly retry?: number | undefined;
190 readonly timeout?: number | undefined;
191 readonly body: TestBody | undefined;
192}
193
194/** Low-level test registration — used by adapters (e.g. alchemy Test.make). */
195export const registerTest = (options: RegisterTestOptions): void => {
196 const parent = currentSuite();
197 parent.children.push({
198 type: "test",
199 name: options.name,
200 tags: mergeTags(parent.tags, options.tags),
201 optInTags: mergeTags(parent.optInTags, options.optInTags),
202 mode: options.mode,
203 fails: options.fails ?? false,
204 exclusive: options.exclusive ?? false,
205 retry: options.retry,
206 timeout: options.timeout,
207 body: options.body,
208 parent,
209 });
210};
211
212/** Wrap a plain (sync or async) test function into a self-contained Effect. */
213const fromFn =
214 (fn: (ctx: TestContext) => unknown): TestBody =>
215 () =>
216 Effect.promise(async () => {
217 await fn(emptyContext);
218 });
219
220/** Minimal stand-in for vitest's TestContext (rarely used by our tests). */
221export interface TestContext {
222 readonly signal?: AbortSignal;
223}
224
225const emptyContext: TestContext = {};
226
227// ---------------------------------------------------------------------------
228// Effect testers (it.effect / it.live)
229// ---------------------------------------------------------------------------
230
231/**
232 * The environment `it.effect` provides, matching @effect/vitest: a TestClock
233 * and a TestConsole on top of a per-test Scope.
234 */
235const TestEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer());
236
237export type EffectTestFunction<R> = (
238 ctx: TestContext,
239) => Effect.Effect<unknown, unknown, R>;
240
241export interface Tester<R> {
242 (name: string, self: EffectTestFunction<R>, options?: TestOptions): void;
243 skip(name: string, self: EffectTestFunction<R>, options?: TestOptions): void;
244 skipIf(
245 condition: unknown,
246 ): (name: string, self: EffectTestFunction<R>, options?: TestOptions) => void;
247 runIf(
248 condition: unknown,
249 ): (name: string, self: EffectTestFunction<R>, options?: TestOptions) => void;
250 only(name: string, self: EffectTestFunction<R>, options?: TestOptions): void;
251 fails(name: string, self: EffectTestFunction<R>, options?: TestOptions): void;
252 each<T>(
253 cases: ReadonlyArray<T>,
254 ): (
255 name: string,
256 self: (args: T) => Effect.Effect<unknown, unknown, R>,
257 options?: TestOptions,
258 ) => void;
259}
260
261export const makeTester = <R>(
262 mapEffect: (
263 self: Effect.Effect<unknown, unknown, R>,
264 ) => Effect.Effect<unknown, unknown, never>,
265): Tester<R> => {
266 const register = (
267 name: string,
268 self: EffectTestFunction<R>,
269 options: TestOptions | undefined,
270 overrides?: Partial<RegisterTestOptions>,
271 ) =>
272 registerTest({
273 name,
274 mode: "run",
275 timeout: timeoutOf(options),
276 tags: tagsOf(options),
277 optInTags: optInTagsOf(options),
278 retry: retryOf(options),
279 exclusive: exclusiveOf(options),
280 body: () => mapEffect(Effect.suspend(() => self(emptyContext))),
281 ...overrides,
282 });
283
284 const fn = ((name, self, options) =>
285 register(name, self, options)) as Tester<R>;
286 return Object.assign(fn, {
287 skip: (name: string, self: EffectTestFunction<R>, options?: TestOptions) =>
288 register(name, self, options, { mode: "skip" }),
289 skipIf:
290 (condition: unknown) =>
291 (name: string, self: EffectTestFunction<R>, options?: TestOptions) =>
292 register(name, self, options, { mode: condition ? "skip" : "run" }),
293 runIf:
294 (condition: unknown) =>
295 (name: string, self: EffectTestFunction<R>, options?: TestOptions) =>
296 register(name, self, options, { mode: condition ? "run" : "skip" }),
297 only: (name: string, self: EffectTestFunction<R>, options?: TestOptions) =>
298 register(name, self, options, { mode: "only" }),
299 fails: (name: string, self: EffectTestFunction<R>, options?: TestOptions) =>
300 register(name, self, options, { fails: true }),
301 each:
302 <T>(cases: ReadonlyArray<T>) =>
303 (
304 name: string,
305 self: (args: T) => Effect.Effect<unknown, unknown, R>,
306 options?: TestOptions,
307 ) => {
308 cases.forEach((args, index) => {
309 register(
310 formatEachName(name, args, index),
311 () => self(args),
312 options,
313 );
314 });
315 },
316 });
317};
318
319const formatEachName = (name: string, args: unknown, index: number): string => {
320 let out = name;
321 // `$prop` templating (vitest): "Container (dev: $dev)"
322 if (out.includes("$") && typeof args === "object" && args !== null) {
323 out = out.replace(/\$([a-zA-Z_$][\w.$]*)/g, (match, path: string) => {
324 const value = path
325 .split(".")
326 .reduce<unknown>(
327 (acc, key) =>
328 acc !== null && typeof acc === "object"
329 ? (acc as Record<string, unknown>)[key]
330 : undefined,
331 args,
332 );
333 return value === undefined ? match : String(value);
334 });
335 }
336 // printf-style templating: "adds %s"
337 if (out.includes("%")) {
338 out = out.replace(/%[sdifjo#]/g, () =>
339 typeof args === "object" ? JSON.stringify(args) : String(args),
340 );
341 }
342 return out === name ? `${name} [${index}]` : out;
343};
344
345// ---------------------------------------------------------------------------
346// it / test
347// ---------------------------------------------------------------------------
348
349export interface TestFn {
350 (
351 name: string,
352 fn?: (ctx: TestContext) => unknown,
353 options?: TestOptions,
354 ): void;
355 skip(
356 name: string,
357 fn?: (ctx: TestContext) => unknown,
358 options?: TestOptions,
359 ): void;
360 only(
361 name: string,
362 fn?: (ctx: TestContext) => unknown,
363 options?: TestOptions,
364 ): void;
365 todo(
366 name: string,
367 fn?: (ctx: TestContext) => unknown,
368 options?: TestOptions,
369 ): void;
370 fails(
371 name: string,
372 fn?: (ctx: TestContext) => unknown,
373 options?: TestOptions,
374 ): void;
375 skipIf(
376 condition: unknown,
377 ): (
378 name: string,
379 fn?: (ctx: TestContext) => unknown,
380 options?: TestOptions,
381 ) => void;
382 runIf(
383 condition: unknown,
384 ): (
385 name: string,
386 fn?: (ctx: TestContext) => unknown,
387 options?: TestOptions,
388 ) => void;
389 each<T>(
390 cases: ReadonlyArray<T>,
391 ): (name: string, fn: (args: T) => unknown, options?: TestOptions) => void;
392 /** Effect tester with TestClock + TestConsole (matches @effect/vitest). */
393 readonly effect: Tester<
394 Scope.Scope | TestClock.TestClock | TestConsole.TestConsole
395 >;
396 /** Effect tester against the live environment (scope only). */
397 readonly live: Tester<Scope.Scope>;
398}
399
400const registerFnTest = (
401 name: string,
402 fn: ((ctx: TestContext) => unknown) | undefined,
403 options: TestOptions | undefined,
404 mode: Mode,
405 fails = false,
406): void =>
407 registerTest({
408 name,
409 mode: fn === undefined ? "todo" : mode,
410 fails,
411 timeout: timeoutOf(options),
412 tags: tagsOf(options),
413 optInTags: optInTagsOf(options),
414 retry: retryOf(options),
415 exclusive: exclusiveOf(options),
416 body: fn === undefined ? undefined : fromFn(fn),
417 });
418
419const makeTestFn = (): TestFn => {
420 const fn = ((name, body, options) =>
421 registerFnTest(name, body, options, "run")) as TestFn;
422 return Object.assign(fn, {
423 skip: (
424 name: string,
425 body?: (ctx: TestContext) => unknown,
426 options?: TestOptions,
427 ) => registerFnTest(name, body, options, "skip"),
428 only: (
429 name: string,
430 body?: (ctx: TestContext) => unknown,
431 options?: TestOptions,
432 ) => registerFnTest(name, body, options, "only"),
433 todo: (
434 name: string,
435 body?: (ctx: TestContext) => unknown,
436 options?: TestOptions,
437 ) => registerFnTest(name, body, options, "todo"),
438 fails: (
439 name: string,
440 body?: (ctx: TestContext) => unknown,
441 options?: TestOptions,
442 ) => registerFnTest(name, body, options, "run", true),
443 skipIf:
444 (condition: unknown) =>
445 (
446 name: string,
447 body?: (ctx: TestContext) => unknown,
448 options?: TestOptions,
449 ) =>
450 registerFnTest(name, body, options, condition ? "skip" : "run"),
451 runIf:
452 (condition: unknown) =>
453 (
454 name: string,
455 body?: (ctx: TestContext) => unknown,
456 options?: TestOptions,
457 ) =>
458 registerFnTest(name, body, options, condition ? "run" : "skip"),
459 each:
460 <T>(cases: ReadonlyArray<T>) =>
461 (name: string, body: (args: T) => unknown, options?: TestOptions) => {
462 cases.forEach((args, index) => {
463 registerFnTest(
464 formatEachName(name, args, index),
465 () => body(args),
466 options,
467 "run",
468 );
469 });
470 },
471 effect: makeTester<
472 Scope.Scope | TestClock.TestClock | TestConsole.TestConsole
473 >(
474 (self) =>
475 self.pipe(Effect.scoped, Effect.provide(TestEnv)) as Effect.Effect<
476 unknown,
477 unknown,
478 never
479 >,
480 ),
481 live: makeTester<Scope.Scope>((self) => Effect.scoped(self)),
482 });
483};
484
485export const it: TestFn = makeTestFn();
486export const test: TestFn = it;
487
488// ---------------------------------------------------------------------------
489// Hooks
490// ---------------------------------------------------------------------------
491
492export type HookKind = "beforeAll" | "afterAll" | "beforeEach" | "afterEach";
493
494/** Low-level hook registration with an Effect body — used by adapters. */
495export const registerHook = (kind: HookKind, hook: Hook): void => {
496 currentSuite()[kind].push(hook);
497};
498
499const hookFromFn =
500 (fn: () => unknown): Hook["body"] =>
501 () =>
502 Effect.promise(async () => {
503 await fn();
504 });
505
506export const beforeAll = (fn: () => unknown, timeout?: number): void =>
507 registerHook("beforeAll", { body: hookFromFn(fn), timeout });
508export const afterAll = (fn: () => unknown, timeout?: number): void =>
509 registerHook("afterAll", { body: hookFromFn(fn), timeout });
510export const beforeEach = (fn: () => unknown, timeout?: number): void =>
511 registerHook("beforeEach", { body: hookFromFn(fn), timeout });
512export const afterEach = (fn: () => unknown, timeout?: number): void =>
513 registerHook("afterEach", { body: hookFromFn(fn), timeout });
514
515// ---------------------------------------------------------------------------
516// layer — share a Layer across the tests of a block (@effect/vitest parity)
517// ---------------------------------------------------------------------------
518
519export interface LayerMethods<R> {
520 effect: Tester<
521 R | Scope.Scope | TestClock.TestClock | TestConsole.TestConsole
522 >;
523 layer<R2, E2>(
524 nested: Layer.Layer<R2, E2, R>,
525 options?: { readonly timeout?: number },
526 ): {
527 (f: (it: LayerMethods<R | R2>) => void): void;
528 (name: string, f: (it: LayerMethods<R | R2>) => void): void;
529 };
530}
531
532export const layer =
533 <R, E>(
534 layer_: Layer.Layer<R, E>,
535 options?: {
536 readonly memoMap?: Layer.MemoMap;
537 readonly timeout?: number;
538 readonly excludeTestServices?: boolean;
539 },
540 ): {
541 (f: (it: LayerMethods<R>) => void): void;
542 (name: string, f: (it: LayerMethods<R>) => void): void;
543 } =>
544 (
545 ...args:
546 | [name: string, f: (it: LayerMethods<R>) => void]
547 | [f: (it: LayerMethods<R>) => void]
548 ): void => {
549 const excludeTestServices = options?.excludeTestServices ?? false;
550 const withTestEnv = excludeTestServices
551 ? (layer_ as Layer.Layer<R, E>)
552 : Layer.provideMerge(layer_, TestEnv);
553 const memoMap = options?.memoMap ?? Effect.runSync(Layer.makeMemoMap);
554 const scope = Scope.makeUnsafe();
555 const contextEffect = Layer.buildWithMemoMap(
556 withTestEnv,
557 memoMap,
558 scope,
559 ).pipe(Effect.orDie, Effect.cached, Effect.runSync);
560
561 const makeMethods = (): LayerMethods<R> => ({
562 effect: makeTester<
563 R | Scope.Scope | TestClock.TestClock | TestConsole.TestConsole
564 >(
565 (self) =>
566 Effect.flatMap(contextEffect, (context) =>
567 self.pipe(Effect.scoped, Effect.provide(context)),
568 ) as Effect.Effect<unknown, unknown, never>,
569 ),
570 layer: (nested, nestedOptions) =>
571 layer(
572 Layer.provideMerge(nested, withTestEnv) as Layer.Layer<any, any>,
573 {
574 ...nestedOptions,
575 memoMap: Layer.forkMemoMapUnsafe(memoMap),
576 excludeTestServices,
577 },
578 ) as any,
579 });
580
581 const register = (): void => {
582 registerHook("afterAll", {
583 body: () => Scope.close(scope, Exit.void),
584 timeout: options?.timeout,
585 });
586 const f = (args.length === 1 ? args[0] : args[1]) as (
587 it: LayerMethods<R>,
588 ) => void;
589 f(makeMethods());
590 };
591
592 if (args.length === 2) {
593 describe(args[0], register);
594 } else {
595 register();
596 }
597 };