Open History to see the last commit
1/**
2 * @since 4.0.0
3 */
4
5import * as Arbitrary from "effect/Arbitrary"
6import * as Cause from "effect/Cause"
7import * as Duration from "effect/Duration"
8import * as Effect from "effect/Effect"
9import * as Exit from "effect/Exit"
10import * as Fiber from "effect/Fiber"
11import { constVoid, flow, pipe } from "effect/Function"
12import * as Layer from "effect/Layer"
13import * as Schedule from "effect/Schedule"
14import type * as Schema from "effect/Schema"
15import * as Scope from "effect/Scope"
16import * as TestClock from "effect/testing/TestClock"
17import * as TestConsole from "effect/testing/TestConsole"
18import * as V from "vitest"
19import type * as Vitest from "../index.ts"
20
21const getCurrentSuite = V.TestRunner.getCurrentSuite
22
23const runPromise = <E, A>(
24 effect: Effect.Effect<A, E>,
25 ctx?: Pick<V.TestContext, "signal">
26): Promise<A> =>
27 Effect.runPromise(
28 // Only the test is interruptible: once the signal aborts, the fiber stops
29 // at its next interruptible step, which would skip reporting the defects
30 // raised by finalizers.
31 Effect.uninterruptibleMask((restore) =>
32 Effect.gen(function*() {
33 const exit = yield* Effect.exit(restore(effect))
34 if (Exit.isFailure(exit)) {
35 const errors = Cause.prettyErrors(exit.cause)
36 for (let i = 0; i < errors.length; i++) {
37 yield* Effect.logError(errors[i])
38 }
39 }
40 return yield* exit
41 })
42 ),
43 { signal: ctx?.signal }
44 )
45
46const settleOnAbort = <A>(ctx: Vitest.TestContext, promise: Promise<A>) => {
47 // Vitest stops awaiting the promise once the signal aborts (timeout or
48 // cancellation), so only then add a hook that waits for the finalizers.
49 // Registering it unconditionally would change the teardown of every test.
50 const onAbort = () => ctx.onTestFinished(() => promise.then(constVoid, constVoid))
51 ctx.signal.addEventListener("abort", onAbort, { once: true })
52 const cleanup = () => ctx.signal.removeEventListener("abort", onAbort)
53 promise.then(cleanup, cleanup)
54 // A retry after a timed-out attempt reuses the aborted signal, so no event fires.
55 if (ctx.signal.aborted) onAbort()
56 return promise
57}
58
59/** @internal */
60const runTest = (ctx?: Vitest.TestContext) => <E, A>(effect: Effect.Effect<A, E>) => {
61 const promise = runPromise(effect, ctx)
62 return ctx ? settleOnAbort(ctx, promise) : promise
63}
64
65/** @internal */
66export type TestContext = TestConsole.TestConsole | TestClock.TestClock
67
68const TestEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
69
70/** @internal */
71export const addEqualityTesters = () => {
72 V.expect.addEqualityTesters([])
73}
74
75/** @internal */
76const testOptions = (timeout?: number | V.TestOptions) => typeof timeout === "number" ? { timeout } : timeout ?? {}
77
78const hookTimeout = (timeout?: Duration.Input) =>
79 timeout === undefined ? undefined : Duration.toMillis(Duration.fromInputUnsafe(timeout))
80
81type PropertyTimeout =
82 | number
83 | V.TestOptions & {
84 readonly arbitrary?: Arbitrary.CheckOptions | undefined
85 }
86
87type ArbitraryInput = Schema.Schema<any> | Arbitrary.Arbitrary<unknown>
88
89type Arbitraries = Array<ArbitraryInput> | { [K in string]: ArbitraryInput }
90
91const propertyTestOptions = (
92 timeout: PropertyTimeout | undefined
93): Exclude<PropertyTimeout, number> | undefined => typeof timeout === "number" ? undefined : timeout
94
95const checkOptions = (timeout: PropertyTimeout | undefined): Arbitrary.CheckOptions | undefined =>
96 propertyTestOptions(timeout)?.arbitrary
97
98const compileArbitraryInput = (input: ArbitraryInput): Arbitrary.Arbitrary<any> =>
99 Arbitrary.isArbitrary(input) ? input : Arbitrary.schema(input)
100
101const makeArbitrary = (arbitraries: Arbitraries): Arbitrary.Arbitrary<any> =>
102 Arbitrary.all(
103 Array.isArray(arbitraries)
104 ? arbitraries.map(compileArbitraryInput)
105 : Object.fromEntries(Object.entries(arbitraries).map(([key, input]) => [key, compileArbitraryInput(input)]))
106 )
107
108const normalizeProperty = <A, E, R>(
109 property: (value: A) => boolean | Effect.Effect<boolean, E, R>,
110 value: A
111): Effect.Effect<boolean, E | Cause.Cause<E>, R> =>
112 Effect.catchCause(
113 Effect.suspend(() => {
114 const output = property(value)
115 return Effect.isEffect(output) ? output : Effect.succeed(output)
116 }),
117 (cause): Effect.Effect<never, E | Cause.Cause<E>> =>
118 Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.fail(cause)
119 )
120
121const runCheck = <A, E>(
122 ctx: V.TestContext,
123 arbitrary: Arbitrary.Arbitrary<A>,
124 property: (value: A) => boolean | Effect.Effect<boolean, E>,
125 options: Arbitrary.CheckOptions | undefined
126): Promise<void> =>
127 runTest(ctx)(
128 Effect.flatMapEager(
129 Arbitrary.checkEffect(arbitrary, (value) => normalizeProperty(property, value), options),
130 (result) => {
131 const failure = Arbitrary.formatCheckFailure(result)
132 return failure === undefined ? Effect.void : Effect.die(new Error(failure))
133 }
134 )
135 )
136
137// Vitest decides which fixtures to set up by parsing the source of a test
138// function's context parameter, and there is no public API to supply the names
139// for a wrapper, so wrappers expose the source of the function they call.
140const withFixturesOf = <F extends Function>(source: Function, f: F): F =>
141 Object.defineProperty(f, "toString", { value: () => source.toString() })
142
143const runHook = <Args extends Array<any>, R>(
144 fn: (...args: Args) => unknown,
145 run: (args: Args, effect: Effect.Effect<void, unknown>, scope: Scope.Closeable) => R
146) =>
147 withFixturesOf(fn, (...args: Args) => {
148 const result = fn(...args)
149 if (!Effect.isEffect(result)) {
150 return result
151 }
152 const scope = Scope.makeUnsafe()
153 // Everything the hook leaves behind lives in the scope: the resources it
154 // acquired and, ahead of them, a cleanup function it returned. On failure
155 // the scope closes inside the fiber, so interrupting it awaits the release.
156 const effect = Scope.provide(result as Effect.Effect<unknown, unknown, Scope.Scope>, scope).pipe(
157 Effect.flatMap((cleanup) =>
158 typeof cleanup === "function"
159 ? Scope.addFinalizer(scope, Effect.promise(async () => await cleanup()))
160 : Effect.void
161 ),
162 Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause)))
163 )
164 return run(args, effect, scope)
165 })
166
167// Closing must outlive an already-aborted signal, so it runs without one.
168const closeHookScope = (scope: Scope.Closeable) => runPromise(Scope.close(scope, Exit.void))
169
170/** @internal */
171export const beforeAll: typeof V.beforeAll = (fn, timeout) => {
172 let dispose = () => Promise.resolve()
173 // Vitest drops the cleanup of a setup that outlives its timeout, so the
174 // scope is disposed by the cleanup of a hook registered just before this
175 // one. It runs in the same cleanup phase, in the slot next to this hook's,
176 // with this hook's timeout.
177 V.beforeAll(() => () => dispose(), timeout)
178 V.beforeAll(
179 runHook(fn, (_, effect, scope) => {
180 const fiber = Effect.runFork(effect)
181 // Interrupting a setup that Vitest stopped waiting for awaits its release.
182 dispose = () => runPromise(Effect.andThen(Fiber.interrupt(fiber), Scope.close(scope, Exit.void)))
183 return runPromise(Fiber.join(fiber))
184 }),
185 timeout
186 )
187}
188
189/** @internal */
190export const beforeEach: typeof V.beforeEach = (fn, timeout) =>
191 V.beforeEach(
192 runHook(fn, ([ctx], effect, scope) =>
193 // The scope closes when Vitest runs the hook's cleanup after the test,
194 // and the next test waits for it even if that cleanup times out.
195 runTest(ctx)(effect).then(() => () => settleOnAbort(ctx, closeHookScope(scope)))),
196 timeout
197 )
198
199const makeItProxy = <Methods extends object, ExtraContext>(
200 it: V.TestAPI<ExtraContext>,
201 overrides: Methods
202): Methods & V.TestAPI<ExtraContext> =>
203 new Proxy(it as Methods & V.TestAPI<ExtraContext>, {
204 apply(target, thisArg, argArray) {
205 return Reflect.apply(target, thisArg, argArray)
206 },
207 get(target, property, receiver) {
208 if (Object.hasOwn(overrides, property)) {
209 return Reflect.get(overrides, property)
210 }
211 // do not bind: binding would strip vitest's static helpers (e.g. `describe.each`)
212 return Reflect.get(target, property, receiver)
213 }
214 })
215
216type CollectedTask = {
217 readonly type: string
218 readonly mode?: string
219 readonly tasks?: ReadonlyArray<CollectedTask>
220}
221
222const collectTasks = (tasks: ReadonlyArray<CollectedTask>, acc: Array<V.TestContext["task"]> = []) => {
223 for (let i = 0; i < tasks.length; i++) {
224 const task = tasks[i]
225 if (task.type === "test" && task.mode !== "skip" && task.mode !== "todo") {
226 acc.push(task as V.TestContext["task"])
227 } else if (task.tasks !== undefined) {
228 collectTasks(task.tasks, acc)
229 }
230 }
231 return acc
232}
233
234const runsMoreThanOnce = (task: V.TestContext["task"]) => {
235 const retry = typeof task.retry === "number" ? task.retry : task.retry?.count
236 return (retry ?? 0) > 0 || (task.repeats ?? 0) > 0
237}
238
239const registerProp = <ExtraContext, A>(
240 it: V.TestAPI<ExtraContext>,
241 name: string,
242 arbitraries: Arbitraries,
243 property: (values: A, ctx: V.TestContext) => boolean | Effect.Effect<boolean, unknown>,
244 timeout: PropertyTimeout | undefined
245) => {
246 const arbitrary = makeArbitrary(arbitraries)
247 // Property callbacks receive only the base context; the empty source requests no fixtures.
248 it(
249 name,
250 testOptions(timeout),
251 withFixturesOf(
252 () => {},
253 (ctx: V.TestContext) => runCheck(ctx, arbitrary, (values) => property(values, ctx), checkOptions(timeout))
254 )
255 )
256}
257
258const makeTester = <R, ExtraContext>(
259 mapEffect: <A, E>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, never>,
260 it: V.TestAPI<ExtraContext>
261): Vitest.Vitest.Tester<R, ExtraContext> => {
262 const run = <A, E, TestArgs extends Array<unknown>>(
263 ctx: V.TestContext,
264 args: TestArgs,
265 self: Vitest.Vitest.TestFunction<A, E, R, TestArgs>
266 ) => pipe(Effect.suspend(() => self(...args)), mapEffect, runTest(ctx))
267
268 const register = (
269 test: (name: string, options: V.TestOptions, fn: V.TestFunction<ExtraContext>) => void
270 ): Vitest.Vitest.Test<R, ExtraContext> =>
271 (name, self, timeout) => test(name, testOptions(timeout), withFixturesOf(self, (ctx) => run(ctx, [ctx], self)))
272
273 const each: Vitest.Vitest.Tester<R, ExtraContext>["each"] = (cases) => (name, self, timeout) =>
274 it.for(cases)(
275 name,
276 testOptions(timeout),
277 withFixturesOf(self, (args, ctx) => run(ctx, [args, ctx], self).then(constVoid))
278 )
279
280 const prop: Vitest.Vitest.Tester<R, ExtraContext>["prop"] = (name, arbitraries, self, timeout) =>
281 registerProp(
282 it,
283 name,
284 arbitraries,
285 (values, ctx) =>
286 Effect.mapEager(
287 mapEffect(Effect.suspend(() => self(values as any, ctx))),
288 (value) => (value as unknown) !== false
289 ),
290 timeout
291 )
292
293 return Object.assign(register(it), {
294 skip: register(it.skip),
295 skipIf: (condition: unknown) => register(it.skipIf(condition)),
296 runIf: (condition: unknown) => register(it.runIf(condition)),
297 only: register(it.only),
298 each,
299 fails: register(it.fails),
300 prop
301 })
302}
303
304const makeProp =
305 <ExtraContext>(it: V.TestAPI<ExtraContext>): Vitest.Vitest.Methods["prop"] => (name, arbitraries, self, timeout) =>
306 registerProp(it, name, arbitraries, (values, ctx) => {
307 const result = self(values as any, ctx)
308 if (result !== undefined && typeof result !== "boolean") {
309 throw new TypeError("it.prop callbacks must return boolean or void. Use it.effect.prop for Effect callbacks.")
310 }
311 return result !== false
312 }, timeout)
313
314const makeLayer = <ExtraContext>(it: V.TestAPI<ExtraContext>) =>
315<R, E>(
316 layer_: Layer.Layer<R, E>,
317 options?: {
318 readonly concurrent?: boolean
319 readonly memoMap?: Layer.MemoMap
320 readonly timeout?: Duration.Input
321 readonly excludeTestServices?: boolean
322 readonly buildPerTest?: boolean
323 }
324): {
325 (f: (it: Vitest.Vitest.MethodsNonLive<R, ExtraContext>) => void): void
326 (
327 name: string,
328 f: (it: Vitest.Vitest.MethodsNonLive<R, ExtraContext>) => void
329 ): void
330} =>
331(
332 ...args: [
333 name: string,
334 f: (
335 it: Vitest.Vitest.MethodsNonLive<R, ExtraContext>
336 ) => void
337 ] | [
338 f: (it: Vitest.Vitest.MethodsNonLive<R, ExtraContext>) => void
339 ]
340) => {
341 const excludeTestServices = options?.excludeTestServices ?? false
342 const withTestEnv = excludeTestServices
343 ? layer_ as Layer.Layer<R, E>
344 : Layer.provideMerge(layer_, TestEnv)
345 const memoMap = options?.memoMap ?? Effect.runSync(Layer.makeMemoMap)
346 const buildPerTest = options?.buildPerTest ?? false
347 const suiteOptions = options?.concurrent === undefined ? {} : { concurrent: options.concurrent }
348
349 const makeBlockMethods = (
350 provide: <A, E>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>
351 ): Vitest.Vitest.MethodsNonLive<R, ExtraContext> =>
352 makeItProxy(it, {
353 effect: makeTester<R | Scope.Scope, ExtraContext>((effect) => provide(Effect.scoped(effect)), it),
354 prop: makeProp<ExtraContext>(it),
355 flakyTest,
356 layer<R2, E2>(nestedLayer: Layer.Layer<R2, E2, R>, nestedOptions?: {
357 readonly concurrent?: boolean
358 readonly timeout?: Duration.Input
359 readonly buildPerTest?: boolean
360 }) {
361 return makeLayer(it)(Layer.provideMerge(nestedLayer, withTestEnv), {
362 ...nestedOptions,
363 memoMap: Layer.forkMemoMapUnsafe(memoMap),
364 excludeTestServices,
365 // The services of a per-test block only exist during a test, so
366 // blocks nested in it are built per test as well.
367 buildPerTest: buildPerTest || nestedOptions?.buildPerTest === true
368 })
369 }
370 })
371
372 if (buildPerTest) {
373 // Each test builds into its own fork of the memo map, so it reuses the
374 // services of enclosing blocks and releases the rest when it finishes.
375 const methods = makeBlockMethods((effect) =>
376 Effect.scopedWith((scope) =>
377 Layer.buildWithMemoMap(withTestEnv, Layer.forkMemoMapUnsafe(memoMap), scope).pipe(
378 Effect.orDie,
379 Effect.flatMap((context) => Effect.provide(effect, context))
380 )
381 )
382 )
383 if (args.length === 1) {
384 return args[0](methods)
385 }
386 return V.describe(args[0], suiteOptions, () => args[1](methods))
387 }
388
389 const scope = Effect.runSync(Scope.make())
390 const contextEffect = Layer.buildWithMemoMap(withTestEnv, memoMap, scope).pipe(
391 Effect.orDie,
392 Effect.cached,
393 Effect.runSync
394 )
395 let closed = false
396 const closeScope = () => {
397 if (closed) {
398 return Promise.resolve()
399 }
400 closed = true
401 // Layer cleanup must outlive the last test's already-aborted signal.
402 return runPromise(Scope.close(scope, Exit.void))
403 }
404
405 const methods = makeBlockMethods((effect) =>
406 Effect.flatMap(contextEffect, (context) => Effect.provide(effect, context))
407 )
408
409 if (args.length === 1) {
410 const currentSuite = getCurrentSuite()
411 const previousTasks = new Set(currentSuite.tasks)
412
413 args[0](methods)
414
415 const blockTasks = collectTasks(
416 currentSuite.tasks.filter((task) => !previousTasks.has(task)) as ReadonlyArray<CollectedTask>
417 )
418 if (blockTasks.length === 0) {
419 V.afterAll(() => closeScope(), hookTimeout(options?.timeout))
420 return
421 }
422
423 const blockTaskSet = new Set(blockTasks)
424 // Vitest runs hooks once per retry and repeat attempt, so a test's last
425 // attempt cannot be counted. Blocks with such tests are released by `afterAll`.
426 const releaseAfterLastTest = !blockTasks.some(runsMoreThanOnce)
427 let remaining = blockTasks.length
428
429 V.beforeEach(
430 // Destructured so Vitest can parse the hook once the suite defines fixtures.
431 ({ onTestFinished, signal, task }) => {
432 if (!blockTaskSet.has(task)) {
433 return
434 }
435 if (releaseAfterLastTest) {
436 onTestFinished(() => {
437 remaining--
438 if (remaining === 0) {
439 return closeScope()
440 }
441 })
442 }
443 return runPromise(Effect.asVoid(contextEffect), { signal })
444 },
445 hookTimeout(options?.timeout)
446 )
447 V.afterAll(() => closeScope(), hookTimeout(options?.timeout))
448 return
449 }
450
451 return V.describe(args[0], suiteOptions, () => {
452 V.beforeAll(
453 () => runPromise(Effect.asVoid(contextEffect)),
454 hookTimeout(options?.timeout)
455 )
456 V.afterAll(
457 () => closeScope(),
458 hookTimeout(options?.timeout)
459 )
460 return args[1](methods)
461 })
462}
463
464/** @internal */
465export const flakyTest = <A, E, R>(
466 self: Effect.Effect<A, E, R | Scope.Scope>,
467 timeout: Duration.Input = Duration.seconds(30)
468) =>
469 pipe(
470 self,
471 Effect.scoped,
472 Effect.sandbox,
473 Effect.retry(
474 pipe(
475 Schedule.recurs(10),
476 Schedule.while((_) =>
477 Effect.succeed(Duration.isLessThanOrEqualTo(
478 Duration.fromInputUnsafe(_.elapsed),
479 Duration.fromInputUnsafe(timeout)
480 ))
481 )
482 )
483 ),
484 Effect.orDie
485 )
486
487/** @internal */
488export const makeMethods = <ExtraContext>(it: V.TestAPI<ExtraContext>): Vitest.Vitest.Methods<never, ExtraContext> =>
489 makeItProxy(it, {
490 effect: makeTester<Scope.Scope, ExtraContext>(flow(Effect.scoped, Effect.provide(TestEnv)), it),
491 live: makeTester<Scope.Scope, ExtraContext>(Effect.scoped, it),
492 flakyTest,
493 layer: makeLayer<ExtraContext>(it),
494 prop: makeProp<ExtraContext>(it)
495 })
496
497/** @internal */
498export const it = makeMethods(V.it)
499
500/** @internal */
501export const {
502 /** @internal */
503 effect,
504 /** @internal */
505 layer,
506 /** @internal */
507 live,
508 /** @internal */
509 prop
510} = it
511
512/** @internal */
513export const describeWrapped = (name: string, f: (it: Vitest.Vitest.Methods) => void): V.SuiteCollector =>
514 V.describe(name, () => f(it))