diff --git a/.changeset/cool-streets-teach.md b/.changeset/cool-streets-teach.md new file mode 100644 index 00000000..9d394883 --- /dev/null +++ b/.changeset/cool-streets-teach.md @@ -0,0 +1,5 @@ +--- +"fast-check": minor +--- + +✨ Support teardwon of `beforeEach` plugin diff --git a/packages/fast-check/src/check/plugin/LifeCyclePlugins.ts b/packages/fast-check/src/check/plugin/LifeCyclePlugins.ts index b9db6ae4..f8160fd2 100644 --- a/packages/fast-check/src/check/plugin/LifeCyclePlugins.ts +++ b/packages/fast-check/src/check/plugin/LifeCyclePlugins.ts @@ -3,9 +3,38 @@ import type { Plugin, PluginInstance } from './Plugin.js'; const LifeCyclePluginSymbol = Symbol.for('fast-check/plugin/life-cycle'); +type TeardownFunction = () => Promise | void; type AfterEachHook = () => Promise | void; -type BeforeEachHook = () => Promise | void; -type LifeCycleHooks = { lastPluginIndex: number; beforeHooks: BeforeEachHook[]; afterHooks: AfterEachHook[] }; +type BeforeEachHook = () => Promise | void | TeardownFunction; +type LifeCycleHooks = { + lastPluginIndex: number; + beforeHooks: BeforeEachHook[]; + beforeHooksIndices: number[]; + afterHooks: AfterEachHook[]; + afterHooksIndices: number[]; +}; + +function computeResultingAfterHooks( + teardownFunctions: { index: number; fn: TeardownFunction }[], + hooks: LifeCycleHooks, +) { + if (teardownFunctions.length === 0) { + return hooks.afterHooks; + } + if (hooks.afterHooks.length === 0) { + return teardownFunctions.map((details) => details.fn); + } + const afterAndPluginIndices: { index: number; fn: TeardownFunction | AfterEachHook }[] = []; + for (const value of teardownFunctions) { + const { index, fn } = value; + afterAndPluginIndices.push({ index: hooks.beforeHooksIndices[index], fn }); + } + for (let index = 0; index !== hooks.afterHooks.length; ++index) { + afterAndPluginIndices.push({ index: hooks.afterHooksIndices[index], fn: hooks.afterHooks[index] }); + } + afterAndPluginIndices.sort((a, b) => a.index - b.index); + return afterAndPluginIndices.map((details) => details.fn); +} function lifeCycleHooksRunner( hooks: LifeCycleHooks, @@ -13,19 +42,44 @@ function lifeCycleHooksRunner( value: unknown, ): ReturnType { let wrappedRunOutput: Awaited> = null; // null means success - let beforeEachContinuation: Promise | undefined = undefined; + let wrappedRunContinuation: Promise>> | undefined = undefined; // Before hooks + const teardownFunctions: { index: number; fn: TeardownFunction }[] = []; if (hooks.beforeHooks.length !== 0) { try { - for (const before of hooks.beforeHooks) { - if (beforeEachContinuation === undefined) { + for (let index = 0; index !== hooks.beforeHooks.length; ++index) { + const before = hooks.beforeHooks[index]; + if (wrappedRunContinuation === undefined) { const out = before(); + if (typeof out === 'function') { + teardownFunctions.push({ index, fn: out }); + } if (typeof out === 'object') { - beforeEachContinuation = out; + wrappedRunContinuation = out.then((beforeOut) => { + if (beforeOut !== undefined) { + teardownFunctions.push({ index, fn: beforeOut }); + } + return null; + }); } } else { - beforeEachContinuation = beforeEachContinuation.then(() => before()); + wrappedRunContinuation = wrappedRunContinuation.then(() => { + const beforeOut = before(); + if (beforeOut === undefined) { + return null; + } + if (typeof beforeOut === 'function') { + teardownFunctions.push({ index, fn: beforeOut }); + return null; + } + return beforeOut.then((beforeOutNested) => { + if (beforeOutNested !== undefined) { + teardownFunctions.push({ index, fn: beforeOutNested }); + } + return null; + }); + }); } } } catch (error) { @@ -34,9 +88,9 @@ function lifeCycleHooksRunner( } // Predicate - let wrappedRunContinuation: Promise>> | undefined = undefined; + if (wrappedRunOutput === null) { - if (beforeEachContinuation === undefined) { + if (wrappedRunContinuation === undefined) { // We are currently into a sync flow const out = nestedRun(value); if (out !== null && 'then' in out) { @@ -46,7 +100,7 @@ function lifeCycleHooksRunner( } } else { // We switched to an async flow - wrappedRunContinuation = beforeEachContinuation.then( + wrappedRunContinuation = wrappedRunContinuation.then( () => nestedRun(value), (error) => ({ error }), // beforeEach flows do not catch anything, they always result into succes being null or throw ); @@ -54,39 +108,69 @@ function lifeCycleHooksRunner( } // After hooks - for (let index = hooks.afterHooks.length - 1; index >= 0; --index) { - const after = hooks.afterHooks[index]; - if (wrappedRunContinuation === undefined) { - try { - const out = after(); - if (typeof out === 'object') { - wrappedRunContinuation = out.then( - () => wrappedRunOutput, - // TODO Switch to ?? when the node range defined by fast-check accepts it - (error) => wrappedRunOutput || { error }, - ); - } - } catch (error) { - wrappedRunOutput = { error }; - } - } else { - wrappedRunContinuation = wrappedRunContinuation.then((previous) => { + if (wrappedRunContinuation === undefined) { + const resultingAfterHooks = computeResultingAfterHooks(teardownFunctions, hooks); + for (let index = resultingAfterHooks.length - 1; index >= 0; --index) { + const after = resultingAfterHooks[index]; + if (wrappedRunContinuation === undefined) { try { const out = after(); if (typeof out === 'object') { - return out.then( - () => previous, + wrappedRunContinuation = out.then( + () => wrappedRunOutput, // TODO Switch to ?? when the node range defined by fast-check accepts it - (error) => previous || { error }, + (error) => wrappedRunOutput || { error }, ); } - return previous; } catch (error) { - // TODO Switch to ?? when the node range defined by fast-check accepts it - return previous || { error }; + wrappedRunOutput = { error }; } - }); + } else { + wrappedRunContinuation = wrappedRunContinuation.then((previous) => { + try { + const out = after(); + if (typeof out === 'object') { + return out.then( + () => previous, + // TODO Switch to ?? when the node range defined by fast-check accepts it + (error) => previous || { error }, + ); + } + return previous; + } catch (error) { + // TODO Switch to ?? when the node range defined by fast-check accepts it + return previous || { error }; + } + }); + } } + } else { + wrappedRunContinuation = wrappedRunContinuation.then((previousBeforeAfters) => { + const resultingAfterHooks = computeResultingAfterHooks(teardownFunctions, hooks); + if (resultingAfterHooks.length === 0) { + return previousBeforeAfters; + } + let afterContinuation: Promise>> = Promise.resolve(previousBeforeAfters); + for (let index = resultingAfterHooks.length - 1; index >= 0; --index) { + const after = resultingAfterHooks[index]; + afterContinuation = afterContinuation.then((previous) => { + try { + const out = after(); + return out === undefined + ? previous + : out.then( + () => previous, + // TODO Switch to ?? when the node range defined by fast-check accepts it + (error) => previous || { error }, + ); + } catch (error) { + // TODO Switch to ?? when the node range defined by fast-check accepts it + return previous || { error }; + } + }); + } + return afterContinuation; + }); } return wrappedRunContinuation === undefined ? wrappedRunOutput : wrappedRunContinuation; @@ -115,9 +199,16 @@ export function beforeEach(fn: BeforeEachHook): Plugin { if (lifeCycleHooks !== undefined && lifeCycleHooks.lastPluginIndex === pluginIndex - 1) { lifeCycleHooks.lastPluginIndex = pluginIndex; lifeCycleHooks.beforeHooks.push(fn); + lifeCycleHooks.beforeHooksIndices.push(pluginIndex); return {}; } - lifeCycleHooks = { lastPluginIndex: pluginIndex, beforeHooks: [fn], afterHooks: [] }; + lifeCycleHooks = { + lastPluginIndex: pluginIndex, + beforeHooks: [fn], + beforeHooksIndices: [pluginIndex], + afterHooks: [], + afterHooksIndices: [], + }; crossPluginContext[LifeCyclePluginSymbol] = lifeCycleHooks; return { decorateRun: (nestedRun) => (value) => lifeCycleHooksRunner(lifeCycleHooks, nestedRun, value) }; }; @@ -146,9 +237,16 @@ export function afterEach(fn: AfterEachHook): Plugin { if (lifeCycleHooks !== undefined && lifeCycleHooks.lastPluginIndex === pluginIndex - 1) { lifeCycleHooks.lastPluginIndex = pluginIndex; lifeCycleHooks.afterHooks.push(fn); + lifeCycleHooks.afterHooksIndices.push(pluginIndex); return {}; } - lifeCycleHooks = { lastPluginIndex: pluginIndex, beforeHooks: [], afterHooks: [fn] }; + lifeCycleHooks = { + lastPluginIndex: pluginIndex, + beforeHooks: [], + beforeHooksIndices: [], + afterHooks: [fn], + afterHooksIndices: [pluginIndex], + }; crossPluginContext[LifeCyclePluginSymbol] = lifeCycleHooks; return { decorateRun: (nestedRun) => (value) => lifeCycleHooksRunner(lifeCycleHooks, nestedRun, value) }; }; diff --git a/packages/fast-check/test/e2e/LifeCyclePlugins.spec.ts b/packages/fast-check/test/e2e/LifeCyclePlugins.spec.ts index 4a5ac466..a51f7190 100644 --- a/packages/fast-check/test/e2e/LifeCyclePlugins.spec.ts +++ b/packages/fast-check/test/e2e/LifeCyclePlugins.spec.ts @@ -17,9 +17,15 @@ describe(`LifeCyclePlugins (seed: ${seed})`, () => { plugins: [ fc.beforeEach(() => { probes.push('beforeEach #1'); + return () => { + probes.push('teardown for beforeEach #1'); + }; }), fc.beforeEach(() => { probes.push('beforeEach #2'); + return () => { + probes.push('teardown for beforeEach #2'); + }; }), fc.afterEach(() => { probes.push('afterEach #3'); @@ -29,6 +35,9 @@ describe(`LifeCyclePlugins (seed: ${seed})`, () => { }), fc.beforeEach(() => { probes.push('beforeEach #5'); + return () => { + probes.push('teardown for beforeEach #5'); + }; }), ], numRuns: 2, @@ -42,15 +51,21 @@ describe(`LifeCyclePlugins (seed: ${seed})`, () => { 'beforeEach #2', 'beforeEach #5', 'predicate', + 'teardown for beforeEach #5', 'afterEach #4', 'afterEach #3', + 'teardown for beforeEach #2', + 'teardown for beforeEach #1', // 2nd run 'beforeEach #1', 'beforeEach #2', 'beforeEach #5', 'predicate', + 'teardown for beforeEach #5', 'afterEach #4', 'afterEach #3', + 'teardown for beforeEach #2', + 'teardown for beforeEach #1', ]); }); @@ -82,6 +97,9 @@ describe(`LifeCyclePlugins (seed: ${seed})`, () => { plugins: [ fc.beforeEach(() => { probes.push('beforeEach #1'); + return () => { + probes.push('teardown for beforeEach #1'); + }; }), fc.afterEach(() => { probes.push('afterEach #2'); @@ -89,6 +107,9 @@ describe(`LifeCyclePlugins (seed: ${seed})`, () => { retryTwice(), fc.beforeEach(() => { probes.push('beforeEach #3'); + return () => { + probes.push('teardown for beforeEach #3'); + }; }), fc.afterEach(() => { probes.push('afterEach #4'); @@ -106,26 +127,32 @@ describe(`LifeCyclePlugins (seed: ${seed})`, () => { 'beforeEach #3', 'predicate', 'afterEach #4', + 'teardown for beforeEach #3', // --1st try done // --2nd try started 'beforeEach #3', 'predicate', 'afterEach #4', + 'teardown for beforeEach #3', // --2nd try done 'afterEach #2', + 'teardown for beforeEach #1', // 2nd run 'beforeEach #1', // --1st try started 'beforeEach #3', 'predicate', 'afterEach #4', + 'teardown for beforeEach #3', // --1st try done // --2nd try started 'beforeEach #3', 'predicate', 'afterEach #4', + 'teardown for beforeEach #3', // --2nd try done 'afterEach #2', + 'teardown for beforeEach #1', ]); }); }); 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 534fb4dd..77e638d8 100644 --- a/packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts +++ b/packages/fast-check/test/unit/check/plugin/LifeCyclePlugins.spec.ts @@ -19,7 +19,7 @@ describe('LifeCyclePlugins', () => { let probingFailed: boolean = false; const probes: string[] = []; let lastProbe: { type: 'start' | 'end'; label: string } | undefined; - const probing = (type: 'start' | 'end', label: string) => { + const probe = (type: 'start' | 'end', label: string) => { if (lastProbe !== undefined) { if (lastProbe.type === type) { probingFailed ||= @@ -37,25 +37,28 @@ describe('LifeCyclePlugins', () => { lastProbe = { type, label }; probes.push(`${type}>> ${label}`); }; + const probing = (out: ProbingOutput) => + probe( + out.type, + `${out.hookType}${out.isTeardown ? ' (teardown)' : ''}${out.failingPlugin && (out.isTeardown || !out.hookType.includes('teardown')) ? ' throw' : ''}`, + ); let pluginIndex = 0; const sharedContext = {}; let finalRun: IRawProperty['run'] = isAsyncRun ? async () => { - probing('start', 'predicate'); + probe('start', 'predicate'); await delay0(); - probing('end', 'predicate'); + probe('end', 'predicate'); return runValue; } : () => { - probing('start', 'predicate'); - probing('end', 'predicate'); + probe('start', 'predicate'); + probe('end', 'predicate'); return runValue; }; const instances = hookTypes - .map(({ hookType, fails }, index) => - fails - ? failingPluginFor(hookType, (type) => probing(type, `${hookType} #${index} throw`)) - : successfulPluginFor(hookType, (type) => probing(type, `${hookType} #${index}`)), + .map(({ hookType, fails }) => + fails ? failingPluginFor(hookType, probing) : successfulPluginFor(hookType, probing), ) .map((plugin) => plugin(pluginIndex++, sharedContext)); finalRun = produceFinalRun(finalRun, instances); @@ -70,7 +73,7 @@ describe('LifeCyclePlugins', () => { ); }); - it('should apply proper relative order: beforeEach then predicate then afterEach', async () => { + it('should apply proper relative order: beforeEach then predicate then afterEach-and-teardown', async () => { await fc.assert( fc.asyncProperty( fc.array(fc.record({ hookType: hookTypeArbitrary(), fails: fc.boolean() }), { minLength: 1 }), @@ -83,19 +86,30 @@ describe('LifeCyclePlugins', () => { let lastChunk = -1; let hasFailedStep = false; const seenSteps: string[] = []; - const probing = (type: 'start' | 'end', label: string, index: number) => { - const currentChunk = label.includes('afterEach') ? 3 : label === 'predicate' ? 2 : 1; + const probing = (out: ProbingOutput, index: number) => { + const currentChunk = out.hookType.includes('afterEach') || out.isTeardown ? 3 : 1; hasFailedStep ||= currentChunk < lastChunk; - seenSteps.push(`${type} ${label} ${index}`); + lastChunk = currentChunk; + seenSteps.push(`${out.type} ${out.hookType}${out.isTeardown ? ' (teardown)' : ''} ${index}`); }; let pluginIndex = 0; const sharedContext = {}; - let finalRun: IRawProperty['run'] = isAsyncRun ? async () => runValue : () => runValue; + let finalRun: IRawProperty['run'] = isAsyncRun + ? async () => { + hasFailedStep ||= lastChunk > 2; + lastChunk = 2; + return runValue; + } + : () => { + hasFailedStep ||= lastChunk > 2; + lastChunk = 2; + return runValue; + }; const instances = hookTypes .map(({ hookType, fails }, index) => fails - ? failingPluginFor(hookType, (type) => probing(type, hookType, index)) - : successfulPluginFor(hookType, (type) => probing(type, hookType, index)), + ? failingPluginFor(hookType, (out) => probing(out, index)) + : successfulPluginFor(hookType, (out) => probing(out, index)), ) .map((plugin) => plugin(pluginIndex++, sharedContext)); finalRun = produceFinalRun(finalRun, instances); @@ -127,8 +141,8 @@ describe('LifeCyclePlugins', () => { async (hookTypes, isAsyncRun, runValue) => { // Arrange const seenStartsOfBeforeEach: number[] = []; - const probing = (type: 'start' | 'end', label: string, index: number) => { - if (type === 'start' && label.includes('beforeEach')) { + const probing = (out: ProbingOutput, index: number) => { + if (out.type === 'start' && out.hookType.includes('beforeEach') && !out.isTeardown) { seenStartsOfBeforeEach.push(index); } }; @@ -138,8 +152,8 @@ describe('LifeCyclePlugins', () => { const instances = hookTypes .map(({ hookType, fails }, index) => fails - ? failingPluginFor(hookType, (type) => probing(type, hookType, index)) - : successfulPluginFor(hookType, (type) => probing(type, hookType, index)), + ? failingPluginFor(hookType, (out) => probing(out, index)) + : successfulPluginFor(hookType, (out) => probing(out, index)), ) .map((plugin) => plugin(pluginIndex++, sharedContext)); finalRun = produceFinalRun(finalRun, instances); @@ -157,7 +171,7 @@ describe('LifeCyclePlugins', () => { ); }); - it('should always trigger all afterEach hooks in reverse order no matter the status of other hooks or predicate', async () => { + it('should always trigger all afterEach-and-teardown hooks in reverse order no matter the status of other hooks or predicate', async () => { await fc.assert( fc.asyncProperty( fc.array(fc.record({ hookType: hookTypeArbitrary(), fails: fc.boolean() }), { minLength: 1 }), @@ -168,8 +182,8 @@ describe('LifeCyclePlugins', () => { async (hookTypes, isAsyncRun, runValue) => { // Arrange const seenStartsOfAfterEach: number[] = []; - const probing = (type: 'start' | 'end', label: string, index: number) => { - if (type === 'start' && label.includes('afterEach')) { + const probing = (out: ProbingOutput, index: number) => { + if (out.type === 'start' && (out.hookType.includes('afterEach') || out.isTeardown)) { seenStartsOfAfterEach.push(index); } }; @@ -179,8 +193,8 @@ describe('LifeCyclePlugins', () => { const instances = hookTypes .map(({ hookType, fails }, index) => fails - ? failingPluginFor(hookType, (type) => probing(type, hookType, index)) - : successfulPluginFor(hookType, (type) => probing(type, hookType, index)), + ? failingPluginFor(hookType, (out) => probing(out, index)) + : successfulPluginFor(hookType, (out) => probing(out, index)), ) .map((plugin) => plugin(pluginIndex++, sharedContext)); finalRun = produceFinalRun(finalRun, instances); @@ -189,9 +203,17 @@ describe('LifeCyclePlugins', () => { await finalRun(null); // Assert - const expectedAfterEachs = hookTypes.flatMap(({ hookType }, index) => - hookType.includes('afterEach') ? [index] : [], - ); + let beforeEachFailureSpotted = false; + const expectedAfterEachs = hookTypes.flatMap(({ hookType, fails }, index) => { + if (hookType.includes('afterEach')) { + return [index]; + } + if (!beforeEachFailureSpotted && hookType.includes('teardown')) { + return [index]; + } + beforeEachFailureSpotted ||= fails; + return []; + }); const expectedAfterEachsButReversed = [...expectedAfterEachs].reverse(); expect(seenStartsOfAfterEach).toEqual(expectedAfterEachsButReversed); }, @@ -297,7 +319,7 @@ describe('LifeCyclePlugins', () => { fc .tuple( fc.array(fc.record({ hookType: hookTypeArbitrary(), fails: fc.boolean() })), - hookTypeArbitrary('only', 'beforeEach'), + hookTypeArbitrary('only', 'beforeEach no teardown'), // we want a throw at beforeEach time so before any teardown ) .chain(([potentiallySafeHooks, throwingBefore]) => fc.shuffledSubarray([...potentiallySafeHooks, { hookType: throwingBefore, fails: true }], { @@ -310,20 +332,17 @@ describe('LifeCyclePlugins', () => { let beforeEachFailedStarted = false; let pluginIndex = 0; const sharedContext = {}; - const originalRun = vi.fn(); + const originalRun = vi.fn(() => null); let finalRun: IRawProperty['run'] = originalRun; - const probing = (hookType: string, fails: boolean) => { - if (!hookType.includes('beforeEach')) { - return; + const probing = (out: ProbingOutput) => { + if (out.hookType.includes('beforeEach') && !out.hookType.includes('teardown')) { + beforeEachCalledAfterFailure ||= beforeEachFailedStarted; + beforeEachFailedStarted ||= out.failingPlugin; } - beforeEachCalledAfterFailure ||= beforeEachFailedStarted; - beforeEachFailedStarted ||= fails; }; const instances = hookTypes .map(({ hookType, fails }) => - fails - ? failingPluginFor(hookType, () => probing(hookType, true)) - : successfulPluginFor(hookType, () => probing(hookType, false)), + fails ? failingPluginFor(hookType, probing) : successfulPluginFor(hookType, probing), ) .map((plugin) => plugin(pluginIndex++, sharedContext)); finalRun = produceFinalRun(finalRun, instances); @@ -382,19 +401,40 @@ function delay0() { return new Promise((r) => setTimeout(r, 0)); } -function hookTypeArbitrary( - ...config: [] | ['only', 'beforeEach' | 'afterEach'] | ['except', 'beforeEach' | 'afterEach'] -) { - const beforeEach = ['sync beforeEach', 'async beforeEach'] as const; +type HookTypeConfigTypes = 'beforeEach' | 'beforeEach no teardown' | 'beforeEach with teardown' | 'afterEach'; +type HookTypes = + | 'sync beforeEach' + | 'async beforeEach' + | 'sync beforeEach with sync teardown' + | 'sync beforeEach with async teardown' + | 'async beforeEach with sync teardown' + | 'async beforeEach with async teardown' + | 'sync afterEach' + | 'async afterEach'; + +function hookTypeArbitrary(...config: [] | ['only', HookTypeConfigTypes] | ['except', HookTypeConfigTypes]) { + const beforeEachNoTeardown = ['sync beforeEach', 'async beforeEach'] as const; + const beforeEachWithTeardown = [ + 'sync beforeEach with sync teardown', + 'sync beforeEach with async teardown', + 'async beforeEach with sync teardown', + 'async beforeEach with async teardown', + ] as const; const afterEach = ['sync afterEach', 'async afterEach'] as const; - const all = [...beforeEach, ...afterEach]; + const all = [...beforeEachNoTeardown, ...beforeEachWithTeardown, ...afterEach]; if (config.length === 0) { return fc.constantFrom(...all); } let selection: typeof all; switch (config[1]) { case 'beforeEach': - selection = [...beforeEach]; + selection = [...beforeEachNoTeardown, ...beforeEachWithTeardown]; + break; + case 'beforeEach no teardown': + selection = [...beforeEachNoTeardown]; + break; + case 'beforeEach with teardown': + selection = [...beforeEachWithTeardown]; break; case 'afterEach': selection = [...afterEach]; @@ -403,59 +443,138 @@ function hookTypeArbitrary( return fc.constantFrom(...(config[0] === 'only' ? selection : all.filter((value) => !selection.includes(value)))); } -function successfulPluginFor( - hookType: 'sync beforeEach' | 'async beforeEach' | 'sync afterEach' | 'async afterEach', - probing?: (type: 'start' | 'end') => void, -): Plugin { +type ProbingOutput = { + type: 'start' | 'end'; + hookType: HookTypes; + isTeardown: boolean; + failingPlugin: boolean; +}; + +function successfulPluginFor(hookType: HookTypes, probing?: (out: ProbingOutput) => void): Plugin { + const shared = { hookType, failingPlugin: false }; switch (hookType) { case 'sync beforeEach': return beforeEach(() => { - probing?.('start'); - probing?.('end'); + probing?.({ ...shared, type: 'start', isTeardown: false }); + probing?.({ ...shared, type: 'end', isTeardown: false }); + }); + case 'sync beforeEach with sync teardown': + return beforeEach(() => { + probing?.({ ...shared, type: 'start', isTeardown: false }); + probing?.({ ...shared, type: 'end', isTeardown: false }); + return () => { + probing?.({ ...shared, type: 'start', isTeardown: true }); + probing?.({ ...shared, type: 'end', isTeardown: true }); + }; + }); + case 'sync beforeEach with async teardown': + return beforeEach(() => { + probing?.({ ...shared, type: 'start', isTeardown: false }); + probing?.({ ...shared, type: 'end', isTeardown: false }); + return async () => { + probing?.({ ...shared, type: 'start', isTeardown: true }); + await delay0(); + probing?.({ ...shared, type: 'end', isTeardown: true }); + }; }); case 'async beforeEach': return beforeEach(async () => { - probing?.('start'); + probing?.({ ...shared, type: 'start', isTeardown: false }); + await delay0(); + probing?.({ ...shared, type: 'end', isTeardown: false }); + }); + case 'async beforeEach with sync teardown': + return beforeEach(async () => { + probing?.({ ...shared, type: 'start', isTeardown: false }); + await delay0(); + probing?.({ ...shared, type: 'end', isTeardown: false }); + return () => { + probing?.({ ...shared, type: 'start', isTeardown: true }); + probing?.({ ...shared, type: 'end', isTeardown: true }); + }; + }); + case 'async beforeEach with async teardown': + return beforeEach(async () => { + probing?.({ ...shared, type: 'start', isTeardown: false }); await delay0(); - probing?.('end'); + probing?.({ ...shared, type: 'end', isTeardown: false }); + return async () => { + probing?.({ ...shared, type: 'start', isTeardown: true }); + await delay0(); + probing?.({ ...shared, type: 'end', isTeardown: true }); + }; }); case 'sync afterEach': return afterEach(() => { - probing?.('start'); - probing?.('end'); + probing?.({ ...shared, type: 'start', isTeardown: false }); + probing?.({ ...shared, type: 'end', isTeardown: false }); }); case 'async afterEach': return afterEach(async () => { - probing?.('start'); + probing?.({ ...shared, type: 'start', isTeardown: false }); await delay0(); - probing?.('end'); + probing?.({ ...shared, type: 'end', isTeardown: false }); }); } } -function failingPluginFor( - hookType: 'sync beforeEach' | 'async beforeEach' | 'sync afterEach' | 'async afterEach', - probing?: (type: 'start' | 'end') => void, -): Plugin { +function failingPluginFor(hookType: HookTypes, probing?: (out: ProbingOutput) => void): Plugin { + const shared = { hookType, failingPlugin: true }; switch (hookType) { case 'sync beforeEach': return beforeEach(() => { - probing?.('start'); + probing?.({ ...shared, type: 'start', isTeardown: false }); throw new Error('sync throw'); }); + case 'sync beforeEach with sync teardown': + return beforeEach(() => { + probing?.({ ...shared, type: 'start', isTeardown: false }); + probing?.({ ...shared, type: 'end', isTeardown: false }); + return () => { + probing?.({ ...shared, type: 'start', isTeardown: true }); + throw new Error('sync throw'); + }; + }); + case 'sync beforeEach with async teardown': + return beforeEach(() => { + probing?.({ ...shared, type: 'start', isTeardown: false }); + probing?.({ ...shared, type: 'end', isTeardown: false }); + return async () => { + probing?.({ ...shared, type: 'start', isTeardown: true }); + throw new Error('async throw'); + }; + }); case 'async beforeEach': return beforeEach(async () => { - probing?.('start'); + probing?.({ ...shared, type: 'start', isTeardown: false }); throw new Error('async throw'); }); + case 'async beforeEach with sync teardown': + return beforeEach(async () => { + probing?.({ ...shared, type: 'start', isTeardown: false }); + probing?.({ ...shared, type: 'end', isTeardown: false }); + return () => { + probing?.({ ...shared, type: 'start', isTeardown: true }); + throw new Error('sync throw'); + }; + }); + case 'async beforeEach with async teardown': + return beforeEach(async () => { + probing?.({ ...shared, type: 'start', isTeardown: false }); + probing?.({ ...shared, type: 'end', isTeardown: false }); + return async () => { + probing?.({ ...shared, type: 'start', isTeardown: true }); + throw new Error('async throw'); + }; + }); case 'sync afterEach': return afterEach(() => { - probing?.('start'); + probing?.({ ...shared, type: 'start', isTeardown: false }); throw new Error('sync throw'); }); case 'async afterEach': return afterEach(async () => { - probing?.('start'); + probing?.({ ...shared, type: 'start', isTeardown: false }); throw new Error('async throw'); }); } diff --git a/website/docs/core-blocks/plugins/life-cycle.md b/website/docs/core-blocks/plugins/life-cycle.md index 0d62584a..29132e78 100644 --- a/website/docs/core-blocks/plugins/life-cycle.md +++ b/website/docs/core-blocks/plugins/life-cycle.md @@ -10,9 +10,11 @@ Life-cycle plugins provide hooks to prepare or clean up things for your predicat 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. +It expects to receive a function returning either nothing or a teardown function, in a synchronous or asynchronous fashion. 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: +When a teardown function gets returned, it will be used as a clean-up function running right after the execution of the predicate, no matter its status. + +Simple example involving two synchronous `beforeEach` without any teardown function being declared: ```ts { @@ -27,7 +29,52 @@ The hooks will execute in the order they get declared. As such if you declare: } ``` -We will first run #1 then #2. If #1 fails, #2 will never get executed and neither will the predicate. +Example featuring all the possible variants with or without teardown, synchronous or asynchronous: + +```ts +{ + plugins: [ + // synchronous beforeEach + beforeEach(() => { + // ... + }), + // synchronous beforeEach with synchronous teardown + beforeEach(() => { + // ... + return () => { + // ... + }; + }), + // synchronous beforeEach with asynchronous teardown + beforeEach(() => { + // ... + return async () => { + // ... + }; + }), + // asynchronous beforeEach + beforeEach(async () => { + // ... + }), + // asynchronous beforeEach with synchronous teardown + beforeEach(async () => { + // ... + return () => { + // ... + }; + }), + // asynchronous beforeEach with asynchronous teardown + beforeEach(async () => { + // ... + return async () => { + // ... + }; + }), + ]; +} +``` + +All these variants can be used together. They have the same ordering priorities. Resources: [API reference](/docs/api/functions/beforeEach). @@ -35,24 +82,30 @@ Resources: [API reference](/docs/api/functions/beforeEach). The `afterEach` plugin is the mirror of `beforeEach`. It lets you run code right after the execution of your predicate. -Similarly to `beforeEach`, it expects to receive a function returning either `void` or `Promise`. Any other returned value may lead to unexpected behavior. +Similarly to `beforeEach`, it expects to receive a function returning either `void` or `Promise`, but unlike `beforeEach` it cannot return a teardown function. Any other returned value may lead to unexpected behavior. -Contrary to `beforeEach`, `afterEach` will always be executed no matter the execution status of other hooks. As such if one `beforeEach` or one `afterEach` rejects other `afterEach` will still be executed. +Contrary to `beforeEach`, `afterEach` will always be executed no matter the execution status of other hooks. As such, if one `beforeEach` or one `afterEach` rejects, other `afterEach` will still be executed. Resources: [API reference](/docs/api/functions/afterEach). ## Execution flow -Life-cycle plugins run in the same order as they got declared for before tasks and in reverse order for after tasks. As such the following ordering of plugins: +Life-cycle plugins run in the same order as they got declared for before tasks and in reverse order for after tasks. As such, the following ordering of plugins: ```ts { plugins: [ beforeEach(() => { console.log('beforeEach #1'); + return () => { + console.log('teardown for beforeEach #1'); + }; }), beforeEach(() => { console.log('beforeEach #2'); + return () => { + console.log('teardown for beforeEach #2'); + }; }), afterEach(() => { console.log('afterEach #3'); @@ -62,6 +115,9 @@ Life-cycle plugins run in the same order as they got declared for before tasks a }), beforeEach(() => { console.log('beforeEach #5'); + return () => { + console.log('teardown for beforeEach #5'); + }; }), ]; } @@ -74,10 +130,19 @@ beforeEach #1 beforeEach #2 beforeEach #5 predicate +teardown for beforeEach #5 afterEach #4 afterEach #3 +teardown for beforeEach #2 +teardown for beforeEach #1 ``` +They run in an exclusive fashion. As such, a `beforeEach`, a teardown or an `afterEach` will wait for the currently running one to end before starting. Similarly, they don't run while the predicate is under way. They wait for it to come back with an execution status. + +:::warning[Predicate may run longer] +While they are waiting for the predicate to come back with a status, note that whenever the predicate's execution gets stopped or interrupted, due to a timeout for example, there is no certainty that the code of the predicate has really been stopped. In Node, and more generally in JavaScript, there is no way to stop a running script: such plugins will mostly resume before the script ends but in many cases the script will keep running in background (as the plugin can't stop it). +::: + Like any built-in plugin, life-cycle plugins compose with the plugins declared next to them. As such, in the hypothesis of a plugin named `retryOnFailure(count)`, declaring plugins as follows: ```ts @@ -85,10 +150,16 @@ Like any built-in plugin, life-cycle plugins compose with the plugins declared n plugins: [ beforeEach(() => { console.log('beforeEach #1'); + return () => { + console.log('teardown for beforeEach #1'); + }; }), retryOnFailure(2), beforeEach(() => { console.log('beforeEach #2'); + return () => { + console.log('teardown for beforeEach #2'); + }; }), ]; } @@ -100,6 +171,9 @@ May result in the following logs at execution time: beforeEach #1 <-- runs once, it wraps all the attempts beforeEach #2 <-- the first attempt predicate <-- considering it fails at first attempt... +teardown for beforeEach #2 beforeEach #2 <-- ...`retryOnFailure` launches a second attempt predicate +teardown for beforeEach #2 +teardown for beforeEach #1 ```