diff --git a/cucumber-core.api.md b/cucumber-core.api.md index 4460bec..2241a32 100644 --- a/cucumber-core.api.md +++ b/cucumber-core.api.md @@ -18,6 +18,7 @@ import { PickleDocString } from '@cucumber/messages'; import { PickleStep } from '@cucumber/messages'; import { PickleTable } from '@cucumber/messages'; import { RegularExpression } from '@cucumber/cucumber-expressions'; +import { Snippet } from '@cucumber/messages'; import { SourceReference } from '@cucumber/messages'; import { StepDefinition } from '@cucumber/messages'; import { TestCase } from '@cucumber/messages'; @@ -25,9 +26,16 @@ import { TestStep } from '@cucumber/messages'; // @public export class AmbiguousError extends Error { - constructor(text: string, references: ReadonlyArray); + constructor(step: AmbiguousStep); } +// @public +export type AmbiguousStep = { + type: 'ambiguous'; + pickleStep: PickleStep; + matches: ReadonlyArray; +}; + // @public export interface AssembledTestCase { id: string; @@ -53,7 +61,7 @@ export interface AssembledTestStep { prefix: string; body: string; }; - prepare(): PreparedStep; + prepare(): PreparedStep | UndefinedStep | AmbiguousStep; sourceReference: SourceReference; toMessage(): TestStep; } @@ -160,6 +168,7 @@ export interface NewTestRunHook { // @public export type PreparedStep = { + type: 'prepared'; fn: SupportCodeFunction; args: ReadonlyArray; dataTable?: PickleTable; @@ -212,9 +221,7 @@ export interface TestPlanOptions { // @public export class UndefinedError extends Error { - constructor(pickleStep: PickleStep); - // (undocumented) - readonly pickleStep: PickleStep; + constructor(step: UndefinedStep, snippets?: ReadonlyArray); } // @public @@ -223,6 +230,12 @@ export type UndefinedParameterType = { expression: string; }; +// @public +export type UndefinedStep = { + type: 'undefined'; + pickleStep: PickleStep; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/src/AmbiguousError.spec.ts b/src/AmbiguousError.spec.ts index 7fbe82f..8b89a3e 100644 --- a/src/AmbiguousError.spec.ts +++ b/src/AmbiguousError.spec.ts @@ -1,16 +1,42 @@ -import { SourceReference } from '@cucumber/messages' +import { PickleStep, SourceReference } from '@cucumber/messages' import { expect } from 'chai' +import sinon from 'sinon' import { AmbiguousError } from './AmbiguousError' +import { AmbiguousStep, DefinedStep } from './types' describe('AmbiguousError', () => { it('handles source references with and without locations', () => { + const pickleStep: PickleStep = { + id: 'step-1', + text: 'text', + astNodeIds: [], + } + const references: ReadonlyArray = [ { uri: 'steps.js', location: { line: 1, column: 2 } }, { uri: 'steps.js', location: { line: 3, column: 4 } }, { uri: 'mysterious.js' }, ] - const error = new AmbiguousError('text', references) + + const matches: ReadonlyArray = references.map((ref) => ({ + id: 'def-id', + expression: { + raw: 'text', + compiled: {} as any, + }, + fn: sinon.stub(), + sourceReference: ref, + toMessage: sinon.stub(), + })) + + const step: AmbiguousStep = { + type: 'ambiguous', + pickleStep, + matches, + } + + const error = new AmbiguousError(step) expect(error.message).to.equal( 'Multiple matching step definitions found for text "text":\n' + '1) steps.js:1:2\n' + diff --git a/src/AmbiguousError.ts b/src/AmbiguousError.ts index 7e023c5..d4c945e 100644 --- a/src/AmbiguousError.ts +++ b/src/AmbiguousError.ts @@ -1,18 +1,21 @@ -import { SourceReference } from '@cucumber/messages' +import { AmbiguousStep } from './types' /** * Represents an error that occurs when multiple step definitions are found matching the text of a step * @public + * @remarks + * Can be useful where an {@link AmbiguousStep} needs to bubble up as an error in the test framework + * and have a helpful message for the end user. */ export class AmbiguousError extends Error { - constructor(text: string, references: ReadonlyArray) { + constructor(step: AmbiguousStep) { super( - `Multiple matching step definitions found for text "${text}":` + + `Multiple matching step definitions found for text "${step.pickleStep.text}":` + '\n' + - references + step.matches .map( - (ref, index) => - `${index + 1}) ${ref.uri}:${ref.location?.line ?? '?'}:${ref.location?.column ?? '?'}` + (def, index) => + `${index + 1}) ${def.sourceReference.uri}:${def.sourceReference.location?.line ?? '?'}:${def.sourceReference.location?.column ?? '?'}` ) .join('\n') ) diff --git a/src/UndefinedError.spec.ts b/src/UndefinedError.spec.ts new file mode 100644 index 0000000..22dec42 --- /dev/null +++ b/src/UndefinedError.spec.ts @@ -0,0 +1,82 @@ +import { PickleStep, Snippet } from '@cucumber/messages' +import { expect } from 'chai' + +import { UndefinedStep } from './types' +import { UndefinedError } from './UndefinedError' + +describe('UndefinedError', () => { + it('should create an error message with the step text', () => { + const pickleStep: PickleStep = { + id: 'step-1', + text: 'I do something that is not defined', + astNodeIds: [], + } + + const step: UndefinedStep = { + type: 'undefined', + pickleStep, + } + + const error = new UndefinedError(step) + expect(error.message).to.eq( + 'No matching step definitions found for text "I do something that is not defined"' + ) + }) + + it('should not include snippet text when snippets array is empty', () => { + const pickleStep: PickleStep = { + id: 'step-1', + text: 'I do something that is not defined', + astNodeIds: [], + } + + const step: UndefinedStep = { + type: 'undefined', + pickleStep, + } + + const error = new UndefinedError(step, []) + expect(error.message).to.eq( + 'No matching step definitions found for text "I do something that is not defined"' + ) + }) + + it('should include snippets when provided', () => { + const pickleStep: PickleStep = { + id: 'step-1', + text: 'I do something that is not defined', + astNodeIds: [], + } + + const step: UndefinedStep = { + type: 'undefined', + pickleStep, + } + + const snippets: ReadonlyArray = [ + { + language: 'javascript', + code: 'Given("I do something that is not defined", function () {\n // Write code here\n});', + }, + { + language: 'javascript', + code: 'Given(/^I do something that is not defined$/, function () {\n // Write code here\n});', + }, + ] + + const error = new UndefinedError(step, snippets) + expect(error.message).to.eq( + `No matching step definitions found for text "I do something that is not defined" + +You can implement the step with this code: + +Given("I do something that is not defined", function () { + // Write code here +}); + +Given(/^I do something that is not defined$/, function () { + // Write code here +});` + ) + }) +}) diff --git a/src/UndefinedError.ts b/src/UndefinedError.ts index 7c4b47b..9e600e2 100644 --- a/src/UndefinedError.ts +++ b/src/UndefinedError.ts @@ -1,11 +1,24 @@ -import { PickleStep } from '@cucumber/messages' +import { Snippet } from '@cucumber/messages' + +import { UndefinedStep } from './types' /** * Represents an error that occurs when no step definitions are found matching the text of a step * @public + * @remarks + * Can be useful where an {@link UndefinedStep} needs to bubble up as an error in the test framework + * and have a helpful message for the end user. */ export class UndefinedError extends Error { - constructor(public readonly pickleStep: PickleStep) { - super(`No matching step definitions found for text "${pickleStep.text}"`) + constructor(step: UndefinedStep, snippets?: ReadonlyArray) { + let message = `No matching step definitions found for text "${step.pickleStep.text}"` + + if (snippets?.length) { + message += + '\n\nYou can implement the step with this code:\n\n' + + snippets.map((snippet) => snippet.code).join('\n\n') + } + + super(message) } } diff --git a/src/makeTestPlan.spec.ts b/src/makeTestPlan.spec.ts index eb16b4b..ca396f7 100644 --- a/src/makeTestPlan.spec.ts +++ b/src/makeTestPlan.spec.ts @@ -4,10 +4,8 @@ import sinon from 'sinon' import sinonChai from 'sinon-chai' import { parseGherkin } from '../test/parseGherkin' -import { AmbiguousError } from './AmbiguousError' import { buildSupportCode } from './buildSupportCode' import { makeTestPlan } from './makeTestPlan' -import { UndefinedError } from './UndefinedError' use(sinonChai) @@ -107,7 +105,7 @@ describe('makeTestPlan', () => { }) describe('pickle steps', () => { - it('throws if a step is ambiguous', () => { + it('returns an ambiguous step when a step has multiple matches', () => { const { gherkinDocument, pickles } = parseGherkin('minimal.feature', newId) const supportCodeLibrary = buildSupportCode({ newId }) .step({ @@ -129,10 +127,15 @@ describe('makeTestPlan', () => { } ) - expect(() => result.testCases[0].testSteps[0].prepare()).to.throw(AmbiguousError) + const prepared = result.testCases[0].testSteps[0].prepare() + expect(prepared.type).to.eq('ambiguous') + if (prepared.type === 'ambiguous') { + expect(prepared.pickleStep).to.eq(pickles[0].steps[0]) + expect(prepared.matches).to.have.lengthOf(2) + } }) - it('throws if a step is undefined', () => { + it('returns an undefined step when a step has no matches', () => { const { gherkinDocument, pickles } = parseGherkin('minimal.feature', newId) const supportCodeLibrary = buildSupportCode({ newId }).build() @@ -143,11 +146,10 @@ describe('makeTestPlan', () => { } ) - try { - result.testCases[0].testSteps[0].prepare() - } catch (err: any) { - expect(err).to.be.instanceOf(UndefinedError) - expect(err.pickleStep).to.eq(pickles[0].steps[0]) + const prepared = result.testCases[0].testSteps[0].prepare() + expect(prepared.type).to.eq('undefined') + if (prepared.type === 'undefined') { + expect(prepared.pickleStep).to.eq(pickles[0].steps[0]) } }) @@ -171,8 +173,11 @@ describe('makeTestPlan', () => { ) const prepared = result.testCases[0].testSteps[0].prepare() - expect(prepared.fn).to.eq(fn) - expect(prepared.args).to.deep.eq([]) + expect(prepared.type).to.eq('prepared') + if (prepared.type === 'prepared') { + expect(prepared.fn).to.eq(fn) + expect(prepared.args).to.deep.eq([]) + } }) it('matches and prepares a step with parameters', () => { @@ -195,8 +200,11 @@ describe('makeTestPlan', () => { ) const prepared = result.testCases[0].testSteps[0].prepare() - expect(prepared.fn).to.eq(fn) - expect(prepared.args.map((arg) => arg.getValue(undefined))).to.deep.eq([4, 5]) + expect(prepared.type).to.eq('prepared') + if (prepared.type === 'prepared') { + expect(prepared.fn).to.eq(fn) + expect(prepared.args.map((arg) => arg.getValue(undefined))).to.deep.eq([4, 5]) + } }) it('matches and prepares a step with a data table', () => { @@ -219,9 +227,12 @@ describe('makeTestPlan', () => { ) const prepared = result.testCases[0].testSteps[0].prepare() - expect(prepared.fn).to.eq(fn) - expect(prepared.args).to.deep.eq([]) - expect(prepared.dataTable).to.eq(pickles[0].steps[0].argument?.dataTable) + expect(prepared.type).to.eq('prepared') + if (prepared.type === 'prepared') { + expect(prepared.fn).to.eq(fn) + expect(prepared.args).to.deep.eq([]) + expect(prepared.dataTable).to.eq(pickles[0].steps[0].argument?.dataTable) + } }) it('matches and prepares a step with a doc string', () => { @@ -244,9 +255,12 @@ describe('makeTestPlan', () => { ) const prepared = result.testCases[0].testSteps[0].prepare() - expect(prepared.fn).to.eq(fn) - expect(prepared.args).to.deep.eq([]) - expect(prepared.docString).to.eq(pickles[0].steps[0].argument?.docString) + expect(prepared.type).to.eq('prepared') + if (prepared.type === 'prepared') { + expect(prepared.fn).to.eq(fn) + expect(prepared.args).to.deep.eq([]) + expect(prepared.docString).to.eq(pickles[0].steps[0].argument?.docString) + } }) }) @@ -442,8 +456,11 @@ describe('makeTestPlan', () => { ) const prepared = result.testCases[0].testSteps[0].prepare() - expect(prepared.fn).to.eq(fn) - expect(prepared.args).to.deep.eq([]) + expect(prepared.type).to.eq('prepared') + if (prepared.type === 'prepared') { + expect(prepared.fn).to.eq(fn) + expect(prepared.args).to.deep.eq([]) + } }) it('prepares After hooks for execution', () => { @@ -470,8 +487,11 @@ describe('makeTestPlan', () => { ) const prepared = result.testCases[0].testSteps[3].prepare() - expect(prepared.fn).to.eq(fn) - expect(prepared.args).to.deep.eq([]) + expect(prepared.type).to.eq('prepared') + if (prepared.type === 'prepared') { + expect(prepared.fn).to.eq(fn) + expect(prepared.args).to.deep.eq([]) + } }) }) diff --git a/src/makeTestPlan.ts b/src/makeTestPlan.ts index 67534a5..eede45b 100644 --- a/src/makeTestPlan.ts +++ b/src/makeTestPlan.ts @@ -16,7 +16,6 @@ import { Query, } from '@cucumber/query' -import { AmbiguousError } from './AmbiguousError' import { AssembledTestPlan, AssembledTestStep, @@ -24,7 +23,6 @@ import { TestPlanIngredients, TestPlanOptions, } from './types' -import { UndefinedError } from './UndefinedError' /** * Make an executable test plan for a Gherkin document @@ -105,6 +103,7 @@ function fromBeforeHooks( always: false, prepare() { return { + type: 'prepared' as const, fn: def.fn, args: [], } @@ -142,6 +141,7 @@ function fromAfterHooks( always: true, prepare() { return { + type: 'prepared' as const, fn: def.fn, args: [], } @@ -178,15 +178,20 @@ function fromPickleSteps( always: false, prepare() { if (matched.length < 1) { - throw new UndefinedError(pickleStep) + return { + type: 'undefined', + pickleStep, + } } else if (matched.length > 1) { - throw new AmbiguousError( - pickleStep.text, - matched.map(({ def }) => def.sourceReference) - ) + return { + type: 'ambiguous', + pickleStep, + matches: matched.map(({ def }) => def), + } } else { const { def, args } = matched[0] return { + type: 'prepared', fn: def.fn, args, dataTable: pickleStep.argument?.dataTable, diff --git a/src/types.ts b/src/types.ts index ae68a93..1741e45 100644 --- a/src/types.ts +++ b/src/types.ts @@ -412,6 +412,10 @@ export interface TestPlanOptions { * differently. */ export type PreparedStep = { + /** + * Discriminator field to identify this as a prepared step + */ + type: 'prepared' /** * The user-authored function to be executed for this step */ @@ -432,6 +436,40 @@ export type PreparedStep = { docString?: PickleDocString } +/** + * A step that could not be matched to any step definitions + * @public + */ +export type UndefinedStep = { + /** + * Discriminator field to identify this as an undefined step + */ + type: 'undefined' + /** + * The pickle step that could not be matched + */ + pickleStep: import('@cucumber/messages').PickleStep +} + +/** + * A step that was matched to multiple step definitions + * @public + */ +export type AmbiguousStep = { + /** + * Discriminator field to identify this as an ambiguous step + */ + type: 'ambiguous' + /** + * The pickle step that was ambiguous + */ + pickleStep: import('@cucumber/messages').PickleStep + /** + * The step definitions that matched + */ + matches: ReadonlyArray +} + /** * A test step that belongs to an {@link AssembledTestCase} * @public @@ -460,15 +498,17 @@ export interface AssembledTestStep { */ always: boolean /** - * Prepare the test step for execution and return the function and arguments + * Prepare the test step for execution * @remarks * For pickle steps, preparation includes finding matching step definitions from - * the support code library and throwing if there is not exactly one, plus resolving - * the correct arguments from across expressions, doc strings and data tables. The - * consumer can then call the function with the right arrangement of those arguments + * the support code library and returning the appropriate result. If there are no + * matches, an {@link UndefinedStep} is returned. If there are multiple matches, an + * {@link AmbiguousStep} is returned. Otherwise, a {@link PreparedStep} is returned + * with the correct arguments from across expressions, doc strings and data tables. + * The consumer can then call the function with the right arrangement of those arguments * plus anything else as appropriate. */ - prepare(): PreparedStep + prepare(): PreparedStep | UndefinedStep | AmbiguousStep /** * Converts the step to a TestStep message */