diff --git a/.changeset/clever-ducks-drive.md b/.changeset/clever-ducks-drive.md new file mode 100644 index 00000000..b3b3a2ce --- /dev/null +++ b/.changeset/clever-ducks-drive.md @@ -0,0 +1,5 @@ +--- +"fast-check": patch +--- + +🐛 Proper ordering between plugins diff --git a/packages/fast-check/src/check/plugin/Plugin.ts b/packages/fast-check/src/check/plugin/Plugin.ts index e0ed38d3..acc612a6 100644 --- a/packages/fast-check/src/check/plugin/Plugin.ts +++ b/packages/fast-check/src/check/plugin/Plugin.ts @@ -40,7 +40,8 @@ export type PluginInstance = { * Each property will instantiate its own plugin when starting to be assessed via {@link check} or {@link assert}. * * Parameters received by the Plugin function: - * - 1st argument or pluginIndex: Corresponds to the index of the plugin (starts at zero). + * - 1st argument or pluginIndex: Corresponds to the index of the plugin within the run (starts at zero). + * Plugins are instantiated in order. As such, for a given batch expect to see index 0 instantiated first, followed by others. * - 2nd argument or crossPluginContext: Context parameter shared across all builders. * The variable can be leveraged to exchange insights with other builders. * As such it is writable and can be mutated via the builder. diff --git a/packages/fast-check/src/check/runner/Runner.ts b/packages/fast-check/src/check/runner/Runner.ts index b704fbf1..3b38dac4 100644 --- a/packages/fast-check/src/check/runner/Runner.ts +++ b/packages/fast-check/src/check/runner/Runner.ts @@ -17,7 +17,7 @@ import { asyncReportRunDetails, reportRunDetails } from './utils/RunDetailsForma import type { IAsyncProperty } from '../property/AsyncProperty.js'; import type { IProperty } from '../property/Property.js'; import type { Value } from '../arbitrary/definition/Value.js'; -import type { Plugin, PluginInstance } from '../plugin/Plugin.js'; +import type { PluginInstance } from '../plugin/Plugin.js'; import { readInstalledGlobalPlugins } from './configuration/GlobalPlugins.js'; /** @internal */ @@ -66,26 +66,6 @@ async function asyncPropertyExecution(property: IRawProperty, v: Ts) { return out; } -function applyPlugins( - plugins: Plugin[], - pluginOffset: number, - crossPluginContext: { [K in any]?: unknown }, - run: IRawProperty['run'], - pluginAfterAllCallbacks: Required>['afterAll'][], -) { - let nextRun = run; - for (let index = 0; index !== plugins.length; ++index) { - const pluginInstance = plugins[index](pluginOffset, crossPluginContext); - if (pluginInstance.decorateRun !== undefined) { - nextRun = pluginInstance.decorateRun(nextRun); - } - if (pluginInstance.afterAll !== undefined) { - pluginAfterAllCallbacks.push(pluginInstance.afterAll.bind(pluginInstance)); - } - } - return nextRun; -} - /** * Run the property, do not throw contrary to {@link assert} * @@ -146,15 +126,33 @@ function check(rawProperty: IRawProperty, params?: Parameters): unkn throw new Error('Invalid parameters encountered, only asyncProperty can be used when asyncReporter specified'); const property = decorateProperty(rawProperty, qParams); + const globalPlugins = readInstalledGlobalPlugins(); + const localPlugins = qParams.plugins; + + // Instantiate plugins + const crossPluginContext: { [K in any]?: unknown } = {}; + const pluginInstances: PluginInstance[] = []; + for (let index = 0; index !== globalPlugins.length; ++index) { + pluginInstances.push(globalPlugins[index](index, crossPluginContext)); + } + for (let index = 0; index !== localPlugins.length; ++index) { + pluginInstances.push(localPlugins[index](globalPlugins.length + index, crossPluginContext)); + } + + // Apply and decorate with plugins let run: typeof property.run = property.isAsync() ? async (v) => asyncPropertyExecution(property, v) : (v) => propertyExecution(property, v); const pluginAfterAllCallbacks: Required>['afterAll'][] = []; - - const crossPluginContext: { [K in any]?: unknown } = {}; - const globalPlugins = readInstalledGlobalPlugins(); - run = applyPlugins(globalPlugins, 0, crossPluginContext, run, pluginAfterAllCallbacks); - run = applyPlugins(qParams.plugins, globalPlugins.length, crossPluginContext, run, pluginAfterAllCallbacks); + for (let index = pluginInstances.length - 1; index >= 0; --index) { + const pluginInstance = pluginInstances[index]; + if (pluginInstance.decorateRun !== undefined) { + run = pluginInstance.decorateRun(run); + } + if (pluginInstance.afterAll !== undefined) { + pluginAfterAllCallbacks.push(pluginInstance.afterAll.bind(pluginInstance)); + } + } const maxInitialIterations = qParams.path.length === 0 || qParams.path.indexOf(':') === -1 ? qParams.numRuns : -1; const maxSkips = qParams.numRuns * qParams.maxSkipsPerRun; diff --git a/packages/fast-check/src/check/runner/configuration/GlobalPlugins.ts b/packages/fast-check/src/check/runner/configuration/GlobalPlugins.ts index c488f899..21569241 100644 --- a/packages/fast-check/src/check/runner/configuration/GlobalPlugins.ts +++ b/packages/fast-check/src/check/runner/configuration/GlobalPlugins.ts @@ -6,12 +6,16 @@ const globalPlugins: Plugin[] = []; * Install a plugin to be used by all the runners * Installed plugins come before the ones passed via the `plugins` option of the run. * + * In other words, they are the outermost ones: they are entered first when running the predicate. + * Think of: `outer(inner(predicate))`. + * * @example * ```typescript * fc.installGlobalPlugin(myPlugin()); * //... * fc.assert(myProp, { plugins: [myOtherPlugin()] }) * // equivalent to { plugins: [myPlugin(), myOtherPlugin()] } + * // myPlugin will wrap myOtherPlugin, itself wrapping the default behavior * ``` * * @param plugin - Plugin to be installed globally diff --git a/packages/fast-check/src/check/runner/configuration/Parameters.ts b/packages/fast-check/src/check/runner/configuration/Parameters.ts index cc16c25b..1445fbea 100644 --- a/packages/fast-check/src/check/runner/configuration/Parameters.ts +++ b/packages/fast-check/src/check/runner/configuration/Parameters.ts @@ -210,6 +210,10 @@ export interface Parameters { * Each plugin is instantiated once per run. * They can be leveraged to control and enrich the execution flow of each predicate. * + * They come after the plugins installed globally via {@link installGlobalPlugin}. + * At execution time, the first plugin of the resulting array is entered first while the last one is the closest to the predicate. + * Plugins are instantiated in order. + * * @remarks Since 4.10.0 */ plugins?: Plugin[]; diff --git a/packages/fast-check/test/e2e/Plugins.spec.ts b/packages/fast-check/test/e2e/Plugins.spec.ts index da360984..4d67c8aa 100644 --- a/packages/fast-check/test/e2e/Plugins.spec.ts +++ b/packages/fast-check/test/e2e/Plugins.spec.ts @@ -34,10 +34,10 @@ describe(`Plugins (seed: ${seed})`, () => { 'assert started', 'a instantiated', 'b instantiated', - 'a::afterAll started', - 'a::afterAll done', 'b::afterAll started', 'b::afterAll done', + 'a::afterAll started', + 'a::afterAll done', 'assert done', ]); }); @@ -77,16 +77,16 @@ describe(`Plugins (seed: ${seed})`, () => { 'assert started', 'a instantiated', 'b instantiated', - 'b::run started', 'a::run started', + 'b::run started', 'predicate called', - 'a::run done', 'b::run done', - 'b::run started', + 'a::run done', 'a::run started', + 'b::run started', 'predicate called', - 'a::run done', 'b::run done', + 'a::run done', 'assert done', ]); }); @@ -121,16 +121,16 @@ describe(`Plugins (seed: ${seed})`, () => { // Assert expect(probes).toEqual([ - 'b::run started', 'a::run started', + 'b::run started', 'predicate called', - 'a::run done', 'b::run done', - 'b::run started', + 'a::run done', 'a::run started', + 'b::run started', 'predicate called', - 'a::run done', 'b::run done', + 'a::run done', ]); }); @@ -181,16 +181,16 @@ describe(`Plugins (seed: ${seed})`, () => { 'c instantiated', 'd instantiated', 'e instantiated', - 'a::afterAll started', - 'a::afterAll done', - 'b::afterAll started', - 'b::afterAll done', - 'c::afterAll started', - 'c::afterAll done', - 'd::afterAll started', - 'd::afterAll done', 'e::afterAll started', 'e::afterAll done', + 'd::afterAll started', + 'd::afterAll done', + 'c::afterAll started', + 'c::afterAll done', + 'b::afterAll started', + 'b::afterAll done', + 'a::afterAll started', + 'a::afterAll done', 'assert done', ]); }); diff --git a/packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts b/packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts index 2b055e8e..16ad854a 100644 --- a/packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts +++ b/packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts @@ -3,7 +3,7 @@ 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'; +import type { Plugin, PluginInstance } from '../../../../src/check/plugin/Plugin.js'; describe('LifeCyclePlugins', () => { describe('ordering', () => { @@ -128,13 +128,10 @@ describe('LifeCyclePlugins', () => { 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); - } - } + const instances = hookTypes + .map((hookType) => successfulPluginFor(hookType)) + .map((plugin) => plugin(pluginIndex++, sharedContext)); + finalRun = produceFinalRun(finalRun, instances); // Act const out = finalRun(null); @@ -164,13 +161,10 @@ describe('LifeCyclePlugins', () => { 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); - } - } + const instances = hookTypes + .map((hookType) => successfulPluginFor(hookType)) + .map((plugin) => plugin(pluginIndex++, sharedContext)); + finalRun = produceFinalRun(finalRun, instances); // Act const out = await finalRun(null); @@ -198,27 +192,12 @@ describe('LifeCyclePlugins', () => { 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); - } - } + const instances = [ + ...hookTypesBeforeFailure.map((hookType) => successfulPluginFor(hookType)), + failingPluginFor(hookTypeFailing), + ...hookTypesAfterFailure.map((hookType) => successfulPluginFor(hookType)), + ].map((plugin) => plugin(pluginIndex++, sharedContext)); + finalRun = produceFinalRun(finalRun, instances); // Act const out = await finalRun(null); @@ -364,3 +343,14 @@ function failingPluginFor(hookType: 'sync beforeEach' | 'async beforeEach'): Plu }); } } + +function produceFinalRun(sourceRun: IRawProperty['run'], instances: PluginInstance[]) { + let finalRun = sourceRun; + for (let index = instances.length - 1; index >= 0; --index) { + const instance = instances[index]; + if (instance.decorateRun !== undefined) { + finalRun = instance.decorateRun(finalRun); + } + } + return finalRun; +}