Skip to content

Commit

Permalink
new config option matchKeywords to enable keyword matching when searc…
Browse files Browse the repository at this point in the history
…hing for step definitions (#221)
  • Loading branch information
vitalets committed Oct 22, 2024
1 parent b2532f4 commit 4d06295
Show file tree
Hide file tree
Showing 22 changed files with 269 additions and 111 deletions.
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
## Dev
* feature: provide full localized step titles to Playwright HTML reporter ([#229](https://github.com/vitalets/playwright-bdd/issues/229), [#122](https://github.com/vitalets/playwright-bdd/issues/122))
* show background title in Playwright HTML reporter ([#122](https://github.com/vitalets/playwright-bdd/issues/122))
* new config option `missingSteps` to setup different behavior when missing step definitions detected ([#158](https://github.com/vitalets/playwright-bdd/issues/158))
* make config option `quote` default to `"single"` to have less escapes in generated files
* new config option `missingSteps` to setup different behavior when step definitions are missing ([#158](https://github.com/vitalets/playwright-bdd/issues/158))
* new config option `matchKeywords` to enable keyword matching when searching for step definitions ([#221](https://github.com/vitalets/playwright-bdd/issues/221))
* make config option `quote` default to `"single"` to have less escapes in the generated files
* increase minimal Playwright version from 1.35 to 1.38

## 7.5.0
Expand Down
5 changes: 4 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export default [
'no-console': 'error',
'no-undef': 0,
'no-empty-pattern': 0,
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' },
],
'@typescript-eslint/no-require-imports': 0,
},
},
Expand Down
2 changes: 2 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type BDDInputConfig = CucumberConfigDeprecated & {
disableWarnings?: DisableWarningsConfig;
/** Behavior for missing step definitions */
missingSteps?: 'fail-on-gen' | 'fail-on-run' | 'skip-scenario';
/** Enables additional matching by keywords in step definitions */
matchKeywords?: boolean;
};

export type BDDConfig = BDDInputConfig &
Expand Down
8 changes: 2 additions & 6 deletions src/features/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { Step, PickleStep } from '@cucumber/messages';

export function getStepTextWithKeyword(scenarioStep: Step | undefined, pickleStep: PickleStep) {
// fallback to empty keyword if scenario step is not provided
const keyword = scenarioStep?.keyword || '';
export function getStepTextWithKeyword(keyword: string | undefined, pickleStepText: string) {
// There is no full original step text in gherkin document.
// Build it by concatenation of keyword and pickle text.
// Cucumber html-formatter does the same:
// https://github.com/cucumber/react-components/blob/27b02543a5d7abeded3410a58588ee4b493b4a8f/src/components/gherkin/GherkinStep.tsx#L114
return `${keyword}${pickleStep.text}`;
return `${keyword || ''}${pickleStepText}`;
}
38 changes: 21 additions & 17 deletions src/gen/bddMetaBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/* eslint max-len: ['error', { code: 130 }] */
/**
* Class to build and print meta - an object containing meta info about each test.
* Tests are identified by special key constructed from title path.
*
* Example:
* const bddFileMeta = {
* "Simple scenario": { pickleLocation: "3:10", tags: ["@foo"], pickleSteps: ["Given step"] },
* "Scenario with examples|Example #1": { pickleLocation: "8:26", tags: [], pickleSteps: ["Given step"] },
* "Rule 1|Scenario with examples|Example #1": { pickleLocation: "9:42", tags: [], pickleSteps: ["Given step"] },
* "Simple scenario": { pickleLocation: "3:10", tags: ["@foo"], pickleSteps: [["Given ", "precondition"]] },
* "Scenario with examples|Example #1": { pickleLocation: "8:26", tags: [], pickleSteps: [["Given ", "precondition"]] },
* "Rule 1|Scenario with examples|Example #1": { pickleLocation: "9:42", tags: [], pickleSteps: [["Given ", "precondition"]] },
* };
*/

Expand All @@ -17,7 +18,7 @@ import { stringifyLocation } from '../utils';
import { GherkinDocumentQuery } from '../features/documentQuery';
import { indent } from './formatter';
import { PickleWithLocation } from '../features/types';
import { getStepTextWithKeyword } from '../features/helpers';
import { KeywordType } from '../cucumber/keywordType';

const TEST_KEY_SEPARATOR = '|';

Expand All @@ -27,17 +28,23 @@ export type BddTestMeta = {
pickleLocation: string;
tags?: string[];
ownTags?: string[];
pickleSteps: string[]; // array of step titles with keyword (including bg steps)
pickleSteps: BddStepMeta[]; // array of steps meta (including bg)
};

type BddStepMeta = [
string /* original keyword*/,
KeywordType,
string /* step location 'line:col' */,
];

type BddTestData = {
pickle: PickleWithLocation;
node: TestNode;
};

export class BddMetaBuilder {
private tests: BddTestData[] = [];
private pickleStepToScenarioStep = new Map<messages.PickleStep, messages.Step>();
private stepsMap = new Map<messages.PickleStep, BddStepMeta>();

constructor(private gherkinDocumentQuery: GherkinDocumentQuery) {}

Expand All @@ -53,10 +60,11 @@ export class BddMetaBuilder {
this.tests.push({ node, pickle: pickles[0] });
}

registerStep(scenarioStep: messages.Step) {
registerStep(scenarioStep: messages.Step, keywordType: KeywordType) {
this.gherkinDocumentQuery.getPickleSteps(scenarioStep.id).forEach((pickleStep) => {
// map each pickle step to scenario step to get full original step later
this.pickleStepToScenarioStep.set(pickleStep, scenarioStep);
const stepLocation = stringifyLocation(scenarioStep.location);
this.stepsMap.set(pickleStep, [scenarioStep.keyword, keywordType, stepLocation]);
});
}

Expand All @@ -82,24 +90,20 @@ export class BddMetaBuilder {
});
}

private buildTestKey(node: TestNode) {
// .slice(1) -> b/c we remove top describe title (it's same for all tests)
return node.titlePath.slice(1).join(TEST_KEY_SEPARATOR);
}

private buildTestMeta({ node, pickle }: BddTestData): BddTestMeta {
return {
pickleLocation: stringifyLocation(pickle.location),
tags: node.tags.length ? node.tags : undefined,
// todo: avoid duplication of tags and ownTags
ownTags: node.ownTags.length ? node.ownTags : undefined,
pickleSteps: pickle.steps.map((pickleStep) => this.buildStepTitleWithKeyword(pickleStep)),
// all pickle steps should be registered to that moment
pickleSteps: pickle.steps.map((pickleStep) => this.stepsMap.get(pickleStep)!),
};
}

private buildStepTitleWithKeyword(pickleStep: messages.PickleStep) {
const scenarioStep = this.pickleStepToScenarioStep.get(pickleStep);
return getStepTextWithKeyword(scenarioStep, pickleStep);
private buildTestKey(node: TestNode) {
// .slice(1) -> b/c we remove top describe title (it's same for all tests)
return node.titlePath.slice(1).join(TEST_KEY_SEPARATOR);
}
}

Expand Down
27 changes: 22 additions & 5 deletions src/gen/testFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import path from 'node:path';
import { Formatter } from './formatter';
import { KeywordsMap, getKeywordsMap } from './i18n';
import { throwIf } from '../utils';
import { stringifyLocation, throwIf } from '../utils';
import parseTagsExpression from '@cucumber/tag-expressions';
import { TestNode } from './testNode';
import { isCucumberStyleStep, isDecorator } from '../steps/stepConfig';
Expand All @@ -30,7 +30,7 @@ import { BddMetaBuilder } from './bddMetaBuilder';
import { GherkinDocumentWithPickles } from '../features/types';
import { DecoratorSteps } from './decoratorSteps';
import { BDDConfig } from '../config/types';
import { StepDefinition, findStepDefinition } from '../steps/registry';
import { StepDefinition } from '../steps/registry';
import { KeywordType, mapStepsToKeywordTypes } from '../cucumber/keywordType';
import { ImportTestFromGuesser } from './importTestFrom';
import { isBddAutoInjectFixture } from '../run/bddFixtures/autoInject';
Expand All @@ -40,6 +40,8 @@ import { GherkinDocumentQuery } from '../features/documentQuery';
import { ExamplesTitleBuilder } from './examplesTitleBuilder';
import { MissingStep } from '../snippets/types';
import { getStepTextWithKeyword } from '../features/helpers';
import { formatDuplicateStepsMessage, StepFinder } from '../steps/finder';
import { exit } from '../utils/exit';

type TestFileOptions = {
gherkinDocument: GherkinDocumentWithPickles;
Expand All @@ -55,6 +57,7 @@ export class TestFile {
private usedDecoratorFixtures = new Set<string>();
private gherkinDocumentQuery: GherkinDocumentQuery;
private bddMetaBuilder: BddMetaBuilder;
private stepFinder: StepFinder;

public missingSteps: MissingStep[] = [];
public featureUri: string;
Expand All @@ -65,6 +68,7 @@ export class TestFile {
this.gherkinDocumentQuery = new GherkinDocumentQuery(this.gherkinDocument);
this.bddMetaBuilder = new BddMetaBuilder(this.gherkinDocumentQuery);
this.featureUri = this.getFeatureUri();
this.stepFinder = new StepFinder(options.config);
}

get gherkinDocument() {
Expand Down Expand Up @@ -282,10 +286,10 @@ export class TestFile {
const keywordType = stepToKeywordType.get(step)!;
const keywordEng = this.getStepEnglishKeyword(step);
testFixtureNames.add(keywordEng);
this.bddMetaBuilder.registerStep(step);
this.bddMetaBuilder.registerStep(step, keywordType);
// pickleStep contains step text with inserted example values and argument
const pickleStep = this.findPickleStep(step, outlineExampleRowId);
const stepDefinition = findStepDefinition(pickleStep.text, this.featureUri);
const stepDefinition = this.findStepDefinition(keywordType, step, pickleStep);
if (!stepDefinition) {
hasMissingSteps = true;
return this.handleMissingStep(keywordEng, keywordType, pickleStep, step);
Expand Down Expand Up @@ -338,6 +342,19 @@ export class TestFile {
return { fixtures: testFixtureNames, lines, hasMissingSteps };
}

private findStepDefinition(keywordType: KeywordType, scenarioStep: Step, pickleStep: PickleStep) {
const stepDefinitions = this.stepFinder.findDefinitions(keywordType, pickleStep.text);

if (stepDefinitions.length > 1) {
const stepTextWithKeyword = getStepTextWithKeyword(scenarioStep.keyword, pickleStep.text);
const stepLocation = `${this.featureUri}:${stringifyLocation(scenarioStep.location)}`;
// todo: maybe not exit and collect all duplicates?
exit(formatDuplicateStepsMessage(stepDefinitions, stepTextWithKeyword, stepLocation));
}

return stepDefinitions[0];
}

private handleMissingStep(
keywordEng: StepKeyword,
keywordType: KeywordType,
Expand All @@ -347,7 +364,7 @@ export class TestFile {
const { line, column } = step.location;
this.missingSteps.push({
location: { uri: this.featureUri, line, column },
textWithKeyword: getStepTextWithKeyword(step, pickleStep),
textWithKeyword: getStepTextWithKeyword(step.keyword, pickleStep.text),
keywordType,
pickleStep,
});
Expand Down
53 changes: 34 additions & 19 deletions src/run/StepInvoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { PickleStepArgument } from '@cucumber/messages';
import { getLocationInFile } from '../playwright/getLocationInFile';
import { DataTable } from '../cucumber/DataTable';
import { getBddAutoInjectFixtures } from './bddFixtures/autoInject';
import { StepDefinition, findStepDefinition } from '../steps/registry';
import { StepDefinition } from '../steps/registry';
import { runStepWithLocation } from '../playwright/runStepWithLocation';
import { BddContext } from './bddFixtures/test';
import { StepKeyword } from '../steps/types';
import { formatDuplicateStepsMessage, StepFinder } from '../steps/finder';
import { getStepTextWithKeyword } from '../features/helpers';

export type StepKeywordFixture = ReturnType<typeof createStepInvoker>;

Expand All @@ -19,11 +21,10 @@ export function createStepInvoker(bddContext: BddContext, _keyword: StepKeyword)
}

class StepInvoker {
constructor(
private bddContext: BddContext,
// keyword in not needed now, b/c we can get all info about step from test meta
// private keyword: StepKeyword,
) {}
private stepFinder: StepFinder;
constructor(private bddContext: BddContext) {
this.stepFinder = new StepFinder(bddContext.config);
}

/**
* Invokes particular step.
Expand All @@ -37,13 +38,13 @@ class StepInvoker {
this.bddContext.stepIndex++;
this.bddContext.step.title = stepText;

const stepDefinition = this.getStepDefinition(stepText);
const stepDefinition = this.findStepDefinition(stepText);
const stepTextWithKeyword = this.getStepTextWithKeyword(stepText);

// Get location of step call in generated test file.
// This call must be exactly here to have correct call stack (before async calls)
const location = getLocationInFile(this.bddContext.testInfo.file);

const stepTitleWithKeyword = this.getStepTitleWithKeyword();
const parameters = await this.getStepParameters(
stepDefinition,
stepText,
Expand All @@ -54,7 +55,7 @@ class StepInvoker {

this.bddContext.bddAnnotation?.registerStep(stepDefinition, stepText, location);

await runStepWithLocation(this.bddContext.test, stepTitleWithKeyword, location, () => {
await runStepWithLocation(this.bddContext.test, stepTextWithKeyword, location, () => {
// Although pw-style does not expect usage of world / this in steps,
// some projects request it for better migration process from cucumber.
// Here, for pw-style we pass empty object as world.
Expand All @@ -63,18 +64,27 @@ class StepInvoker {
});
}

private getStepDefinition(text: string) {
const stepDefinition = findStepDefinition(
text,
// todo: change to feature uri
this.bddContext.testInfo.file,
);
private findStepDefinition(stepText: string) {
const [_keyword, keywordType, stepLocation] = this.getStepMeta();
const stepDefinitions = this.stepFinder.findDefinitions(keywordType, stepText);

if (stepDefinitions.length === 1) return stepDefinitions[0];

const stepTextWithKeyword = this.getStepTextWithKeyword(stepText);
const fullStepLocation = `${this.bddContext.featureUri}:${stepLocation}`;

if (!stepDefinition) {
throw new Error(`Missing step: ${text}`);
if (stepDefinitions.length === 0) {
// todo: better error?
const message = `Missing step: ${stepTextWithKeyword}`;
throw new Error(message);
}

return stepDefinition;
const message = formatDuplicateStepsMessage(
stepDefinitions,
stepTextWithKeyword,
fullStepLocation,
);
throw new Error(message);
}

private async getStepParameters(
Expand All @@ -99,7 +109,12 @@ class StepInvoker {
return parameters;
}

private getStepTitleWithKeyword() {
private getStepTextWithKeyword(stepText: string) {
const [keyword] = this.getStepMeta();
return getStepTextWithKeyword(keyword, stepText);
}

private getStepMeta() {
const { stepIndex, bddTestMeta } = this.bddContext;
return bddTestMeta.pickleSteps[stepIndex];
}
Expand Down
2 changes: 2 additions & 0 deletions src/run/bddFixtures/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type BddFixturesTest = {

export type BddContext = {
config: BDDConfig;
featureUri: string;
test: TestTypeCommon;
testInfo: TestInfo;
tags: string[];
Expand Down Expand Up @@ -83,6 +84,7 @@ export const test = base.extend<BddFixturesTest>({

await use({
config: $bddConfig,
featureUri: $uri,
testInfo,
test: $test,
tags: $tags,
Expand Down
Loading

0 comments on commit 4d06295

Please sign in to comment.