diff --git a/CHANGELOG.md b/CHANGELOG.md index 111c35f..c8bdb46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- BREAKING CHANGE: Make `prepare()` less prescriptive ([#32](https://github.com/cucumber/javascript-core/pull/32)) ## [0.7.0] - 2025-11-19 ### Changed diff --git a/cucumber-core.api.md b/cucumber-core.api.md index 8b96c5f..4460bec 100644 --- a/cucumber-core.api.md +++ b/cucumber-core.api.md @@ -14,7 +14,9 @@ import { IdGenerator } from '@cucumber/messages'; import { NamingStrategy } from '@cucumber/query'; import parse from '@cucumber/tag-expressions'; import { Pickle } from '@cucumber/messages'; +import { PickleDocString } from '@cucumber/messages'; import { PickleStep } from '@cucumber/messages'; +import { PickleTable } from '@cucumber/messages'; import { RegularExpression } from '@cucumber/cucumber-expressions'; import { SourceReference } from '@cucumber/messages'; import { StepDefinition } from '@cucumber/messages'; @@ -51,7 +53,7 @@ export interface AssembledTestStep { prefix: string; body: string; }; - prepare(thisArg?: unknown): PreparedFunction; + prepare(): PreparedStep; sourceReference: SourceReference; toMessage(): TestStep; } @@ -62,6 +64,7 @@ export function buildSupportCode(options?: SupportCodeOptions): SupportCodeBuild // @public export class DataTable { constructor(cells: ReadonlyArray>); + static from(pickleTable: PickleTable): DataTable; hashes(): ReadonlyArray>; list(): ReadonlyArray; raw(): ReadonlyArray>; @@ -156,9 +159,11 @@ export interface NewTestRunHook { } // @public -export type PreparedFunction = { +export type PreparedStep = { fn: SupportCodeFunction; - args: ReadonlyArray; + args: ReadonlyArray; + dataTable?: PickleTable; + docString?: PickleDocString; }; // @public diff --git a/src/DataTable.spec.ts b/src/DataTable.spec.ts index a361c3f..7aea8af 100644 --- a/src/DataTable.spec.ts +++ b/src/DataTable.spec.ts @@ -1,6 +1,8 @@ +import { PickleTable } from '@cucumber/messages' import { expect } from 'chai' import { describe, it } from 'mocha' +import { parseGherkin } from '../test/parseGherkin' import { DataTable } from './DataTable' describe('DataTable', () => { @@ -103,4 +105,16 @@ describe('DataTable', () => { ]) }) }) + + describe('from', () => { + it('should construct directly from a PickleTable', () => { + const { pickles } = parseGherkin('datatable.feature') + + const dataTable = DataTable.from(pickles[0].steps[0].argument?.dataTable as PickleTable) + expect(dataTable.raw()).to.deep.eq([ + ['a', 'b', 'c'], + ['1', '2', '3'], + ]) + }) + }) }) diff --git a/src/DataTable.ts b/src/DataTable.ts index 6dce87e..c4765af 100644 --- a/src/DataTable.ts +++ b/src/DataTable.ts @@ -1,9 +1,9 @@ +import { PickleTable } from '@cucumber/messages' + /** * Represents the cells of a Gherkin data table associated with a test step. * @public * @remarks - * For steps that include a data table, an instance of this will be injected as the last - * argument to your step function. */ export class DataTable { constructor(private readonly cells: ReadonlyArray>) {} @@ -151,4 +151,20 @@ export class DataTable { transpose(): DataTable { return new DataTable(this.cells[0].map((x, i) => this.cells.map((y) => y[i]))) } + + /** + * Constructs a DataTable directly from a PickleTable + * + * @example + * ```typescript + * const dataTable = DataTable.from(pickleStep.argument.dataTable) + * ``` + */ + static from(pickleTable: PickleTable): DataTable { + return new DataTable( + pickleTable.rows.map((row) => { + return row.cells.map((cell) => cell.value) + }) + ) + } } diff --git a/src/makeTestPlan.spec.ts b/src/makeTestPlan.spec.ts index 319124e..eb16b4b 100644 --- a/src/makeTestPlan.spec.ts +++ b/src/makeTestPlan.spec.ts @@ -1,42 +1,17 @@ -import * as fs from 'node:fs' -import * as path from 'node:path' - -import { AstBuilder, compile, GherkinClassicTokenMatcher, Parser } from '@cucumber/gherkin' -import { GherkinDocument, IdGenerator, Pickle } from '@cucumber/messages' +import { IdGenerator } from '@cucumber/messages' import { expect, use } from 'chai' import sinon from 'sinon' import sinonChai from 'sinon-chai' +import { parseGherkin } from '../test/parseGherkin' import { AmbiguousError } from './AmbiguousError' import { buildSupportCode } from './buildSupportCode' -import { DataTable } from './DataTable' import { makeTestPlan } from './makeTestPlan' import { UndefinedError } from './UndefinedError' use(sinonChai) -function parseGherkin( - file: string, - newId: () => string -): { gherkinDocument: GherkinDocument; pickles: ReadonlyArray } { - const data = fs.readFileSync(path.join(__dirname, '..', 'testdata', file), { encoding: 'utf-8' }) - const builder = new AstBuilder(newId) - const matcher = new GherkinClassicTokenMatcher() - const parser = new Parser(builder, matcher) - const uri = 'features/' + file - const gherkinDocument = { - uri, - ...parser.parse(data), - } - const pickles = compile(gherkinDocument, uri, newId) - return { - gherkinDocument, - pickles, - } -} - describe('makeTestPlan', () => { - class FakeWorld {} const testRunStartedId = 'run-id' let newId: () => string @@ -154,7 +129,7 @@ describe('makeTestPlan', () => { } ) - expect(() => result.testCases[0].testSteps[0].prepare(undefined)).to.throw(AmbiguousError) + expect(() => result.testCases[0].testSteps[0].prepare()).to.throw(AmbiguousError) }) it('throws if a step is undefined', () => { @@ -169,7 +144,7 @@ describe('makeTestPlan', () => { ) try { - result.testCases[0].testSteps[0].prepare(undefined) + result.testCases[0].testSteps[0].prepare() } catch (err: any) { expect(err).to.be.instanceOf(UndefinedError) expect(err.pickleStep).to.eq(pickles[0].steps[0]) @@ -177,10 +152,7 @@ describe('makeTestPlan', () => { }) it('matches and prepares a step without parameters', () => { - let capturedThis: any - const fn = sinon.spy(function (this: any) { - capturedThis = this - }) + const fn = sinon.stub() const { gherkinDocument, pickles } = parseGherkin('minimal.feature', newId) const supportCodeLibrary = buildSupportCode({ newId }) @@ -198,19 +170,13 @@ describe('makeTestPlan', () => { } ) - const fakeWorld = new FakeWorld() - const prepared = result.testCases[0].testSteps[0].prepare(fakeWorld) + const prepared = result.testCases[0].testSteps[0].prepare() + expect(prepared.fn).to.eq(fn) expect(prepared.args).to.deep.eq([]) - prepared.fn() - expect(fn).to.have.been.calledWithExactly() - expect(capturedThis).to.eq(fakeWorld) }) it('matches and prepares a step with parameters', () => { - let capturedThis: any - const fn = sinon.spy(function (this: any) { - capturedThis = this - }) + const fn = sinon.stub() const { gherkinDocument, pickles } = parseGherkin('parameters.feature', newId) const supportCodeLibrary = buildSupportCode({ newId }) @@ -228,19 +194,13 @@ describe('makeTestPlan', () => { } ) - const fakeWorld = new FakeWorld() - const prepared = result.testCases[0].testSteps[0].prepare(fakeWorld) - expect(prepared.args).to.deep.eq([4, 5]) - prepared.fn(...prepared.args) - expect(fn).to.have.been.calledWithExactly(...prepared.args) - expect(capturedThis).to.eq(fakeWorld) + 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]) }) it('matches and prepares a step with a data table', () => { - let capturedThis: any - const fn = sinon.spy(function (this: any) { - capturedThis = this - }) + const fn = sinon.stub() const { gherkinDocument, pickles } = parseGherkin('datatable.feature', newId) const supportCodeLibrary = buildSupportCode({ newId }) @@ -258,24 +218,14 @@ describe('makeTestPlan', () => { } ) - const fakeWorld = new FakeWorld() - const prepared = result.testCases[0].testSteps[0].prepare(fakeWorld) - expect(prepared.args).to.deep.eq([ - new DataTable([ - ['a', 'b', 'c'], - ['1', '2', '3'], - ]), - ]) - prepared.fn(...prepared.args) - expect(fn).to.have.been.calledWithExactly(...prepared.args) - expect(capturedThis).to.eq(fakeWorld) + 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) }) it('matches and prepares a step with a doc string', () => { - let capturedThis: any - const fn = sinon.spy(function (this: any) { - capturedThis = this - }) + const fn = sinon.stub() const { gherkinDocument, pickles } = parseGherkin('docstring.feature', newId) const supportCodeLibrary = buildSupportCode({ newId }) @@ -293,12 +243,10 @@ describe('makeTestPlan', () => { } ) - const fakeWorld = new FakeWorld() - const prepared = result.testCases[0].testSteps[0].prepare(fakeWorld) - expect(prepared.args).to.deep.eq(['Hello world']) - prepared.fn(...prepared.args) - expect(fn).to.have.been.calledWithExactly(...prepared.args) - expect(capturedThis).to.eq(fakeWorld) + 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) }) }) @@ -472,11 +420,7 @@ describe('makeTestPlan', () => { }) it('prepares Before hooks for execution', () => { - let capturedThis: any - const fn = sinon.spy(function (this: any) { - capturedThis = this - }) - + const fn = sinon.stub() const { gherkinDocument, pickles } = parseGherkin('minimal.feature', newId) const supportCodeLibrary = buildSupportCode({ newId }) .beforeHook({ @@ -497,19 +441,13 @@ describe('makeTestPlan', () => { } ) - const fakeWorld = new FakeWorld() - const prepared = result.testCases[0].testSteps[0].prepare(fakeWorld) + const prepared = result.testCases[0].testSteps[0].prepare() + expect(prepared.fn).to.eq(fn) expect(prepared.args).to.deep.eq([]) - prepared.fn() - expect(fn).to.have.been.calledWithExactly() - expect(capturedThis).to.eq(fakeWorld) }) it('prepares After hooks for execution', () => { - let capturedThis: any - const fn = sinon.spy(function (this: any) { - capturedThis = this - }) + const fn = sinon.stub() const { gherkinDocument, pickles } = parseGherkin('minimal.feature', newId) const supportCodeLibrary = buildSupportCode({ newId }) @@ -531,12 +469,9 @@ describe('makeTestPlan', () => { } ) - const fakeWorld = new FakeWorld() - const prepared = result.testCases[0].testSteps[3].prepare(fakeWorld) + const prepared = result.testCases[0].testSteps[3].prepare() + expect(prepared.fn).to.eq(fn) expect(prepared.args).to.deep.eq([]) - prepared.fn() - expect(fn).to.have.been.calledWithExactly() - expect(capturedThis).to.eq(fakeWorld) }) }) diff --git a/src/makeTestPlan.ts b/src/makeTestPlan.ts index 3eea244..67534a5 100644 --- a/src/makeTestPlan.ts +++ b/src/makeTestPlan.ts @@ -17,7 +17,6 @@ import { } from '@cucumber/query' import { AmbiguousError } from './AmbiguousError' -import { DataTable } from './DataTable' import { AssembledTestPlan, AssembledTestStep, @@ -104,9 +103,9 @@ function fromBeforeHooks( location, }, always: false, - prepare(thisArg) { + prepare() { return { - fn: def.fn.bind(thisArg), + fn: def.fn, args: [], } }, @@ -141,9 +140,9 @@ function fromAfterHooks( location, }, always: true, - prepare(thisArg) { + prepare() { return { - fn: def.fn.bind(thisArg), + fn: def.fn, args: [], } }, @@ -177,7 +176,7 @@ function fromPickleSteps( location: step.location, }, always: false, - prepare(thisArg) { + prepare() { if (matched.length < 1) { throw new UndefinedError(pickleStep) } else if (matched.length > 1) { @@ -187,22 +186,11 @@ function fromPickleSteps( ) } else { const { def, args } = matched[0] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allArgs: Array = args.map((arg) => arg.getValue(thisArg)) - if (pickleStep.argument?.dataTable) { - allArgs.push( - new DataTable( - pickleStep.argument.dataTable.rows.map((row) => { - return row.cells.map((cell) => cell.value) - }) - ) - ) - } else if (pickleStep.argument?.docString) { - allArgs.push(pickleStep.argument.docString.content) - } return { - fn: def.fn.bind(thisArg), - args: allArgs, + fn: def.fn, + args, + dataTable: pickleStep.argument?.dataTable, + docString: pickleStep.argument?.docString, } } }, diff --git a/src/types.ts b/src/types.ts index 1cd60b1..ae68a93 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,8 @@ import { Hook, IdGenerator, Pickle, + PickleDocString, + PickleTable, SourceReference, StepDefinition, TestCase, @@ -402,22 +404,32 @@ export interface TestPlanOptions { } /** - * A function that has been validated and prepared for execution + * A step that has been validated and prepared for execution * @public * @remarks - * Depending on the characteristics of the Cucumber implementation, additional - * arguments may be prepended or appended to the arguments when passed to the - * function. + * Depending on the characteristics of the Cucumber implementation, the + * function may be called in a different way and with the args positioned + * differently. */ -export type PreparedFunction = { +export type PreparedStep = { /** - * The function that is ready to execute + * The user-authored function to be executed for this step */ fn: SupportCodeFunction /** - * The arguments to pass to the function + * The arguments to pass to the step, as cucumber-expressions argument objects */ - args: ReadonlyArray + args: ReadonlyArray + /** + * The data table to pass to the step, if there is one + * @remarks + * Use {@link DataTable.from} to turn this into a user-friendly object + */ + dataTable?: PickleTable + /** + * The doc string to pass to the step, if there is one + */ + docString?: PickleDocString } /** @@ -448,14 +460,15 @@ export interface AssembledTestStep { */ always: boolean /** - * Prepare the test step for execution and return the prepared function and arguments - * @param thisArg - the value to bound as `this` on the function + * Prepare the test step for execution and return the function and arguments * @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 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(thisArg?: unknown): PreparedFunction + prepare(): PreparedStep /** * Converts the step to a TestStep message */ diff --git a/test/parseGherkin.ts b/test/parseGherkin.ts new file mode 100644 index 0000000..17a98ba --- /dev/null +++ b/test/parseGherkin.ts @@ -0,0 +1,24 @@ +import { GherkinDocument, IdGenerator, Pickle } from '@cucumber/messages' +import fs from 'node:fs' +import path from 'node:path' +import { AstBuilder, compile, GherkinClassicTokenMatcher, Parser } from '@cucumber/gherkin' + +export function parseGherkin( + file: string, + newId: () => string = IdGenerator.uuid() +): { gherkinDocument: GherkinDocument; pickles: ReadonlyArray } { + const data = fs.readFileSync(path.join(__dirname, '..', 'testdata', file), { encoding: 'utf-8' }) + const builder = new AstBuilder(newId) + const matcher = new GherkinClassicTokenMatcher() + const parser = new Parser(builder, matcher) + const uri = 'features/' + file + const gherkinDocument = { + uri, + ...parser.parse(data), + } + const pickles = compile(gherkinDocument, uri, newId) + return { + gherkinDocument, + pickles, + } +}