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 { constVoid, flow, pipe } from "effect/Function"
11import * as Layer from "effect/Layer"
12import * as Schedule from "effect/Schedule"
13import type * as Schema from "effect/Schema"
14import * as Scope from "effect/Scope"
15import * as TestClock from "effect/testing/TestClock"
16import * as TestConsole from "effect/testing/TestConsole"
17import * as V from "vitest"
18import type * as Vitest from "../index.ts"
19
20const getCurrentSuite = V.TestRunner.getCurrentSuite
21
22const runPromise: <E, A>(
23 _: Effect.Effect<A, E, never>,
24 ctx?: Pick<V.TestContext, "signal"> | undefined
25) => Promise<A> = Effect.fnUntraced(function*<E, A>(
26 effect: Effect.Effect<A, E>,
27 _ctx?: Pick<Vitest.TestContext, "signal">
28) {
29 const exit = yield* Effect.exit(effect)
30 if (Exit.isFailure(exit)) {
31 const errors = Cause.prettyErrors(exit.cause)
32 for (let i = 0; i < errors.length; i++) {
33 yield* Effect.logError(errors[i])
34 }
35 }
36 return yield* exit
37}, (effect, _, ctx) => Effect.runPromise(effect, { signal: ctx?.signal }))
38
39/** @internal */
40const runTest = (ctx?: Vitest.TestContext) => <E, A>(effect: Effect.Effect<A, E>) => {
41 const promise = runPromise(effect, ctx)
42 if (ctx) {
43 // Vitest stops awaiting the test promise once the signal aborts (timeout or
44 // cancellation), so only then add a hook that waits for the finalizers.
45 // Registering it unconditionally would change the teardown of every test.
46 const onAbort = () => ctx.onTestFinished(() => promise.then(constVoid, constVoid))
47 ctx.signal.addEventListener("abort", onAbort, { once: true })
48 const cleanup = () => ctx.signal.removeEventListener("abort", onAbort)
49 promise.then(cleanup, cleanup)
50 // A retry after a timed-out attempt reuses the aborted signal, so no event fires.
51 if (ctx.signal.aborted) onAbort()
52 }
53 return promise
54}
55
56/** @internal */
57export type TestContext = TestConsole.TestConsole | TestClock.TestClock
58
59const TestEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
60
61/** @internal */
62export const addEqualityTesters = () => {
63 V.expect.addEqualityTesters([])
64}
65
66/** @internal */
67const testOptions = (timeout?: number | V.TestOptions) => typeof timeout === "number" ? { timeout } : timeout ?? {}
68
69const hookTimeout = (timeout?: Duration.Input) =>
70 timeout === undefined ? undefined : Duration.toMillis(Duration.fromInputUnsafe(timeout))
71
72type PropertyTimeout =
73 | number
74 | V.TestOptions & {
75 readonly arbitrary?: Arbitrary.CheckOptions | undefined
76 }
77
78type ArbitraryInput = Schema.Schema<any> | Arbitrary.Arbitrary<unknown>
79
80type Arbitraries = Array<ArbitraryInput> | { [K in string]: ArbitraryInput }
81
82const propertyTestOptions = (
83 timeout: PropertyTimeout | undefined
84): Exclude<PropertyTimeout, number> | undefined => typeof timeout === "number" ? undefined : timeout
85
86const checkOptions = (timeout: PropertyTimeout | undefined): Arbitrary.CheckOptions | undefined =>
87 propertyTestOptions(timeout)?.arbitrary
88
89const compileArbitraryInput = (input: ArbitraryInput): Arbitrary.Arbitrary<any> =>
90 Arbitrary.isArbitrary(input) ? input : Arbitrary.schema(input)
91
92const makeArbitrary = (arbitraries: Arbitraries): Arbitrary.Arbitrary<any> =>
93 Arbitrary.all(
94 Array.isArray(arbitraries)
95 ? arbitraries.map(compileArbitraryInput)
96 : Object.fromEntries(Object.entries(arbitraries).map(([key, input]) => [key, compileArbitraryInput(input)]))
97 )
98
99const normalizeProperty = <A, E, R>(
100 property: (value: A) => boolean | Effect.Effect<boolean, E, R>,
101 value: A
102): Effect.Effect<boolean, E | Cause.Cause<E>, R> =>
103 Effect.catchCause(
104 Effect.suspend(() => {
105 const output = property(value)
106 return Effect.isEffect(output) ? output : Effect.succeed(output)
107 }),
108 (cause): Effect.Effect<never, E | Cause.Cause<E>> =>
109 Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.fail(cause)
110 )
111
112const runCheck = <A, E>(
113 ctx: V.TestContext,
114 arbitrary: Arbitrary.Arbitrary<A>,
115 property: (value: A) => boolean | Effect.Effect<boolean, E>,
116 options: Arbitrary.CheckOptions | undefined
117): Promise<void> =>
118 runTest(ctx)(
119 Effect.flatMapEager(
120 Arbitrary.checkEffect(arbitrary, (value) => normalizeProperty(property, value), options),
121 (result) => {
122 const failure = Arbitrary.formatCheckFailure(result)
123 return failure === undefined ? Effect.void : Effect.die(new Error(failure))
124 }
125 )
126 )
127
128// Vitest decides which fixtures to set up by parsing the source of a test
129// function's context parameter, and there is no public API to supply the names
130// for a wrapper, so wrappers expose the source of the function they call.
131const withFixturesOf = <F extends Function>(source: Function, f: F): F =>
132 Object.defineProperty(f, "toString", { value: () => source.toString() })
133
134const makeItProxy = <Methods extends object, ExtraContext>(
135 it: V.TestAPI<ExtraContext>,
136 overrides: Methods
137): Methods & V.TestAPI<ExtraContext> =>
138 new Proxy(it as Methods & V.TestAPI<ExtraContext>, {
139 apply(target, thisArg, argArray) {
140 return Reflect.apply(target, thisArg, argArray)
141 },
142 get(target, property, receiver) {
143 if (Object.hasOwn(overrides, property)) {
144 return Reflect.get(overrides, property)
145 }
146 // do not bind: binding would strip vitest's static helpers (e.g. `describe.each`)
147 return Reflect.get(target, property, receiver)
148 }
149 })
150
151type CollectedTask = {
152 readonly type: string
153 readonly mode?: string
154 readonly tasks?: ReadonlyArray<CollectedTask>
155}
156
157const collectTasks = (tasks: ReadonlyArray<CollectedTask>, acc: Array<V.TestContext["task"]> = []) => {
158 for (let i = 0; i < tasks.length; i++) {
159 const task = tasks[i]
160 if (task.type === "test" && task.mode !== "skip" && task.mode !== "todo") {
161 acc.push(task as V.TestContext["task"])
162 } else if (task.tasks !== undefined) {
163 collectTasks(task.tasks, acc)
164 }
165 }
166 return acc
167}
168
169const registerProp = <ExtraContext, A>(
170 it: V.TestAPI<ExtraContext>,
171 name: string,
172 arbitraries: Arbitraries,
173 property: (values: A, ctx: V.TestContext) => boolean | Effect.Effect<boolean, unknown>,
174 timeout: PropertyTimeout | undefined
175) => {
176 const arbitrary = makeArbitrary(arbitraries)
177 // Property callbacks receive only the base context; the empty source requests no fixtures.
178 it(
179 name,
180 testOptions(timeout),
181 withFixturesOf(
182 () => {},
183 (ctx: V.TestContext) => runCheck(ctx, arbitrary, (values) => property(values, ctx), checkOptions(timeout))
184 )
185 )
186}
187
188const makeTester = <R, ExtraContext>(
189 mapEffect: <A, E>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, never>,
190 it: V.TestAPI<ExtraContext>
191): Vitest.Vitest.Tester<R, ExtraContext> => {
192 const run = <A, E, TestArgs extends Array<unknown>>(
193 ctx: V.TestContext,
194 args: TestArgs,
195 self: Vitest.Vitest.TestFunction<A, E, R, TestArgs>
196 ) => pipe(Effect.suspend(() => self(...args)), mapEffect, runTest(ctx))
197
198 const register = (
199 test: (name: string, options: V.TestOptions, fn: V.TestFunction<ExtraContext>) => void
200 ): Vitest.Vitest.Test<R, ExtraContext> =>
201 (name, self, timeout) => test(name, testOptions(timeout), withFixturesOf(self, (ctx) => run(ctx, [ctx], self)))
202
203 const each: Vitest.Vitest.Tester<R, ExtraContext>["each"] = (cases) => (name, self, timeout) =>
204 it.for(cases)(
205 name,
206 testOptions(timeout),
207 withFixturesOf(self, (args, ctx) => run(ctx, [args, ctx], self).then(constVoid))
208 )
209
210 const prop: Vitest.Vitest.Tester<R, ExtraContext>["prop"] = (name, arbitraries, self, timeout) =>
211 registerProp(
212 it,
213 name,
214 arbitraries,
215 (values, ctx) =>
216 Effect.mapEager(
217 mapEffect(Effect.suspend(() => self(values as any, ctx))),
218 (value) => (value as unknown) !== false
219 ),
220 timeout
221 )
222
223 return Object.assign(register(it), {
224 skip: register(it.skip),
225 skipIf: (condition: unknown) => register(it.skipIf(condition)),
226 runIf: (condition: unknown) => register(it.runIf(condition)),
227 only: register(it.only),
228 each,
229 fails: register(it.fails),
230 prop
231 })
232}
233
234const makeProp =
235 <ExtraContext>(it: V.TestAPI<ExtraContext>): Vitest.Vitest.Methods["prop"] => (name, arbitraries, self, timeout) =>
236 registerProp(it, name, arbitraries, (values, ctx) => (self(values as any, ctx) as unknown) !== false, timeout)
237
238const makeLayer = <ExtraContext>(it: V.TestAPI<ExtraContext>) =>
239<R, E>(
240 layer_: Layer.Layer<R, E>,
241 options?: {
242 readonly concurrent?: boolean
243 readonly memoMap?: Layer.MemoMap
244 readonly timeout?: Duration.Input
245 readonly excludeTestServices?: boolean
246 }
247): {
248 (f: (it: Vitest.Vitest.MethodsNonLive<R, ExtraContext>) => void): void
249 (
250 name: string,
251 f: (it: Vitest.Vitest.MethodsNonLive<R, ExtraContext>) => void
252 ): void
253} =>
254(
255 ...args: [
256 name: string,
257 f: (
258 it: Vitest.Vitest.MethodsNonLive<R, ExtraContext>
259 ) => void
260 ] | [
261 f: (it: Vitest.Vitest.MethodsNonLive<R, ExtraContext>) => void
262 ]
263) => {
264 const excludeTestServices = options?.excludeTestServices ?? false
265 const withTestEnv = excludeTestServices
266 ? layer_ as Layer.Layer<R, E>
267 : Layer.provideMerge(layer_, TestEnv)
268 const memoMap = options?.memoMap ?? Effect.runSync(Layer.makeMemoMap)
269 const scope = Effect.runSync(Scope.make())
270 const contextEffect = Layer.buildWithMemoMap(withTestEnv, memoMap, scope).pipe(
271 Effect.orDie,
272 Effect.cached,
273 Effect.runSync
274 )
275 let closed = false
276 const closeScope = () => {
277 if (closed) {
278 return Promise.resolve()
279 }
280 closed = true
281 // Layer cleanup must outlive the last test's already-aborted signal.
282 return runPromise(Scope.close(scope, Exit.void))
283 }
284
285 const methods: Vitest.Vitest.MethodsNonLive<R, ExtraContext> = makeItProxy(it, {
286 effect: makeTester<R | Scope.Scope, ExtraContext>(
287 (effect) =>
288 Effect.flatMap(contextEffect, (context) =>
289 effect.pipe(
290 Effect.scoped,
291 Effect.provide(context)
292 )),
293 it
294 ),
295 prop: makeProp<ExtraContext>(it),
296 flakyTest,
297 layer<R2, E2>(nestedLayer: Layer.Layer<R2, E2, R>, options?: {
298 readonly concurrent?: boolean
299 readonly timeout?: Duration.Input
300 }) {
301 return makeLayer(it)(Layer.provideMerge(nestedLayer, withTestEnv), {
302 ...options,
303 memoMap: Layer.forkMemoMapUnsafe(memoMap),
304 excludeTestServices
305 })
306 }
307 })
308
309 if (args.length === 1) {
310 const currentSuite = getCurrentSuite()
311 const previousTasks = new Set(currentSuite.tasks)
312
313 args[0](methods)
314
315 const blockTasks = collectTasks(
316 currentSuite.tasks.filter((task) => !previousTasks.has(task)) as ReadonlyArray<CollectedTask>
317 )
318 if (blockTasks.length === 0) {
319 V.afterAll(() => closeScope(), hookTimeout(options?.timeout))
320 return
321 }
322
323 const blockTaskSet = new Set(blockTasks)
324 let remaining = blockTasks.length
325
326 V.beforeEach(
327 // Destructured so Vitest can parse the hook once the suite defines fixtures.
328 ({ onTestFinished, signal, task }) => {
329 if (!blockTaskSet.has(task)) {
330 return
331 }
332 onTestFinished(() => {
333 remaining--
334 if (remaining === 0) {
335 return closeScope()
336 }
337 })
338 return runPromise(Effect.asVoid(contextEffect), { signal })
339 },
340 hookTimeout(options?.timeout)
341 )
342 V.afterAll(() => closeScope(), hookTimeout(options?.timeout))
343 return
344 }
345
346 const suiteOptions = options?.concurrent === undefined ? {} : { concurrent: options.concurrent }
347 return V.describe(args[0], suiteOptions, () => {
348 V.beforeAll(
349 () => runPromise(Effect.asVoid(contextEffect)),
350 hookTimeout(options?.timeout)
351 )
352 V.afterAll(
353 () => closeScope(),
354 hookTimeout(options?.timeout)
355 )
356 return args[1](methods)
357 })
358}
359
360/** @internal */
361export const flakyTest = <A, E, R>(
362 self: Effect.Effect<A, E, R | Scope.Scope>,
363 timeout: Duration.Input = Duration.seconds(30)
364) =>
365 pipe(
366 self,
367 Effect.scoped,
368 Effect.sandbox,
369 Effect.retry(
370 pipe(
371 Schedule.recurs(10),
372 Schedule.while((_) =>
373 Effect.succeed(Duration.isLessThanOrEqualTo(
374 Duration.fromInputUnsafe(_.elapsed),
375 Duration.fromInputUnsafe(timeout)
376 ))
377 )
378 )
379 ),
380 Effect.orDie
381 )
382
383/** @internal */
384export const makeMethods = <ExtraContext>(it: V.TestAPI<ExtraContext>): Vitest.Vitest.Methods<never, ExtraContext> =>
385 makeItProxy(it, {
386 effect: makeTester<Scope.Scope, ExtraContext>(flow(Effect.scoped, Effect.provide(TestEnv)), it),
387 live: makeTester<Scope.Scope, ExtraContext>(Effect.scoped, it),
388 flakyTest,
389 layer: makeLayer<ExtraContext>(it),
390 prop: makeProp<ExtraContext>(it)
391 })
392
393/** @internal */
394export const it = makeMethods(V.it)
395
396/** @internal */
397export const {
398 /** @internal */
399 effect,
400 /** @internal */
401 layer,
402 /** @internal */
403 live,
404 /** @internal */
405 prop
406} = it
407
408/** @internal */
409export const describeWrapped = (name: string, f: (it: Vitest.Vitest.Methods) => void): V.SuiteCollector =>
410 V.describe(name, () => f(it))