| 1 | /** |
| 2 | * @since 4.0.0 |
| 3 | */ |
| 4 | |
| 5 | import * as Arbitrary from "effect/Arbitrary" |
| 6 | import * as Cause from "effect/Cause" |
| 7 | import * as Duration from "effect/Duration" |
| 8 | import * as Effect from "effect/Effect" |
| 9 | import * as Exit from "effect/Exit" |
| 10 | import { constVoid, flow, pipe } from "effect/Function" |
| 11 | import * as Layer from "effect/Layer" |
| 12 | import * as Schedule from "effect/Schedule" |
| 13 | import type * as Schema from "effect/Schema" |
| 14 | import * as Scope from "effect/Scope" |
| 15 | import * as TestClock from "effect/testing/TestClock" |
| 16 | import * as TestConsole from "effect/testing/TestConsole" |
| 17 | import * as V from "vitest" |
| 18 | import type * as Vitest from "../index.ts" |
| 19 | |
| 20 | const getCurrentSuite = V.TestRunner.getCurrentSuite |
| 21 | |
| 22 | const 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 */ |
| 40 | const 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 */ |
| 57 | export type TestContext = TestConsole.TestConsole | TestClock.TestClock |
| 58 | |
| 59 | const TestEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) |
| 60 | |
| 61 | /** @internal */ |
| 62 | export const addEqualityTesters = () => { |
| 63 | V.expect.addEqualityTesters([]) |
| 64 | } |
| 65 | |
| 66 | /** @internal */ |
| 67 | const testOptions = (timeout?: number | V.TestOptions) => typeof timeout === "number" ? { timeout } : timeout ?? {} |
| 68 | |
| 69 | const hookTimeout = (timeout?: Duration.Input) => |
| 70 | timeout === undefined ? undefined : Duration.toMillis(Duration.fromInputUnsafe(timeout)) |
| 71 | |
| 72 | type PropertyTimeout = |
| 73 | | number |
| 74 | | V.TestOptions & { |
| 75 | readonly arbitrary?: Arbitrary.CheckOptions | undefined |
| 76 | } |
| 77 | |
| 78 | type ArbitraryInput = Schema.Schema<any> | Arbitrary.Arbitrary<unknown> |
| 79 | |
| 80 | type Arbitraries = Array<ArbitraryInput> | { [K in string]: ArbitraryInput } |
| 81 | |
| 82 | const propertyTestOptions = ( |
| 83 | timeout: PropertyTimeout | undefined |
| 84 | ): Exclude<PropertyTimeout, number> | undefined => typeof timeout === "number" ? undefined : timeout |
| 85 | |
| 86 | const checkOptions = (timeout: PropertyTimeout | undefined): Arbitrary.CheckOptions | undefined => |
| 87 | propertyTestOptions(timeout)?.arbitrary |
| 88 | |
| 89 | const compileArbitraryInput = (input: ArbitraryInput): Arbitrary.Arbitrary<any> => |
| 90 | Arbitrary.isArbitrary(input) ? input : Arbitrary.schema(input) |
| 91 | |
| 92 | const 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 | |
| 99 | const 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 | |
| 112 | const 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. |
| 131 | const withFixturesOf = <F extends Function>(source: Function, f: F): F => |
| 132 | Object.defineProperty(f, "toString", { value: () => source.toString() }) |
| 133 | |
| 134 | const 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 | |
| 151 | type CollectedTask = { |
| 152 | readonly type: string |
| 153 | readonly mode?: string |
| 154 | readonly tasks?: ReadonlyArray<CollectedTask> |
| 155 | } |
| 156 | |
| 157 | const 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 | |
| 169 | const 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 | |
| 188 | const 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 | |
| 234 | const 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 | |
| 238 | const 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 */ |
| 361 | export 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 */ |
| 384 | export 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 */ |
| 394 | export const it = makeMethods(V.it) |
| 395 | |
| 396 | /** @internal */ |
| 397 | export const { |
| 398 | /** @internal */ |
| 399 | effect, |
| 400 | /** @internal */ |
| 401 | layer, |
| 402 | /** @internal */ |
| 403 | live, |
| 404 | /** @internal */ |
| 405 | prop |
| 406 | } = it |
| 407 | |
| 408 | /** @internal */ |
| 409 | export const describeWrapped = (name: string, f: (it: Vitest.Vitest.Methods) => void): V.SuiteCollector => |
| 410 | V.describe(name, () => f(it)) |