From 674f2d55de9813d5ec0552bfe1bfb6f6731475ae Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Mon, 17 Aug 2026 15:21:14 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20the=20`beforeEach`=20plugin?= =?UTF-8?q?=20to=20hook=20in=20life-cycle=20(#7224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes #issue-number ## Checklist — _Don't delete this checklist and make sure you do the following before opening the PR_ - [ ] I have a full understanding of every line in this PR — whether the code was hand-written, AI-generated, copied from external sources or produced by any other tool - [ ] I flagged the impact of my change (minor / patch / major) either by running `pnpm run bump` or by following the instructions from the changeset bot - [ ] I kept this PR focused on a single concern and did not bundle unrelated changes - [ ] I followed the [gitmoji](https://gitmoji.dev/) specification for the name of the PR, including the package scope (e.g. `🐛(vitest) Something...`) when the change targets a package other than `fast-check` - [ ] I added relevant tests and they would have failed without my PR (when applicable) --- .changeset/every-canyons-taste.md | 5 + .../src/check/plugin/LifeCyclePlugins.ts | 74 ++++ packages/fast-check/src/fast-check-default.ts | 2 + .../check/plugin/LifeCyclePlugins.spec.ts | 366 ++++++++++++++++++ website/docs/configuration/global-settings.md | 2 +- website/docs/core-blocks/index.md | 5 +- .../plugins/index.md} | 26 +- .../docs/core-blocks/plugins/life-cycle.md | 50 +++ 8 files changed, 518 insertions(+), 12 deletions(-) create mode 100644 .changeset/every-canyons-taste.md create mode 100644 packages/fast-check/src/check/plugin/LifeCyclePlugins.ts create mode 100644 packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts rename website/docs/{advanced/plugins.md => core-blocks/plugins/index.md} (52%) create mode 100644 website/docs/core-blocks/plugins/life-cycle.md diff --git a/.changeset/every-canyons-taste.md b/.changeset/every-canyons-taste.md new file mode 100644 index 00000000..622e88c9 --- /dev/null +++ b/.changeset/every-canyons-taste.md @@ -0,0 +1,5 @@ +--- +"fast-check": minor +--- + +✨ Add the `beforeEach` plugin to hook in life-cycle diff --git a/packages/fast-check/src/check/plugin/LifeCyclePlugins.ts b/packages/fast-check/src/check/plugin/LifeCyclePlugins.ts new file mode 100644 index 00000000..3e23ca74 --- /dev/null +++ b/packages/fast-check/src/check/plugin/LifeCyclePlugins.ts @@ -0,0 +1,74 @@ +import type { IRawProperty, PropertyFailure } from '../property/IRawProperty.js'; +import type { Plugin, PluginInstance } from './Plugin.js'; + +const LifeCyclePluginSymbol = Symbol.for('fast-check/plugin/life-cycle'); + +type AfterEachHook = () => Promise | void; +type BeforeEachHook = () => Promise | void; +type LifeCycleHooks = { lastPluginIndex: number; beforeHooks: BeforeEachHook[]; afterHooks: AfterEachHook[] }; + +function lifeCycleHooksRunner( + hooks: LifeCycleHooks, + nestedRun: IRawProperty['run'], + value: unknown, +): ReturnType { + let beforeContinuation: Promise | undefined = undefined; + let beforeFailed: PropertyFailure | undefined = undefined; + if (hooks.beforeHooks.length !== 0) { + try { + for (const before of hooks.beforeHooks) { + if (beforeContinuation === undefined) { + const out = before(); + if (typeof out === 'object') { + beforeContinuation = out; + } + } else { + beforeContinuation = beforeContinuation.then(() => before()); + } + } + } catch (error) { + beforeFailed = { error }; + } + } + const runOut: ReturnType = + beforeFailed === undefined + ? beforeContinuation === undefined + ? nestedRun(value) + : beforeContinuation.then( + () => nestedRun(value), + (error) => ({ error }), + ) + : beforeFailed; + return runOut; +} + +/** + * Register a callback to be called before each run of your predicate. + * If the function returns a promise, we wait until the promise resolves before running anything else. + * + * @example + * ```ts + * fc.assert( + * fc.property(..., (...) => {...}), + * { plugins: [fc.beforeEachPlugin(() => {...})] } + * ) + * ``` + * + * @param fn - Hook to be executed before each execution of the predicate + * + * @remarks Since 4.10.0 + * @public + */ +export function beforeEach(fn: BeforeEachHook): Plugin { + return (pluginIndex, crossPluginContext): PluginInstance => { + let lifeCycleHooks = crossPluginContext[LifeCyclePluginSymbol] as LifeCycleHooks | undefined; + if (lifeCycleHooks !== undefined && lifeCycleHooks.lastPluginIndex === pluginIndex - 1) { + lifeCycleHooks.lastPluginIndex = pluginIndex; + lifeCycleHooks.beforeHooks.push(fn); + return {}; + } + lifeCycleHooks = { lastPluginIndex: pluginIndex, beforeHooks: [fn], afterHooks: [] }; + crossPluginContext[LifeCyclePluginSymbol] = lifeCycleHooks; + return { decorateRun: (nestedRun) => (value) => lifeCycleHooksRunner(lifeCycleHooks, nestedRun, value) }; + }; +} diff --git a/packages/fast-check/src/fast-check-default.ts b/packages/fast-check/src/fast-check-default.ts index 0cda5d19..35aa1eaf 100644 --- a/packages/fast-check/src/fast-check-default.ts +++ b/packages/fast-check/src/fast-check-default.ts @@ -210,6 +210,7 @@ import { limitShrink } from './arbitrary/limitShrink.js'; import type { RandomGenerator } from './random/generator/RandomGenerator.js'; import type { Plugin, PluginInstance } from './check/plugin/Plugin.js'; import { installGlobalPlugin } from './check/runner/configuration/GlobalPlugins.js'; +import { beforeEach as beforeEachPlugin } from './check/plugin/LifeCyclePlugins.js'; // Explicit cast into string to avoid to have __type: "process.env.__PACKAGE_TYPE__" /** @@ -454,6 +455,7 @@ export { readConfigureGlobal, resetConfigureGlobal, installGlobalPlugin, + beforeEachPlugin, ExecutionStatus, Random, Stream, diff --git a/packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts b/packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts new file mode 100644 index 00000000..2b055e8e --- /dev/null +++ b/packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts @@ -0,0 +1,366 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as fc from 'fast-check'; +import { beforeEach } from '../../../../src/check/plugin/LifeCyclePlugins.js'; +import type { IRawProperty } from '../../../../src/check/property/IRawProperty.js'; +import { PreconditionFailure } from '../../../../src/check/precondition/PreconditionFailure.js'; +import type { Plugin } from '../../../../src/check/plugin/Plugin.js'; + +describe('LifeCyclePlugins', () => { + describe('ordering', () => { + it.each([{ runner: 'sync' }, { runner: 'async' }])( + 'should run synchronous beforeEach before the $runner runner', + async ({ runner }) => { + // Arrange + const ordering: string[] = []; + let pluginIndex = 0; + const sharedContext = {}; + const pluginA = beforeEach(() => { + ordering.push('beforeEach::A'); + }); + const instanceA = pluginA(pluginIndex++, sharedContext); + const finalRun = instanceA.decorateRun!(() => { + ordering.push('run started'); + if (runner === 'async') { + return delay0().then(() => { + ordering.push('run done'); + return null; + }); + } + ordering.push('run done'); + return null; + }); + + // Act + await finalRun(null); + + // Assert + expect(ordering).toEqual(['beforeEach::A', 'run started', 'run done']); + }, + ); + + it.each([{ runner: 'sync' }, { runner: 'async' }])( + 'should run and wait asynchronous beforeEach before the $runner runner', + async ({ runner }) => { + // Arrange + const ordering: string[] = []; + let pluginIndex = 0; + const sharedContext = {}; + const pluginA = beforeEach(async () => { + ordering.push('beforeEach::A started'); + await delay0(); + ordering.push('beforeEach::A done'); + }); + const instanceA = pluginA(pluginIndex++, sharedContext); + const finalRun = instanceA.decorateRun!(() => { + ordering.push('run started'); + if (runner === 'async') { + return delay0().then(() => { + ordering.push('run done'); + return null; + }); + } + ordering.push('run done'); + return null; + }); + + // Act + await finalRun(null); + + // Assert + expect(ordering).toEqual(['beforeEach::A started', 'beforeEach::A done', 'run started', 'run done']); + }, + ); + + it.each([ + { order: ['sync', 'sync', 'sync'] }, + { order: ['sync', 'async', 'async'] }, + { order: ['async', 'async', 'async'] }, + { order: ['async', 'sync', 'async'] }, + ])('should execute beforeEach statements in declaration order $order', async ({ order }) => { + // Arrange + const expectedOrdering: string[] = []; + const ordering: string[] = []; + let pluginIndex = 0; + const sharedContext = {}; + let finalRun: IRawProperty['run'] = () => { + ordering.push('run'); + return null; + }; + for (const o of order) { + expectedOrdering.push('beforeEach::A started'); + expectedOrdering.push('beforeEach::A done'); + const plugin = + o === 'sync' + ? beforeEach(() => { + ordering.push('beforeEach::A started'); + ordering.push('beforeEach::A done'); + }) + : beforeEach(async () => { + ordering.push('beforeEach::A started'); + await delay0(); + ordering.push('beforeEach::A done'); + }); + const instance = plugin(pluginIndex++, sharedContext); + if (instance.decorateRun !== undefined) { + finalRun = instance.decorateRun(finalRun); + } + } + + // Act + await finalRun(null); + + // Assert + expect(ordering).toEqual([...expectedOrdering, 'run']); + }); + }); + + describe('preserve output', () => { + it('should produce a sync value if and only if all hooks and run were returning a sync value', async () => { + await fc.assert( + fc.property( + fc.array(hookTypeArbitrary(), { minLength: 1 }), + fc.boolean(), + fc.constantFrom['run']>>(null, new PreconditionFailure(), { + error: new Error('abc'), + }), + (hookTypes, isAsyncRun, runValue) => { + // Arrange + let pluginIndex = 0; + const sharedContext = {}; + let finalRun: IRawProperty['run'] = isAsyncRun ? async () => runValue : () => runValue; + for (const hookType of hookTypes) { + const plugin = successfulPluginFor(hookType); + const instance = plugin(pluginIndex++, sharedContext); + if (instance.decorateRun !== undefined) { + finalRun = instance.decorateRun(finalRun); + } + } + + // Act + const out = finalRun(null); + + // Assert + const expectsSync = !isAsyncRun && !hookTypes.some((hookType) => hookType.includes('async')); + if (expectsSync) { + expect(out).not.toBeInstanceOf(Promise); + } else { + expect(out).toBeInstanceOf(Promise); + } + }, + ), + ); + }); + + it('should return the same value as the run function if no hook failed to run', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(hookTypeArbitrary(), { minLength: 1 }), + fc.boolean(), + fc.constantFrom['run']>>(null, new PreconditionFailure(), { + error: new Error('abc'), + }), + async (hookTypes, isAsyncRun, runValue) => { + // Arrange + let pluginIndex = 0; + const sharedContext = {}; + let finalRun: IRawProperty['run'] = isAsyncRun ? async () => runValue : () => runValue; + for (const hookType of hookTypes) { + const plugin = successfulPluginFor(hookType); + const instance = plugin(pluginIndex++, sharedContext); + if (instance.decorateRun !== undefined) { + finalRun = instance.decorateRun(finalRun); + } + } + + // Act + const out = await finalRun(null); + + // Assert + expect(out).toBe(runValue); + }, + ), + ); + }); + }); + + describe('errors', () => { + it('should mark a successful run as failed whenever one of the hooks failed', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(hookTypeArbitrary()), + hookTypeArbitrary(), + fc.array(hookTypeArbitrary()), + fc.boolean(), + async (hookTypesBeforeFailure, hookTypeFailing, hookTypesAfterFailure, isAsyncRun) => { + // Arrange + let pluginIndex = 0; + const sharedContext = {}; + let finalRun: IRawProperty['run'] = isAsyncRun + ? async () => null // emulates successful async run + : () => null; // emulates successful sync run + for (const hookType of hookTypesBeforeFailure) { + const plugin = successfulPluginFor(hookType); + const instance = plugin(pluginIndex++, sharedContext); + if (instance.decorateRun !== undefined) { + finalRun = instance.decorateRun(finalRun); + } + } + { + const plugin = failingPluginFor(hookTypeFailing); + const instance = plugin(pluginIndex++, sharedContext); + if (instance.decorateRun !== undefined) { + finalRun = instance.decorateRun(finalRun); + } + } + for (const hookType of hookTypesAfterFailure) { + const plugin = successfulPluginFor(hookType); + const instance = plugin(pluginIndex++, sharedContext); + if (instance.decorateRun !== undefined) { + finalRun = instance.decorateRun(finalRun); + } + } + + // Act + const out = await finalRun(null); + + // Assert + expect(out).toMatchObject({ error: expect.any(Error) }); + }, + ), + ); + }); + + it.each([{ kind: 'sync' as const }, { kind: 'async' as const }])( + 'should stop and forward beforeEach error on $kind throw', + async ({ kind }) => { + // Arrange + const probeB = vi.fn(); + const probeRun = vi.fn(); + let pluginIndex = 0; + const sharedContext = {}; + const instanceA = beforeEach( + kind === 'sync' + ? () => { + throw new Error('beforeEach throws'); + } + : async () => { + throw new Error('beforeEach throws'); + }, + )(pluginIndex++, sharedContext); + const instanceB = beforeEach(probeB)(pluginIndex++, sharedContext); + expect(instanceB.decorateRun).toBe(undefined); // handled by instanceA + const finalRun = instanceA.decorateRun!(probeRun); + + // Act + const out = await finalRun(null); + + // Assert + expect(probeB).not.toHaveBeenCalled(); // next beforeEach never called + expect(probeRun).not.toHaveBeenCalled(); // run never called + expect(out).toEqual({ error: new Error('beforeEach throws') }); + }, + ); + it.each([{ kind: 'sync' as const }, { kind: 'async' as const }])( + 'should stop and forward beforeEach error on $kind throw not at the beginning of the flow', + async ({ kind }) => { + // Arrange + const probeC = vi.fn(); + const probeRun = vi.fn(); + let pluginIndex = 0; + const sharedContext = {}; + const instanceA = beforeEach(async () => {})(pluginIndex++, sharedContext); + const instanceB = beforeEach( + kind === 'sync' + ? () => { + throw new Error('beforeEach throws'); + } + : async () => { + throw new Error('beforeEach throws'); + }, + )(pluginIndex++, sharedContext); + expect(instanceB.decorateRun).toBe(undefined); // handled by instanceA + const instanceC = beforeEach(probeC)(pluginIndex++, sharedContext); + expect(instanceC.decorateRun).toBe(undefined); // handled by instanceA + const finalRun = instanceA.decorateRun!(probeRun); + + // Act + const out = await finalRun(null); + + // Assert + expect(probeC).not.toHaveBeenCalled(); // next beforeEach never called + expect(probeRun).not.toHaveBeenCalled(); // run never called + expect(out).toEqual({ error: new Error('beforeEach throws') }); + }, + ); + }); + + describe('merge instances', () => { + it('should merge consecutive instances of the plugin into a single instance but create a new instance at each index gap', async () => { + await fc.assert( + fc.property( + fc.nat(), + fc.array( + fc.record({ + hookTypes: fc.array(hookTypeArbitrary(), { minLength: 1 }), + gap: fc.integer({ min: 1, max: 100 }), + }), + { minLength: 1 }, + ), + (startLifeCyclePluginsIndex, hookTypesAndGaps) => { + // Arrange + let pluginIndex = startLifeCyclePluginsIndex; + const sharedContext = {}; + + // Act / Assert + for (let nth = 0; nth !== hookTypesAndGaps.length; ++nth) { + const { hookTypes, gap } = hookTypesAndGaps[nth]; + for (let index = 0; index !== hookTypes.length; ++index) { + const plugin = successfulPluginFor(hookTypes[index]); + const instance = plugin(pluginIndex++, sharedContext); + const expectHint = `at index ${index} of ${nth + 1}th chunk`; + if (index === 0) { + expect(instance.decorateRun, expectHint).not.toBe(undefined); + } else { + expect(instance.decorateRun, expectHint).toBe(undefined); // handled by first instance of the plugin + } + } + pluginIndex += gap; + } + }, + ), + ); + }); + }); +}); + +// Helpers + +function delay0() { + return new Promise((r) => setTimeout(r, 0)); +} + +function hookTypeArbitrary() { + return fc.constantFrom('sync beforeEach', 'async beforeEach'); +} + +function successfulPluginFor(hookType: 'sync beforeEach' | 'async beforeEach'): Plugin { + switch (hookType) { + case 'sync beforeEach': + return beforeEach(() => {}); + case 'async beforeEach': + return beforeEach(async () => {}); + } +} + +function failingPluginFor(hookType: 'sync beforeEach' | 'async beforeEach'): Plugin { + switch (hookType) { + case 'sync beforeEach': + return beforeEach(() => { + throw new Error('sync throw'); + }); + case 'async beforeEach': + return beforeEach(async () => { + throw new Error('async throw'); + }); + } +} diff --git a/website/docs/configuration/global-settings.md b/website/docs/configuration/global-settings.md index 063c3e5b..8061a0f5 100644 --- a/website/docs/configuration/global-settings.md +++ b/website/docs/configuration/global-settings.md @@ -59,7 +59,7 @@ You can also fully reset all the global options by calling `resetConfigureGlobal ::: :::info Plugins -[Plugins](/docs/advanced/plugins/) cannot be shared via `configureGlobal`, they have their own installer: `fc.installGlobalPlugin(myPlugin())`. +[Plugins](/docs/core-blocks/plugins/) cannot be shared via `configureGlobal`, they have their own installer: `fc.installGlobalPlugin(myPlugin())`. ::: Resources: [API reference](/docs/api/functions/configureGlobal). diff --git a/website/docs/core-blocks/index.md b/website/docs/core-blocks/index.md index 065dae1c..f4363aac 100644 --- a/website/docs/core-blocks/index.md +++ b/website/docs/core-blocks/index.md @@ -6,13 +6,14 @@ description: The three reference building blocks of fast-check — arbitraries, # Core Blocks -Every fast-check test is built from the same three pieces: +Every fast-check test is built from the same four pieces: 1. **Arbitraries** describe what values to generate. They pair a random generator with a shrinker so that failing inputs collapse back to readable counterexamples. 2. **Properties** describe what must hold for those values. A property takes one or more arbitraries and a predicate. It asserts that for any input the predicate stays valid. 3. **Runners** describe how to execute the property: how many runs, with what seed, how to report failures, whether to throw or return details. +4. **Plugins** describe how to extend runners to make them even more tailored to your needs. -The children below are the reference pages for each block: start with [Properties](/docs/core-blocks/properties/) if you have never written one, jump to [Arbitraries](/docs/core-blocks/arbitraries/) when you need the right generator and come back to [Runners](/docs/core-blocks/runners/) when you need to tune execution. +The children below are the reference pages for each block: start with [Properties](/docs/core-blocks/properties/) if you have never written one, jump to [Arbitraries](/docs/core-blocks/arbitraries/) when you need the right generator and come back to [Plugins](/docs/core-blocks/plugins/) and [Runners](/docs/core-blocks/runners/) when you need to tune execution. :::tip Reference, not tutorial The Core Blocks pages are the exhaustive reference. If you are looking for a guided, hands-on walkthrough instead, start with the [Quick Start tutorial](/docs/tutorials/quick-start/basic-setup/). diff --git a/website/docs/advanced/plugins.md b/website/docs/core-blocks/plugins/index.md similarity index 52% rename from website/docs/advanced/plugins.md rename to website/docs/core-blocks/plugins/index.md index 6b90ab7b..6138197d 100644 --- a/website/docs/advanced/plugins.md +++ b/website/docs/core-blocks/plugins/index.md @@ -1,24 +1,22 @@ --- -slug: /advanced/plugins/ +sidebar_position: 4 +slug: /core-blocks/plugins/ +description: Extend the default execution flow provided by runners to meet your needs. --- # Plugins -Extend execution flow to meet your needs +Plugins provide a way to extend and refine the runtime and execution behavior of your properties. In the same way building custom arbitraries gives you the flexibility to tweak the generation flows, plugins gives you ways to customize how your properties run. -## Overview - -Plugins provide a way to extend and refine the runtime and execution behavior of your properties. The same way building custom arbitraries gives you the flexibility to tweak the generation flows, the plugins will give you ways to customize the way your properties will run. - -Plugins have been designed in a way to be capable of supporting things such as: +Plugins are designed to support things such as: - Executing something before the predicate -- Cutting predicate running for too long +- Stopping a predicate running for too long - Capturing key insights about the execution flows including timings for observability ## Using plugins -Plugins can just be passed as part of the customizations accepted by the `fc.assert` runner. +Plugins can be passed as part of the customizations accepted by the `fc.assert` runner. ```ts await fc.assert(fc.asyncProperty(...arbs, predicate), { @@ -35,3 +33,13 @@ fc.installGlobalPlugin(pluginA()); ``` Installed plugins run before the ones passed to the runner, so the snippet above followed by `fc.assert(myProp, { plugins: [pluginB()] })` is equivalent to `plugins: [pluginA(), pluginB()]`. + +## The plugins + +We come up with a set of plugins to extend the library using built-in plugins. The following pages provide extended details and deep dive into each of them. + +```mdx-code-block +import DocCardList from '@theme/DocCardList'; + + +``` diff --git a/website/docs/core-blocks/plugins/life-cycle.md b/website/docs/core-blocks/plugins/life-cycle.md new file mode 100644 index 00000000..36dea23d --- /dev/null +++ b/website/docs/core-blocks/plugins/life-cycle.md @@ -0,0 +1,50 @@ +--- +slug: /core-blocks/plugins/life-cycle/ +--- + +# Life-cycle + +Life-cycle plugins provide hooks to prepare or clean up things for your predicates. + +## `beforeEach` + +The `beforeEach` plugin lets you run code right before the execution of your predicate. + +It expects to receive a function returning either `void` or `Promise`. Any other returned value may lead to unexpected behavior and is subject to change between versions. + +The hooks will execute in the order they get declared. As such if you declare: + +```ts +{ + plugins: [ + beforeEach(() => { + // beforeEach hook #1 + }), + beforeEach(() => { + // beforeEach hook #2 + }), + ]; +} +``` + +We will first run #1 then #2. If #1 fails, #2 will never get executed and neither will the predicate. + +Also note that `beforeEach` hooks integrate themselves with other plugins. As such, in the hypothesis of a plugin named `retryOnFailure(count)`, declaring plugins as follows: + +```ts +{ + plugins: [ + beforeEach(() => { + // beforeEach hook #1 + }), + retryOnFailure(2), + beforeEach(() => { + // beforeEach hook #2 + }), + ]; +} +``` + +May result in hook #2 being executed more often than #1. Hook #2 will be re-executed for every retry, while #1 will wrap all the retries. + +Resources: [API reference](/docs/api/functions/beforeEachPlugin). -- 2.51.2