playwright-bdd
Version:
BDD Testing with Playwright runner
159 lines (144 loc) • 5.56 kB
text/typescript
/**
* Generate playwright test files from Gherkin documents.
*/
import fs from 'node:fs';
import tg from 'tinyglobby';
import { TestFile } from './file';
import { FeaturesLoader, resolveFeatureFiles } from '../gherkin/featuresLoader';
import { Snippets } from '../snippets';
import { parse as parseTagsExpression } from '@cucumber/tag-expressions';
import { exit } from '../utils/exit';
import { loadSteps, loadStepsFromFile, resolveStepFiles } from '../steps/loader';
import { relativeToCwd } 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';
import { TestFilesGeneratorLogger } from './logger';
export class TestFilesGenerator {
private featuresLoader = new FeaturesLoader();
private files: TestFile[] = [];
private tagsExpression?: ReturnType<typeof parseTagsExpression>;
private logger: TestFilesGeneratorLogger;
constructor(private config: BDDConfig) {
this.logger = new TestFilesGeneratorLogger({ verbose: config.verbose });
if (config.tags) this.tagsExpression = parseTagsExpression(config.tags);
}
async generate() {
await Promise.all([this.loadFeatures(), this.loadSteps()]);
this.buildFiles();
this.checkStepDefinitionArityErrors();
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.logLoadingFeatures(files, finalPatterns);
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.logLoadingSteps(files, finalPatterns);
await loadSteps(files);
this.logger.logLoadedSteps(files, stepDefinitions);
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 checkStepDefinitionArityErrors() {
const stepDefinitionArityErrors = this.files.flatMap((file) =>
file.getStepDefinitionArityErrors(),
);
if (stepDefinitionArityErrors.length) {
const uniqueErrors = [...new Set(stepDefinitionArityErrors)];
exit(
[
`Found step definitions with incorrect arguments: ${uniqueErrors.length}`,
uniqueErrors.join('\n\n'),
].join('\n'),
);
}
}
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.files.forEach((file) => saveFileSync(file.outputPath, file.content));
this.logger.logGeneratedTestFiles(this.files);
}
private async clearOutputDir() {
const pattern = `${tg.convertPathToPattern(this.config.outputDir)}/**/*.spec.js`;
const testFiles = await tg.glob(pattern, { expandDirectories: false });
this.logger.logClearingOutputDir(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);
}
}
}