From 4e6a9ee53708f8ce17dd7d93687c67fa7ef6a97f Mon Sep 17 00:00:00 2001 From: Adam-ARK <70346185+Adam-ARK@users.noreply.github.com> Date: Mon, 16 Nov 2020 00:45:27 -0500 Subject: [PATCH] add BeforeStep and AfterStep hooks (#1416) --- docs/support_files/api_reference.md | 30 ++- docs/support_files/hooks.md | 20 ++ features/before_after_step_hooks.feature | 95 ++++++++++ features/world_in_hooks.feature | 68 +++++++ src/cli/helpers_spec.ts | 2 + src/formatter/helpers/summary_helpers_spec.ts | 43 +++++ src/index.ts | 4 +- src/models/test_step_hook_definition.ts | 37 ++++ src/models/test_step_hook_definition_spec.ts | 68 +++++++ src/runtime/pickle_runner.ts | 66 ++++++- src/runtime/pickle_runner_spec.ts | 57 +++++- src/support_code_library_builder/index.ts | 76 ++++++++ .../index_spec.ts | 176 ++++++++++++++++++ src/support_code_library_builder/types.ts | 30 +++ .../validate_arguments.ts | 12 ++ 15 files changed, 777 insertions(+), 7 deletions(-) create mode 100644 features/before_after_step_hooks.feature create mode 100644 features/world_in_hooks.feature create mode 100644 src/models/test_step_hook_definition.ts create mode 100644 src/models/test_step_hook_definition_spec.ts diff --git a/docs/support_files/api_reference.md b/docs/support_files/api_reference.md index 1cefd71cc..555dd75a9 100644 --- a/docs/support_files/api_reference.md +++ b/docs/support_files/api_reference.md @@ -36,8 +36,8 @@ Defines a hook which is run after each scenario. * `tags`: String tag expression used to apply this hook to only specific scenarios. See [cucumber-tag-expressions](https://docs.cucumber.io/tag-expressions/) for more information. * `timeout`: A hook-specific timeout, to override the default timeout. * `fn`: A function, defined as follows: - * The first argument will be an object of the form `{sourceLocation: {line, uri}, result: {duration, status, exception?}, pickle}` - * The pickle object comes from the [gherkin](https://github.com/cucumber/cucumber/tree/gherkin-v4.1.3/gherkin) library. See `testdata/good/*.pickles.ndjson` for examples of its structure. + * The first argument will be an object of the form `{pickle, gherkinDocument, result, testCaseStartedId}` + * The pickle object comes from the [gherkin](https://github.com/cucumber/cucumber/tree/gherkin/v15.0.2/gherkin) library. See `testdata/good/*.pickles.ndjson` for examples of its structure. * When using the asynchronous callback interface, have one final argument for the callback function. `options` can also be a string as a shorthand for specifying `tags`. @@ -59,6 +59,24 @@ Multiple `AfterAll` hooks are executed in the **reverse** order that they are de --- +#### `AfterStep([options,] fn)` + +Defines a hook which is run after each step. + +* `options`: An object with the following keys: + * `tags`: String tag expression used to apply this hook to only specific scenarios. See [cucumber-tag-expressions](https://docs.cucumber.io/tag-expressions/) for more information. + * `timeout`: A hook-specific timeout, to override the default timeout. +* `fn`: A function, defined as follows: + * The first argument will be an object of the form `{pickle, gherkinDocument, result, testCaseStartedId, testStepId}` + * The pickle object comes from the [gherkin](https://github.com/cucumber/cucumber/tree/gherkin/v15.0.2/gherkin) library. See `testdata/good/*.pickles.ndjson` for examples of its structure. + * When using the asynchronous callback interface, have one final argument for the callback function. + +`options` can also be a string as a shorthand for specifying `tags`. + +Multiple `AfterStep` hooks are executed in the **reverse** order that they are defined. + +--- + #### `Before([options,] fn)` Defines a hook which is run before each scenario. Same interface as `After` except the first argument passed to `fn` will not have the `result` property. @@ -75,6 +93,14 @@ Multiple `BeforeAll` hooks are executed in the order that they are defined. --- +#### `BeforeStep([options,] fn)` + +Defines a hook which is run before each step. Same interface as `AfterStep` except the first argument passed to `fn` will not have the `result` property. + +Multiple `BeforeStep` hooks are executed in the order that they are defined. + +--- + #### `defineStep(pattern[, options], fn)` Defines a step. diff --git a/docs/support_files/hooks.md b/docs/support_files/hooks.md index 854d0e84e..2c5483c9a 100644 --- a/docs/support_files/hooks.md +++ b/docs/support_files/hooks.md @@ -102,3 +102,23 @@ AfterAll(function () { return Promise.resolve() }); ``` + +## BeforeStep / AfterStep + +If you have some code execution that needs to be done before or after all steps, use `BeforeStep` / `AfterStep`. Like the `Before` / `After` hooks, these also have a world instance as 'this', and can be conditionally selected for execution based on the tags of the scenario. + +```javascript +var {AfterStep, BeforeStep} = require('cucumber'); + +BeforeStep({tags: "@foo"}, function () { + // This hook will be executed before all steps in a scenario with tag @foo +}); + +AfterStep( function ({result}) { + // This hook will be executed after all steps, and take a screenshot on step failure + if (result.status === Status.FAILED) { + this.driver.takeScreenshot(); + } +}); +``` + diff --git a/features/before_after_step_hooks.feature b/features/before_after_step_hooks.feature new file mode 100644 index 000000000..d4baa5da2 --- /dev/null +++ b/features/before_after_step_hooks.feature @@ -0,0 +1,95 @@ +Feature: Before and After Step Hooks + + Background: + Given a file named "features/a.feature" with: + """ + Feature: some feature + @this-tag + Scenario: some scenario + Given a step + """ + And a file named "features/step_definitions/cucumber_steps.js" with: + """ + const {Given} = require('@cucumber/cucumber') + Given(/^a step$/, function() {}) + """ + + Scenario: Before and After Hooks work correctly + Given a file named "features/support/hooks.js" with: + """ + const {BeforeStep, AfterStep, BeforeAll, AfterAll} = require('@cucumber/cucumber') + const {expect} = require('chai') + + let counter = 1 + + BeforeStep(function() { + counter = counter + 1 + }) + + AfterStep(function() { + expect(counter).to.eql(2) + counter = counter + 1 + }) + + AfterAll(function() { + expect(counter).to.eql(3) + }) + """ + When I run cucumber-js + Then it passes + + Scenario: Failing before step fails the scenario + Given a file named "features/support/hooks.js" with: + """ + const {BeforeStep} = require('@cucumber/cucumber') + BeforeStep(function() { throw 'Fail' }) + """ + When I run cucumber-js + Then it fails + + Scenario: Failing after step fails the scenario + Given a file named "features/support/hooks.js" with: + """ + const {AfterStep} = require('@cucumber/cucumber') + AfterStep(function() { throw 'Fail' }) + """ + When I run cucumber-js + Then it fails + + Scenario: Only run BeforeStep hooks with appropriate tags + Given a file named "features/support/hooks.js" with: + """ + const { BeforeStep } = require('@cucumber/cucumber') + BeforeStep({tags: "@any-tag"}, function() { + throw Error("Would fail if ran") + }) + """ + When I run cucumber-js + Then it passes + + Scenario: Only run BeforeStep hooks with appropriate tags + Given a file named "features/support/hooks.js" with: + """ + const { AfterStep } = require('@cucumber/cucumber') + AfterStep({tags: "@this-tag"}, function() { + throw Error("Would fail if ran") + }) + """ + When I run cucumber-js + Then it fails + + Scenario: after hook parameter can access result status of step + Given a file named "features/support/hooks.js" with: + """ + const { AfterStep, Status } = require('@cucumber/cucumber') + + AfterStep(function({result}) { + if (result.status === Status.PASSED) { + return + } else { + throw Error("Result object did not get passed properly to AfterStep Hook.") + } + }) + """ + When I run cucumber-js + Then it passes diff --git a/features/world_in_hooks.feature b/features/world_in_hooks.feature new file mode 100644 index 000000000..818a9e21a --- /dev/null +++ b/features/world_in_hooks.feature @@ -0,0 +1,68 @@ +Feature: World in Hooks + + Background: + Given a file named "features/a.feature" with: + """ + Feature: some feature + Scenario: some scenario + Given a step + """ + And a file named "features/step_definitions/cucumber_steps.js" with: + """ + const {Given} = require('@cucumber/cucumber') + Given(/^a step$/, function() {}) + """ + And a file named "features/support/world.js" with: + """ + const {setWorldConstructor} = require('@cucumber/cucumber') + function WorldConstructor() { + return { + isWorld: function() { return true } + } + } + setWorldConstructor(WorldConstructor) + """ + + Scenario: World is this in hooks + Given a file named "features/support/hooks.js" with: + """ + const {After, Before } = require('@cucumber/cucumber') + Before(function() { + if (!this.isWorld()) { + throw Error("Expected this to be world") + } + }) + After(function() { + if (!this.isWorld()) { + throw Error("Expected this to be world") + } + }) + """ + When I run cucumber-js + Then it passes + + Scenario: World is this in BeforeStep hooks + Given a file named "features/support/hooks.js" with: + """ + const {BeforeStep } = require('@cucumber/cucumber') + BeforeStep(function() { + if (!this.isWorld()) { + throw Error("Expected this to be world") + } + }) + """ + When I run cucumber-js + Then it passes + + Scenario: World is this in AfterStep hooks + Given a file named "features/support/hooks.js" with: + """ + const {AfterStep } = require('@cucumber/cucumber') + AfterStep(function() { + if (!this.isWorld()) { + throw Error("Expected this to be world") + } + }) + """ + When I run cucumber-js + Then it passes diff --git a/src/cli/helpers_spec.ts b/src/cli/helpers_spec.ts index 075c3fe06..c2d222cb9 100644 --- a/src/cli/helpers_spec.ts +++ b/src/cli/helpers_spec.ts @@ -69,8 +69,10 @@ function testEmitSupportCodeMessages( stepDefinitions: [], beforeTestRunHookDefinitions: [], beforeTestCaseHookDefinitions: [], + beforeTestStepHookDefinitions: [], afterTestRunHookDefinitions: [], afterTestCaseHookDefinitions: [], + afterTestStepHookDefinitions: [], defaultTimeout: 0, parameterTypeRegistry: new ParameterTypeRegistry(), undefinedParameterTypes: [], diff --git a/src/formatter/helpers/summary_helpers_spec.ts b/src/formatter/helpers/summary_helpers_spec.ts index 16804787f..5f9ce028c 100644 --- a/src/formatter/helpers/summary_helpers_spec.ts +++ b/src/formatter/helpers/summary_helpers_spec.ts @@ -351,5 +351,48 @@ describe('SummaryHelpers', () => { ) }) }) + + describe('with one passing scenario with one step and a beforeStep and afterStep hook', () => { + it('outputs the duration as `0m24.000s (executing steps: 0m24.000s)`', async () => { + // Arrange + const sourceData = [ + 'Feature: a', + 'Scenario: b', + 'Given a passing step', + ].join('\n') + const supportCodeLibrary = buildSupportCodeLibrary( + ({ Given, BeforeStep, AfterStep }) => { + Given('a passing step', () => { + clock.tick(12.3 * 1000) + }) + BeforeStep(() => { + clock.tick(5 * 1000) + }) + AfterStep(() => { + clock.tick(6.7 * 1000) + }) + } + ) + + // Act + const output = await testFormatSummary({ + sourceData, + supportCodeLibrary, + testRunFinished: messages.TestRunFinished.fromObject({ + timestamp: { + nanos: 0, + seconds: 24, + }, + }), + }) + + // Assert + expect(output).to.contain( + '1 scenario (1 passed)\n' + + '1 step (1 passed)\n' + + '0m24.000s (executing steps: 0m24.000s)\n' + ) + }) + }) }) }) diff --git a/src/index.ts b/src/index.ts index 691cc74c1..98feb92ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,12 +22,14 @@ export { default as UsageFormatter } from './formatter/usage_formatter' export { default as UsageJsonFormatter } from './formatter/usage_json_formatter' export { formatterHelpers } -// Support Code Fuctions +// Support Code Functions const { methods } = supportCodeLibraryBuilder export const After = methods.After export const AfterAll = methods.AfterAll +export const AfterStep = methods.AfterStep export const Before = methods.Before export const BeforeAll = methods.BeforeAll +export const BeforeStep = methods.BeforeStep export const defineParameterType = methods.defineParameterType export const defineStep = methods.defineStep export const Given = methods.Given diff --git a/src/models/test_step_hook_definition.ts b/src/models/test_step_hook_definition.ts new file mode 100644 index 000000000..ff31da134 --- /dev/null +++ b/src/models/test_step_hook_definition.ts @@ -0,0 +1,37 @@ +import { PickleTagFilter } from '../pickle_filter' +import Definition, { + IDefinition, + IGetInvocationDataResponse, + IGetInvocationDataRequest, + IDefinitionParameters, + IHookDefinitionOptions, +} from './definition' +import { messages } from '@cucumber/messages' + +export default class TestStepHookDefinition + extends Definition + implements IDefinition { + public readonly tagExpression: string + private readonly pickleTagFilter: PickleTagFilter + + constructor(data: IDefinitionParameters) { + super(data) + this.tagExpression = data.options.tags + this.pickleTagFilter = new PickleTagFilter(data.options.tags) + } + + appliesToTestCase(pickle: messages.IPickle): boolean { + return this.pickleTagFilter.matchesAllTagExpressions(pickle) + } + + async getInvocationParameters({ + hookParameter, + }: IGetInvocationDataRequest): Promise { + return await Promise.resolve({ + getInvalidCodeLengthMessage: () => + this.buildInvalidCodeLengthMessage('0 or 1', '2'), + parameters: [hookParameter], + validCodeLengths: [0, 1, 2], + }) + } +} diff --git a/src/models/test_step_hook_definition_spec.ts b/src/models/test_step_hook_definition_spec.ts new file mode 100644 index 000000000..a885050a3 --- /dev/null +++ b/src/models/test_step_hook_definition_spec.ts @@ -0,0 +1,68 @@ +import { describe, it } from 'mocha' +import { expect } from 'chai' +import TestStepHookDefinition from './test_step_hook_definition' +import { getPickleWithTags } from '../../test/gherkin_helpers' + +describe('TestStepHookDefinition', () => { + describe('appliesToTestCase', () => { + describe('no tags', () => { + it('returns true', async () => { + // Arrange + const pickle = await getPickleWithTags([]) + const testStepHookDefinition = new TestStepHookDefinition({ + code: undefined, + id: '', + line: 0, + uri: '', + options: {}, + }) + + // Act + const result = testStepHookDefinition.appliesToTestCase(pickle) + + // Assert + expect(result).to.eql(true) + }) + }) + + describe('tags match', () => { + it('returns true', async () => { + // Arrange + const pickle = await getPickleWithTags(['@tagA']) + const testStepHookDefinition = new TestStepHookDefinition({ + code: undefined, + id: '', + line: 0, + uri: '', + options: { tags: '@tagA' }, + }) + + // Act + const result = testStepHookDefinition.appliesToTestCase(pickle) + + // Assert + expect(result).to.eql(true) + }) + }) + + describe('tags do not match', () => { + it('returns false', async () => { + // Arrange + const pickle = await getPickleWithTags([]) + const testStepHookDefinition = new TestStepHookDefinition({ + code: undefined, + id: '', + line: 0, + uri: '', + options: { tags: '@tagA' }, + }) + + // Act + const result = testStepHookDefinition.appliesToTestCase(pickle) + + // Assert + expect(result).to.eql(false) + }) + }) + }) +}) diff --git a/src/runtime/pickle_runner.ts b/src/runtime/pickle_runner.ts index 56eb6b849..b27ac2e10 100644 --- a/src/runtime/pickle_runner.ts +++ b/src/runtime/pickle_runner.ts @@ -3,12 +3,15 @@ import { getAmbiguousStepException } from './helpers' import AttachmentManager from './attachment_manager' import StepRunner from './step_runner' import { IdGenerator, messages } from '@cucumber/messages' +import { addDurations, getZeroDuration } from '../time' import { EventEmitter } from 'events' import { ISupportCodeLibrary, ITestCaseHookParameter, + ITestStepHookParameter, } from '../support_code_library_builder/types' import TestCaseHookDefinition from '../models/test_case_hook_definition' +import TestStepHookDefinition from '../models/test_step_hook_definition' import StepDefinition from '../models/step_definition' import { IDefinition } from '../models/definition' import { doesHaveValue, doesNotHaveValue } from '../value_checker' @@ -23,6 +26,7 @@ interface ITestStep { isBeforeHook?: boolean isHook: boolean hookDefinition?: TestCaseHookDefinition + stepHookDefinition?: TestStepHookDefinition pickleStep?: messages.Pickle.IPickleStep stepDefinitions?: StepDefinition[] } @@ -199,6 +203,18 @@ export default class PickleRunner { ) } + getBeforeStepHookDefinitions(): TestStepHookDefinition[] { + return this.supportCodeLibrary.beforeTestStepHookDefinitions.filter( + (hookDefinition) => hookDefinition.appliesToTestCase(this.pickle) + ) + } + + getAfterStepHookDefinitions(): TestStepHookDefinition[] { + return clone(this.supportCodeLibrary.afterTestStepHookDefinitions) + .reverse() + .filter((hookDefinition) => hookDefinition.appliesToTestCase(this.pickle)) + } + getStepDefinitions( pickleStep: messages.Pickle.IPickleStep ): StepDefinition[] { @@ -346,6 +362,26 @@ export default class PickleRunner { return await this.invokeStep(null, hookDefinition, hookParameter) } + async runStepHooks( + stepHooks: TestStepHookDefinition[], + stepResult?: messages.TestStepFinished.ITestStepResult + ): Promise { + const stepHooksResult = [] + const hookParameter: ITestStepHookParameter = { + gherkinDocument: this.gherkinDocument, + pickle: this.pickle, + testCaseStartedId: this.currentTestCaseStartedId, + testStepId: this.currentTestStepId, + result: stepResult, + } + for (const stepHookDefinition of stepHooks) { + stepHooksResult.push( + await this.invokeStep(null, stepHookDefinition, hookParameter) + ) + } + return stepHooksResult + } + async runStep( testStep: ITestStep ): Promise { @@ -375,9 +411,33 @@ export default class PickleRunner { }, }) } - return await this.invokeStep( - testStep.pickleStep, - testStep.stepDefinitions[0] + + let stepResult + let stepResults = await this.runStepHooks( + this.getBeforeStepHookDefinitions(), + stepResult + ) + if ( + new Query().getWorstTestStepResult(stepResults).status !== Status.FAILED + ) { + stepResult = await this.invokeStep( + testStep.pickleStep, + testStep.stepDefinitions[0] + ) + stepResults.push(stepResult) + } + const afterStepHookResults = await this.runStepHooks( + this.getAfterStepHookDefinitions(), + stepResult ) + stepResults = stepResults.concat(afterStepHookResults) + + const finalStepResult = new Query().getWorstTestStepResult(stepResults) + let finalDuration = getZeroDuration() + for (const result of stepResults) { + finalDuration = addDurations(finalDuration, result.duration) + } + finalStepResult.duration = finalDuration + return finalStepResult } } diff --git a/src/runtime/pickle_runner_spec.ts b/src/runtime/pickle_runner_spec.ts index c3559b211..758dc7200 100644 --- a/src/runtime/pickle_runner_spec.ts +++ b/src/runtime/pickle_runner_spec.ts @@ -505,7 +505,7 @@ describe('PickleRunner', () => { }) }) - describe('with hooks', () => { + describe('with test case hooks', () => { it('emits the expected envelopes and returns a skipped result', async () => { // Arrange const supportCodeLibrary = buildSupportCodeLibrary( @@ -571,5 +571,60 @@ describe('PickleRunner', () => { ) }) }) + + describe('with step hooks', () => { + it('emits the expected envelopes and returns a skipped result', async () => { + // Arrange + const supportCodeLibrary = buildSupportCodeLibrary( + ({ Given, BeforeStep, AfterStep }) => { + Given('a step', function () { + clock.tick(1) + }) + BeforeStep(function () {}) // eslint-disable-line @typescript-eslint/no-empty-function + AfterStep(function () {}) // eslint-disable-line @typescript-eslint/no-empty-function + } + ) + const { + gherkinDocument, + pickles: [pickle], + } = await parse({ + data: ['Feature: a', 'Scenario: b', 'Given a step'].join('\n'), + uri: 'a.feature', + }) + + // Act + const { envelopes, result } = await testPickleRunner({ + gherkinDocument, + pickle, + supportCodeLibrary, + }) + + // Assert + expect(envelopes).to.have.lengthOf(5) + expect(envelopes[0]).to.eql( + messages.Envelope.fromObject({ + testCase: { + id: '0', + pickleId: pickle.id, + testSteps: [ + { + id: '1', + pickleStepId: pickle.steps[0].id, + stepDefinitionIds: [supportCodeLibrary.stepDefinitions[0].id], + stepMatchArgumentsLists: [ + { + stepMatchArguments: [], + }, + ], + }, + ], + }, + }) + ) + expect(result).to.eql( + envelopes[3].testStepFinished.testStepResult.status + ) + }) + }) }) }) diff --git a/src/support_code_library_builder/index.ts b/src/support_code_library_builder/index.ts index 7e82053d7..a3275b95d 100644 --- a/src/support_code_library_builder/index.ts +++ b/src/support_code_library_builder/index.ts @@ -2,6 +2,7 @@ import _ from 'lodash' import { buildParameterType, getDefinitionLineAndUri } from './build_helpers' import { IdGenerator, messages } from '@cucumber/messages' import TestCaseHookDefinition from '../models/test_case_hook_definition' +import TestStepHookDefinition from '../models/test_step_hook_definition' import TestRunHookDefinition from '../models/test_run_hook_definition' import StepDefinition from '../models/step_definition' import { formatLocation } from '../formatter/helpers' @@ -20,10 +21,12 @@ import { IDefineStepOptions, IDefineSupportCodeMethods, IDefineTestCaseHookOptions, + IDefineTestStepHookOptions, IDefineTestRunHookOptions, IParameterTypeDefinition, ISupportCodeLibrary, TestCaseHookFunction, + TestStepHookFunction, } from './types' import World from './world' @@ -42,6 +45,13 @@ interface ITestCaseHookDefinitionConfig { uri: string } +interface ITestStepHookDefinitionConfig { + code: any + line: number + options: any + uri: string +} + interface ITestRunHookDefinitionConfig { code: any line: number @@ -56,8 +66,10 @@ export class SupportCodeLibraryBuilder { private afterTestCaseHookDefinitionConfigs: ITestCaseHookDefinitionConfig[] private afterTestRunHookDefinitionConfigs: ITestRunHookDefinitionConfig[] + private afterTestStepHookDefinitionConfigs: ITestStepHookDefinitionConfig[] private beforeTestCaseHookDefinitionConfigs: ITestCaseHookDefinitionConfig[] private beforeTestRunHookDefinitionConfigs: ITestRunHookDefinitionConfig[] + private beforeTestStepHookDefinitionConfigs: ITestStepHookDefinitionConfig[] private cwd: string private defaultTimeout: number private definitionFunctionWrapper: any @@ -75,12 +87,18 @@ export class SupportCodeLibraryBuilder { AfterAll: this.defineTestRunHook( () => this.afterTestRunHookDefinitionConfigs ), + AfterStep: this.defineTestStepHook( + () => this.afterTestStepHookDefinitionConfigs + ), Before: this.defineTestCaseHook( () => this.beforeTestCaseHookDefinitionConfigs ), BeforeAll: this.defineTestRunHook( () => this.beforeTestRunHookDefinitionConfigs ), + BeforeStep: this.defineTestStepHook( + () => this.beforeTestStepHookDefinitionConfigs + ), defineParameterType: this.defineParameterType.bind(this), defineStep, Given: defineStep, @@ -158,6 +176,37 @@ export class SupportCodeLibraryBuilder { } } + defineTestStepHook( + getCollection: () => ITestStepHookDefinitionConfig[] + ): ( + options: string | IDefineTestStepHookOptions | TestStepHookFunction, + code?: TestStepHookFunction + ) => void { + return ( + options: string | IDefineTestStepHookOptions | TestStepHookFunction, + code?: TestStepHookFunction + ) => { + if (typeof options === 'string') { + options = { tags: options } + } else if (typeof options === 'function') { + code = options + options = {} + } + const { line, uri } = getDefinitionLineAndUri(this.cwd) + validateArguments({ + args: { code, options }, + fnName: 'defineTestStepHook', + location: formatLocation({ line, uri }), + }) + getCollection().push({ + code, + line, + options, + uri, + }) + } + } + defineTestRunHook( getCollection: () => ITestRunHookDefinitionConfig[] ): (options: IDefineTestRunHookOptions | Function, code?: Function) => void { @@ -218,6 +267,25 @@ export class SupportCodeLibraryBuilder { }) } + buildTestStepHookDefinitions( + configs: ITestStepHookDefinitionConfig[] + ): TestStepHookDefinition[] { + return configs.map(({ code, line, options, uri }) => { + const wrappedCode = this.wrapCode({ + code, + wrapperOptions: options.wrapperOptions, + }) + return new TestStepHookDefinition({ + code: wrappedCode, + id: this.newId(), + line, + options, + unwrappedCode: code, + uri, + }) + }) + } + buildTestRunHookDefinitions( configs: ITestRunHookDefinitionConfig[] ): TestRunHookDefinition[] { @@ -311,12 +379,18 @@ export class SupportCodeLibraryBuilder { afterTestRunHookDefinitions: this.buildTestRunHookDefinitions( this.afterTestRunHookDefinitionConfigs ), + afterTestStepHookDefinitions: this.buildTestStepHookDefinitions( + this.afterTestStepHookDefinitionConfigs + ), beforeTestCaseHookDefinitions: this.buildTestCaseHookDefinitions( this.beforeTestCaseHookDefinitionConfigs ), beforeTestRunHookDefinitions: this.buildTestRunHookDefinitions( this.beforeTestRunHookDefinitionConfigs ), + beforeTestStepHookDefinitions: this.buildTestStepHookDefinitions( + this.beforeTestStepHookDefinitionConfigs + ), defaultTimeout: this.defaultTimeout, parameterTypeRegistry: this.parameterTypeRegistry, undefinedParameterTypes: stepDefinitionsResult.undefinedParameterTypes, @@ -330,8 +404,10 @@ export class SupportCodeLibraryBuilder { this.newId = newId this.afterTestCaseHookDefinitionConfigs = [] this.afterTestRunHookDefinitionConfigs = [] + this.afterTestStepHookDefinitionConfigs = [] this.beforeTestCaseHookDefinitionConfigs = [] this.beforeTestRunHookDefinitionConfigs = [] + this.beforeTestStepHookDefinitionConfigs = [] this.definitionFunctionWrapper = null this.defaultTimeout = 5000 this.parameterTypeRegistry = new ParameterTypeRegistry() diff --git a/src/support_code_library_builder/index_spec.ts b/src/support_code_library_builder/index_spec.ts index 390b42875..f9df5446b 100644 --- a/src/support_code_library_builder/index_spec.ts +++ b/src/support_code_library_builder/index_spec.ts @@ -255,4 +255,180 @@ describe('supportCodeLibraryBuilder', () => { }) }) }) + + describe('AfterStep', () => { + describe('function only', () => { + it('adds a test step hook definition', function () { + // Arrange + const hook = function (): void {} // eslint-disable-line @typescript-eslint/no-empty-function + supportCodeLibraryBuilder.reset('path/to/project', uuid()) + supportCodeLibraryBuilder.methods.AfterStep(hook) + + // Act + const options = supportCodeLibraryBuilder.finalize() + + // Assert + expect(options.afterTestStepHookDefinitions).to.have.lengthOf(1) + const testStepHookDefinition = options.afterTestStepHookDefinitions[0] + expect(testStepHookDefinition.code).to.eql(hook) + }) + }) + + describe('tag and function', () => { + it('adds a step hook definition', async function () { + // Arrange + const hook = function (): void {} // eslint-disable-line @typescript-eslint/no-empty-function + supportCodeLibraryBuilder.reset('path/to/project', uuid()) + supportCodeLibraryBuilder.methods.AfterStep('@tagA', hook) + const pickleWithTagA = await getPickleWithTags(['@tagA']) + const pickleWithTagB = await getPickleWithTags(['@tagB']) + + // Act + const options = supportCodeLibraryBuilder.finalize() + + // Assert + expect(options.afterTestStepHookDefinitions).to.have.lengthOf(1) + const testStepHookDefinition = options.afterTestStepHookDefinitions[0] + expect(testStepHookDefinition.code).to.eql(hook) + expect(testStepHookDefinition.appliesToTestCase(pickleWithTagA)).to.eql( + true + ) + expect(testStepHookDefinition.appliesToTestCase(pickleWithTagB)).to.eql( + false + ) + }) + }) + + describe('options and function', () => { + it('adds a step hook definition', async function () { + // Arrange + const hook = function (): void {} // eslint-disable-line @typescript-eslint/no-empty-function + supportCodeLibraryBuilder.reset('path/to/project', uuid()) + supportCodeLibraryBuilder.methods.AfterStep({ tags: '@tagA' }, hook) + const pickleWithTagA = await getPickleWithTags(['@tagA']) + const pickleWithTagB = await getPickleWithTags(['@tagB']) + + // Act + const options = supportCodeLibraryBuilder.finalize() + + // Assert + expect(options.afterTestStepHookDefinitions).to.have.lengthOf(1) + const testStepHookDefinition = options.afterTestStepHookDefinitions[0] + expect(testStepHookDefinition.code).to.eql(hook) + expect(testStepHookDefinition.appliesToTestCase(pickleWithTagA)).to.eql( + true + ) + expect(testStepHookDefinition.appliesToTestCase(pickleWithTagB)).to.eql( + false + ) + }) + }) + + describe('multiple', () => { + it('adds the step hook definitions in the order of definition', function () { + // Arrange + const hook1 = function hook1(): void {} // eslint-disable-line @typescript-eslint/no-empty-function + const hook2 = function hook2(): void {} // eslint-disable-line @typescript-eslint/no-empty-function + supportCodeLibraryBuilder.reset('path/to/project', uuid()) + supportCodeLibraryBuilder.methods.AfterStep(hook1) + supportCodeLibraryBuilder.methods.AfterStep(hook2) + + // Act + const options = supportCodeLibraryBuilder.finalize() + + // Assert + expect(options.afterTestStepHookDefinitions).to.have.lengthOf(2) + expect(options.afterTestStepHookDefinitions[0].code).to.eql(hook1) + expect(options.afterTestStepHookDefinitions[1].code).to.eql(hook2) + }) + }) + }) + + describe('BeforeStep', () => { + describe('function only', () => { + it('adds a step hook definition', function () { + // Arrange + const hook = function (): void {} // eslint-disable-line @typescript-eslint/no-empty-function + supportCodeLibraryBuilder.reset('path/to/project', uuid()) + supportCodeLibraryBuilder.methods.BeforeStep(hook) + + // Act + const options = supportCodeLibraryBuilder.finalize() + + // Assert + expect(options.beforeTestStepHookDefinitions).to.have.lengthOf(1) + const testStepHookDefinition = options.beforeTestStepHookDefinitions[0] + expect(testStepHookDefinition.code).to.eql(hook) + }) + }) + + describe('tag and function', () => { + it('adds a step hook definition', async function () { + // Arrange + const hook = function (): void {} // eslint-disable-line @typescript-eslint/no-empty-function + supportCodeLibraryBuilder.reset('path/to/project', uuid()) + supportCodeLibraryBuilder.methods.BeforeStep('@tagA', hook) + const pickleWithTagA = await getPickleWithTags(['@tagA']) + const pickleWithTagB = await getPickleWithTags(['@tagB']) + + // Act + const options = supportCodeLibraryBuilder.finalize() + + // Assert + expect(options.beforeTestStepHookDefinitions).to.have.lengthOf(1) + const testStepHookDefinition = options.beforeTestStepHookDefinitions[0] + expect(testStepHookDefinition.code).to.eql(hook) + expect(testStepHookDefinition.appliesToTestCase(pickleWithTagA)).to.eql( + true + ) + expect(testStepHookDefinition.appliesToTestCase(pickleWithTagB)).to.eql( + false + ) + }) + }) + + describe('options and function', () => { + it('adds a step hook definition', async function () { + // Arrange + const hook = function (): void {} // eslint-disable-line @typescript-eslint/no-empty-function + supportCodeLibraryBuilder.reset('path/to/project', uuid()) + supportCodeLibraryBuilder.methods.BeforeStep({ tags: '@tagA' }, hook) + const pickleWithTagA = await getPickleWithTags(['@tagA']) + const pickleWithTagB = await getPickleWithTags(['@tagB']) + + // Act + const options = supportCodeLibraryBuilder.finalize() + + // Assert + expect(options.beforeTestStepHookDefinitions).to.have.lengthOf(1) + const testStepHookDefinition = options.beforeTestStepHookDefinitions[0] + expect(testStepHookDefinition.code).to.eql(hook) + expect(testStepHookDefinition.appliesToTestCase(pickleWithTagA)).to.eql( + true + ) + expect(testStepHookDefinition.appliesToTestCase(pickleWithTagB)).to.eql( + false + ) + }) + }) + + describe('multiple', () => { + it('adds the step hook definitions in the order of definition', function () { + // Arrange + const hook1 = function hook1(): void {} // eslint-disable-line @typescript-eslint/no-empty-function + const hook2 = function hook2(): void {} // eslint-disable-line @typescript-eslint/no-empty-function + supportCodeLibraryBuilder.reset('path/to/project', uuid()) + supportCodeLibraryBuilder.methods.BeforeStep(hook1) + supportCodeLibraryBuilder.methods.BeforeStep(hook2) + + // Act + const options = supportCodeLibraryBuilder.finalize() + + // Assert + expect(options.beforeTestStepHookDefinitions).to.have.lengthOf(2) + expect(options.beforeTestStepHookDefinitions[0].code).to.eql(hook1) + expect(options.beforeTestStepHookDefinitions[1].code).to.eql(hook2) + }) + }) + }) }) diff --git a/src/support_code_library_builder/types.ts b/src/support_code_library_builder/types.ts index 81440a317..9b541c1da 100644 --- a/src/support_code_library_builder/types.ts +++ b/src/support_code_library_builder/types.ts @@ -1,5 +1,6 @@ import { messages } from '@cucumber/messages' import TestCaseHookDefinition from '../models/test_case_hook_definition' +import TestStepHookDefinition from '../models/test_step_hook_definition' import TestRunHookDefinition from '../models/test_run_hook_definition' import StepDefinition from '../models/step_definition' import { ParameterTypeRegistry } from '@cucumber/cucumber-expressions' @@ -13,6 +14,14 @@ export interface ITestCaseHookParameter { testCaseStartedId: string } +export interface ITestStepHookParameter { + gherkinDocument: messages.IGherkinDocument + pickle: messages.IPickle + result: messages.TestStepFinished.ITestStepResult + testCaseStartedId: string + testStepId: string +} + export type TestCaseHookFunctionWithoutParameter = () => void | Promise export type TestCaseHookFunctionWithParameter = ( arg: ITestCaseHookParameter @@ -21,6 +30,14 @@ export type TestCaseHookFunction = | TestCaseHookFunctionWithoutParameter | TestCaseHookFunctionWithParameter +export type TestStepHookFunctionWithoutParameter = () => void +export type TestStepHookFunctionWithParameter = ( + arg: ITestStepHookParameter +) => void +export type TestStepHookFunction = + | TestStepHookFunctionWithoutParameter + | TestStepHookFunctionWithParameter + export interface IDefineStepOptions { timeout?: number wrapperOptions?: any @@ -31,6 +48,11 @@ export interface IDefineTestCaseHookOptions { timeout?: number } +export interface IDefineTestStepHookOptions { + tags?: string + timeout?: number +} + export interface IDefineTestRunHookOptions { timeout?: number } @@ -57,11 +79,17 @@ export interface IDefineSupportCodeMethods { After: ((code: TestCaseHookFunction) => void) & ((tags: string, code: TestCaseHookFunction) => void) & ((options: IDefineTestCaseHookOptions, code: TestCaseHookFunction) => void) + AfterStep: ((code: TestStepHookFunction) => void) & + ((tags: string, code: TestStepHookFunction) => void) & + ((options: IDefineTestStepHookOptions, code: TestStepHookFunction) => void) AfterAll: ((code: Function) => void) & ((options: IDefineTestRunHookOptions, code: Function) => void) Before: ((code: TestCaseHookFunction) => void) & ((tags: string, code: TestCaseHookFunction) => void) & ((options: IDefineTestCaseHookOptions, code: TestCaseHookFunction) => void) + BeforeStep: ((code: TestStepHookFunction) => void) & + ((tags: string, code: TestStepHookFunction) => void) & + ((options: IDefineTestStepHookOptions, code: TestStepHookFunction) => void) BeforeAll: ((code: Function) => void) & ((options: IDefineTestRunHookOptions, code: Function) => void) Given: ((pattern: DefineStepPattern, code: Function) => void) & @@ -86,8 +114,10 @@ export interface IDefineSupportCodeMethods { export interface ISupportCodeLibrary { readonly afterTestCaseHookDefinitions: TestCaseHookDefinition[] + readonly afterTestStepHookDefinitions: TestStepHookDefinition[] readonly afterTestRunHookDefinitions: TestRunHookDefinition[] readonly beforeTestCaseHookDefinitions: TestCaseHookDefinition[] + readonly beforeTestStepHookDefinitions: TestStepHookDefinition[] readonly beforeTestRunHookDefinitions: TestRunHookDefinition[] readonly defaultTimeout: number readonly stepDefinitions: StepDefinition[] diff --git a/src/support_code_library_builder/validate_arguments.ts b/src/support_code_library_builder/validate_arguments.ts index 1984b9e80..70317c8d2 100644 --- a/src/support_code_library_builder/validate_arguments.ts +++ b/src/support_code_library_builder/validate_arguments.ts @@ -54,6 +54,18 @@ const validations: Dictionary = { optionsTimeoutValidation, { identifier: 'second argument', ...fnValidation }, ], + defineTestStepHook: [ + { identifier: 'first argument', ...optionsValidation }, + { + identifier: '"options.tags"', + expectedType: 'string', + predicate({ options }) { + return doesNotHaveValue(options.tags) || _.isString(options.tags) + }, + }, + optionsTimeoutValidation, + { identifier: 'second argument', ...fnValidation }, + ], defineStep: [ { identifier: 'first argument',