playwright-bdd
Version:
BDD Testing with Playwright runner
379 lines (324 loc) • 11.4 kB
text/typescript
/**
* Generate Playwright test file for feature.
*/
/* eslint-disable max-lines, max-params */
import {
FeatureChild,
RuleChild,
Scenario,
Background,
Step,
Feature,
Rule,
} from '@cucumber/messages';
import path from 'node:path';
import { Formatter } from './formatter';
import { KeywordsMap, getKeywordsMap } from './i18n';
import parseTagsExpression from '@cucumber/tag-expressions';
import { LANG_EN, isEnglish } from '../config/lang';
import { GherkinDocumentWithPickles, PickleWithLocation } from '../gherkin/types';
import { BDDConfig } from '../config/types';
import { ImportTestGuesser } from './importTest';
import { GherkinDocumentQuery } from '../gherkin/documentQuery';
import { ExamplesTitleBuilder } from './examplesTitleBuilder';
import { getTagNames, isScenarioOutline } from '../gherkin/helpers';
import { StepFinder } from '../steps/finder';
import { exit } from '../utils/exit';
import { StepDefinition } from '../steps/stepDefinition';
import { TestFileHooks } from './hooks';
import { getSpecFileByFeatureFile } from './paths';
import { SpecialTags } from './specialTags';
import { BackgroundGen } from './background';
import { StepData, TestGen } from './test';
import { SourceMapper } from './sourceMapper';
import { BddDataRenderer } from '../bddData/renderer';
import { extractTagsFromPath } from '../steps/tags';
import { removeDuplicates } from '../utils';
import { supportedFeatures } from '../playwright/supportedFeatures';
type TestFileOptions = {
config: BDDConfig;
gherkinDocument: GherkinDocumentWithPickles;
tagsExpression?: ReturnType<typeof parseTagsExpression>;
};
export class TestFile {
private lines: string[] = [];
private i18nKeywordsMap?: KeywordsMap;
private formatter: Formatter;
private gherkinDocumentQuery: GherkinDocumentQuery;
private stepFinder: StepFinder;
private hooks: TestFileHooks;
private backgrounds: BackgroundGen[] = [];
private tests: TestGen[] = [];
private tagsFromPath: string[];
public outputPath: string;
constructor(private options: TestFileOptions) {
this.outputPath = getSpecFileByFeatureFile(this.config, this.featureUri);
this.formatter = new Formatter(options.config);
this.gherkinDocumentQuery = new GherkinDocumentQuery(this.gherkinDocument);
this.stepFinder = new StepFinder(options.config);
this.hooks = new TestFileHooks(this.formatter);
this.tagsFromPath = extractTagsFromPath(this.featureUri);
}
get gherkinDocument() {
return this.options.gherkinDocument;
}
/**
* Returns to feature file, relative to configDir.
* Separator is OS-specific (/ on Unix, \ on Windows).
*/
get featureUri() {
return this.gherkinDocument.uri!;
}
get pickles() {
return this.gherkinDocument.pickles;
}
get content() {
return this.lines.join('\n');
}
get language() {
return this.gherkinDocument.feature?.language || LANG_EN;
}
get isEnglish() {
return isEnglish(this.language);
}
get config() {
return this.options.config;
}
hasExecutableTests() {
return this.tests.some((test) => !test.skippedByTag);
}
build(): this {
if (!this.gherkinDocumentQuery.hasPickles()) return this;
this.loadI18nKeywords();
// important to calc suites first, b/c file header depends on used steps and used tags
const suites = this.renderRootSuite();
this.hooks.fillFromTests(this.tests);
// todo: use lines instead of this.lines,
// and pass them to updateTestLocations() explicitly
this.lines = [
...this.renderFileHeader(), // prettier-ignore
...suites,
];
this.renderInplaceBackgrounds();
this.lines.push(...this.renderTechnicalSection());
return this;
}
/**
* Collect missing steps for all tests in the feature file.
*/
getMissingSteps() {
return (
this.tests
// if user skipped scenario manually, don't report missing steps
.filter((test) => !test.skippedByTag)
.reduce<StepData[]>((acc, test) => {
test.stepsData.forEach((stepData) => {
if (!stepData.matchedDefinition) acc.push(stepData);
});
return acc;
}, [])
);
}
getUsedDefinitions() {
const usedDefinitions = new Set<StepDefinition>();
this.tests.forEach((test) => {
test.stepsData.forEach(({ matchedDefinition }) => {
if (matchedDefinition) usedDefinitions.add(matchedDefinition.definition);
});
});
return usedDefinitions;
}
private renderFileHeader() {
const importTestFrom = this.resolveImportTestFrom();
return this.formatter.fileHeader(this.featureUri, importTestFrom);
}
private loadI18nKeywords() {
if (!this.isEnglish) {
this.i18nKeywordsMap = getKeywordsMap(this.language);
}
}
private renderTechnicalSection() {
const worldFixtureName = this.getWorldFixtureName();
const sourceMapper = new SourceMapper(this.lines);
const bddDataRenderer = new BddDataRenderer(this.tests, sourceMapper);
const testUse = this.formatter.testUse([
...this.formatter.testFixture(),
...this.formatter.uriFixture(this.featureUri),
...bddDataRenderer.renderFixture(),
...(worldFixtureName ? this.formatter.worldFixture(worldFixtureName) : []),
...(supportedFeatures.ariaSnapshots && this.config.aiFix?.promptAttachment
? this.formatter.pageFixtureWithPromptAttachment()
: []),
]);
return [
'// == technical section ==', // prettier-ignore
'',
...this.hooks.render(),
...testUse,
'',
...bddDataRenderer.renderVariable(),
];
}
private renderRootSuite() {
const { feature } = this.gherkinDocument;
if (!feature) throw new Error(`Document without feature.`);
return this.renderDescribe(feature);
}
/**
* Generate test.describe suite for Feature or Rule
*/
private renderDescribe(feature: Feature | Rule) {
const specialTags = new SpecialTags(getTagNames(feature.tags));
const lines: string[] = [];
feature.children.forEach((child) => {
lines.push(...this.renderChild(child));
});
return this.formatter.describe(feature.name, specialTags, lines);
}
// eslint-disable-next-line visual/complexity
private renderChild(child: FeatureChild | RuleChild) {
if ('rule' in child && child.rule) return this.renderDescribe(child.rule);
if (child.background) return this.renderBackgroundPlaceholder(child.background);
if (child.scenario)
return isScenarioOutline(child.scenario) // prettier-ignore
? this.renderScenarioOutline(child.scenario)
: this.renderScenario(child.scenario);
throw new Error(`Empty child: ${JSON.stringify(child)}`);
}
private renderBackgroundPlaceholder(bg: Background) {
const bgGen = new BackgroundGen(this.formatter, this.i18nKeywordsMap, bg);
this.backgrounds.push(bgGen);
return [bgGen.placeholder];
}
/**
* Insert test.beforeEach for Backgrounds
*/
private renderInplaceBackgrounds() {
this.backgrounds.forEach((bg) => bg.renderInplace(this.lines));
}
private renderScenario(scenario: Scenario) {
const testTitle = scenario.name;
const pickle = this.findPickle(scenario.id, testTitle);
const ownTestTags = getTagNames(scenario.tags);
return this.renderTest(pickle, testTitle, ownTestTags, scenario.steps);
}
/**
* Generate test.describe suite for Scenario Outline
*/
private renderScenarioOutline(scenario: Scenario) {
const specialTags = new SpecialTags(getTagNames(scenario.tags));
const examplesTitleBuilder = this.createExamplesTitleBuilder(scenario);
const lines: string[] = [];
scenario.examples.forEach((examples) => {
examples.tableBody.forEach((exampleRow) => {
const testTitle = examplesTitleBuilder.buildTitle(examples, exampleRow);
const pickle = this.findPickle(exampleRow.id, testTitle);
const ownTestTags = getTagNames(examples.tags);
const testLines = this.renderTest(pickle, testTitle, ownTestTags, scenario.steps);
lines.push(...testLines);
});
});
// don't render describe without tests
if (!lines.length) return [];
return this.formatter.describe(scenario.name, specialTags, lines);
}
/**
* Render test for Scenario or Scenario Outline.
*/
private renderTest(
pickle: PickleWithLocation,
testTitle: string,
ownTestTags: string[],
gherkinSteps: readonly Step[],
) {
const testTags = removeDuplicates([...this.tagsFromPath, ...getTagNames(pickle.tags)]);
if (this.isSkippedByTagsExpression(testTags)) return [];
const test = new TestGen(
this.config,
this.featureUri,
this.i18nKeywordsMap,
this.stepFinder,
this.formatter,
this.backgrounds,
pickle,
testTitle,
gherkinSteps,
testTags,
ownTestTags,
);
this.tests.push(test);
return test.render();
}
private findPickle(astNodeId: string, testTitle: string) {
const pickles = this.gherkinDocumentQuery.getPickles(astNodeId);
const pickle = pickles[0];
if (!pickle) {
exit(`Pickle not found for scenario: ${testTitle}`);
} else if (pickles.length > 1) {
exit(`Found ${pickles.length} pickles for scenario: ${testTitle}`);
}
return pickle;
}
private isSkippedByTagsExpression(tags: string[]) {
// see: https://github.com/cucumber/tag-expressions/tree/main/javascript
const { tagsExpression } = this.options;
return tagsExpression && !tagsExpression.evaluate(tags);
}
private getWorldFixtureName() {
const worldFixtureNames = new Set<string>();
this.tests.forEach((test) => {
test.stepsData.forEach(({ matchedDefinition }) => {
if (matchedDefinition) {
const { worldFixture } = matchedDefinition.definition;
if (worldFixture) worldFixtureNames.add(worldFixture);
}
});
});
this.hooks.getWorldFixtureNames().forEach((name) => worldFixtureNames.add(name));
if (worldFixtureNames.size > 1) {
throw new Error(
[
`All steps and hooks in a feature file should have the same worldFixture.`,
`Found fixtures: ${[...worldFixtureNames].join(', ')}`,
`File: ${this.featureUri}`,
].join('\n'),
);
}
return [...worldFixtureNames][0];
}
private createExamplesTitleBuilder(scenario: Scenario) {
return new ExamplesTitleBuilder({
config: this.config,
gherkinDocument: this.gherkinDocument,
isEnglish: this.isEnglish,
scenario,
});
}
private resolveImportTestFrom() {
let { importTestFrom } = this.config;
if (!importTestFrom) {
importTestFrom = new ImportTestGuesser(
this.featureUri,
this.getUsedDefinitions(),
this.getUsedPomFixtures(),
this.hooks.getCustomTestInstances(),
).guess();
}
if (!importTestFrom) return;
const { file, varName } = importTestFrom;
const dir = path.dirname(this.outputPath);
return {
file: path.relative(dir, file),
varName,
};
}
private getUsedPomFixtures() {
const usedPomFixtures = new Set<string>();
this.tests.forEach((test) => {
test.stepsData.forEach(({ pomFixtureName }) => {
if (pomFixtureName) usedPomFixtures.add(pomFixtureName);
});
});
return usedPomFixtures;
}
}