/** * 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 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; 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.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()); 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 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 = `${fg.convertPathToPattern(this.config.outputDir)}/**/*.spec.js`; const testFiles = await fg(pattern); 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); } } }