playwright-bdd
Version:
BDD Testing with Playwright runner
161 lines (146 loc) • 5.8 kB
text/typescript
/**
* Generate playwright test files from Gherkin documents.
*/
import fs from 'node:fs';
import fg from 'fast-glob';
import { TestFile } from './file';
import { FeaturesLoader, resolveFeatureFiles } from '../gherkin/featuresLoader';
import { Snippets } from '../snippets';
import { Logger } from '../utils/logger';
import parseTagsExpression from '@cucumber/tag-expressions';
import { exit } from '../utils/exit';
import { loadSteps, loadStepsFromFile, resolveStepFiles } from '../steps/loader';
import { relativeToCwd, toPosixPath } from '../utils/paths';
import { BDDConfig } from '../config/types';
import { stepDefinitions } from '../steps/stepRegistry';
import { saveFileSync } from '../utils';
import { StepDefinition } from '../steps/stepDefinition';
import { StepData } from './test';
export class TestFilesGenerator {
private featuresLoader = new FeaturesLoader();
private files: TestFile[] = [];
private tagsExpression?: ReturnType<typeof parseTagsExpression>;
private logger: Logger;
constructor(private config: BDDConfig) {
this.logger = new Logger({ verbose: config.verbose });
if (config.tags) this.tagsExpression = parseTagsExpression(config.tags);
}
async generate() {
await Promise.all([this.loadFeatures(), this.loadSteps()]);
this.buildFiles();
this.checkMissingSteps();
await this.clearOutputDir();
await this.saveFiles();
}
async extractSteps() {
await this.loadSteps();
return stepDefinitions;
}
// todo: combine with extractSteps
async extractUnusedSteps() {
await Promise.all([this.loadFeatures(), this.loadSteps()]);
this.buildFiles();
const allUsedDefinitions = this.files.reduce((acc, file) => {
file.getUsedDefinitions().forEach((definition) => acc.add(definition));
return acc;
}, new Set<StepDefinition>());
return stepDefinitions.filter((stepDefinition) => {
return !allUsedDefinitions.has(stepDefinition);
});
}
private async loadFeatures() {
const { configDir, features, language } = this.config;
const { files, finalPatterns } = await resolveFeatureFiles(configDir, features);
this.logger.log(`Loading features: ${finalPatterns.join(', ')}`);
this.logger.log(`Found feature files: ${files.length}`);
files.forEach((featureFile) => this.logger.log(` - ${relativeToCwd(featureFile)}`));
await this.featuresLoader.load(files, {
relativeTo: configDir,
defaultDialect: language,
});
this.handleFeatureParseErrors();
}
private async loadSteps() {
const { configDir, steps } = this.config;
const { files, finalPatterns } = await resolveStepFiles(configDir, steps);
this.logger.log(`Loading steps: ${finalPatterns.join(', ')}`);
this.logger.log(`Found step files: ${files.length}`);
await loadSteps(files);
this.printFoundSteps(files);
await this.handleImportTestFrom();
}
private buildFiles() {
this.files = this.featuresLoader
.getDocumentsWithPickles()
.map((gherkinDocument) => {
return new TestFile({
config: this.config,
gherkinDocument,
tagsExpression: this.tagsExpression,
}).build();
})
// render only files that have at least one executable test
.filter((file) => file.hasExecutableTests());
}
private checkMissingSteps() {
if (this.config.missingSteps !== 'fail-on-gen') return;
const missingSteps: StepData[] = [];
this.files.forEach((file) => missingSteps.push(...file.getMissingSteps()));
if (missingSteps.length) {
new Snippets(missingSteps).print();
exit();
}
}
private async handleImportTestFrom() {
const { importTestFrom } = this.config;
if (!importTestFrom) return;
// require importTestFrom separately instead of just adding to steps,
// b/c we need additionally check that it exports "test" variable.
// If checking by together loaded files, it's become messy to find correct file by url,
// b/c of win/posix path separator.
const exportedTests = await loadStepsFromFile(importTestFrom.file);
const varName = importTestFrom.varName || 'test';
if (!exportedTests.find((info) => info.varName === varName)) {
exit(
`File "${relativeToCwd(importTestFrom.file)}" pointed by "importTestFrom"`,
`should export "${varName}" variable.`,
);
}
}
private async saveFiles() {
this.logger.log(`Generating Playwright test files: ${this.files.length}`);
this.files.forEach((file) => {
saveFileSync(file.outputPath, file.content);
this.logger.log(` - ${relativeToCwd(file.outputPath)}`);
});
}
private async clearOutputDir() {
const pattern = `${fg.convertPathToPattern(this.config.outputDir)}/**/*.spec.js`;
const testFiles = await fg(pattern);
this.logger.log(`Clearing output dir: ${relativeToCwd(pattern)}`);
const tasks = testFiles.map((testFile) => fs.promises.rm(testFile));
await Promise.all(tasks);
}
private handleFeatureParseErrors() {
const { parseErrors } = this.featuresLoader;
if (parseErrors.length) {
const message = parseErrors
.map((parseError) => {
return `Parse error in "${parseError.source.uri}" ${parseError.message}`;
})
.join('\n');
exit(message);
}
}
private printFoundSteps(files: string[]) {
if (!this.config.verbose) return;
files.forEach((stepFile) => {
const normalizedStepFile = toPosixPath(stepFile);
const definitions = stepDefinitions.filter(
(definition) => toPosixPath(definition.uri) === normalizedStepFile,
);
const suffix = definitions.length === 1 ? 'step' : 'steps';
this.logger.log(` - ${relativeToCwd(stepFile)} (${definitions.length} ${suffix})`);
});
}
}