UNPKG

@wdio/cucumber-framework

Version:
519 lines (515 loc) 17.8 kB
// src/index.ts import os from "node:os"; import url from "node:url"; import path2 from "node:path"; import fs from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { EventEmitter } from "node:events"; import { Writable } from "node:stream"; import isGlob from "is-glob"; import { sync as globSync } from "glob"; import logger2 from "@wdio/logger"; import { executeHooksWithArgs, testFnWrapper } from "@wdio/utils"; import { setDefaultTimeout, setDefinitionFunctionWrapper, supportCodeLibraryBuilder, Status } from "@cucumber/cucumber"; import Gherkin from "@cucumber/gherkin"; import { IdGenerator } from "@cucumber/messages"; import { loadConfiguration, loadSources, runCucumber } from "@cucumber/cucumber/api"; // src/constants.ts var DEFAULT_TIMEOUT = 6e4; var DEFAULT_OPTS = { paths: [], backtrace: false, dryRun: false, forceExit: false, failFast: false, format: [], formatOptions: {}, import: [], language: "en", name: [], order: "defined", publish: false, require: [], requireModule: [], retry: 0, strict: false, tags: "", worldParameters: {}, timeout: DEFAULT_TIMEOUT, scenarioLevelReporter: false, tagsInTitle: false, ignoreUndefinedDefinitions: false, failAmbiguousDefinitions: false, tagExpression: "", profiles: [], file: void 0 }; var CUCUMBER_HOOK_DEFINITION_TYPES = [ "beforeTestRunHookDefinitionConfigs", "beforeTestCaseHookDefinitionConfigs", "afterTestCaseHookDefinitionConfigs", "afterTestRunHookDefinitionConfigs" ]; // src/utils.ts import path from "node:path"; import logger from "@wdio/logger"; import { isFunctionAsync } from "@wdio/utils"; var log = logger("@wdio/cucumber-framework:utils"); function generateSkipTagsFromCapabilities(capabilities, tags) { const generatedTags = []; const skipTag = /^@skip$|^@skip\((.*)\)$/; const match = (value, expr) => { if (Array.isArray(expr)) { return expr.indexOf(value) >= 0; } else if (expr instanceof RegExp) { return expr.test(value); } return (expr && ("" + expr).toLowerCase()) === (value && ("" + value).toLowerCase()); }; const parse = (skipExpr) => skipExpr.split(";").reduce((acc, splitItem) => { const pos = splitItem.indexOf("="); if (pos > 0) { try { acc[splitItem.substring(0, pos)] = (0, eval)( splitItem.substring(pos + 1) ); } catch { log.error(`Couldn't use tag "${splitItem}" for filtering because it is malformed`); } } return acc; }, {}); tags.flat(1).forEach((tag) => { const matched = tag.match(skipTag); if (matched) { const isSkip = [parse(matched[1] ?? "")].find((filter) => Object.keys(filter).every((key) => match(capabilities[key], filter[key]))); if (isSkip) { generatedTags.push(`(not ${tag.replace(/[()\\]/g, "\\$&")})`); } } }); return generatedTags; } function setUserHookNames(options) { CUCUMBER_HOOK_DEFINITION_TYPES.forEach((hookName) => { options[hookName].forEach((testRunHookDefinition) => { const hookFn = testRunHookDefinition.code; if (!hookFn.name.startsWith("wdioHook")) { const userHookAsyncFn = async function(...args) { return hookFn.apply(this, args); }; const userHookFn = function(...args) { return hookFn.apply(this, args); }; testRunHookDefinition.code = isFunctionAsync(hookFn) ? userHookAsyncFn : userHookFn; } }); }); } // src/index.ts export * from "@cucumber/cucumber"; var FILE_PROTOCOL = "file://"; var uuidFn = IdGenerator.uuid(); var log2 = logger2("@wdio/cucumber-framework"); var require2 = createRequire(import.meta.url); var __dirname = path2.dirname(url.fileURLToPath(import.meta.url)); function getResultObject(world) { return { passed: world.result?.status === Status.PASSED || world.result?.status === Status.SKIPPED, error: world.result?.message, duration: world.result?.duration?.nanos / 1e6 // convert into ms }; } var CucumberAdapter = class { constructor(_cid, _config, _specs, _capabilities, _reporter, _eventEmitter, _generateSkipTags = true, _cucumberFormatter = url.pathToFileURL(path2.resolve(__dirname, "cucumberFormatter.js")).href) { this._cid = _cid; this._config = _config; this._specs = _specs; this._capabilities = _capabilities; this._reporter = _reporter; this._eventEmitter = _eventEmitter; this._generateSkipTags = _generateSkipTags; this._cucumberFormatter = _cucumberFormatter; this._eventEmitter = new EventEmitter(); this._cucumberOpts = Object.assign( {}, DEFAULT_OPTS, this._config.cucumberOpts ); if (this._config.cucumberOpts?.parallel) { throw new Error('The option "parallel" is not supported by WebdriverIO'); } this._cucumberOpts.format.push([this._cucumberFormatter]); this._cucumberOpts.formatOptions = { // We need to pass the user provided Formatter options // Example: JUnit formatter https://github.com/cucumber/cucumber-js/blob/3a945b1077d4539f8a363c955a0506e088ff4271/docs/formatters.md#junit // { junit: { suiteName: "MySuite" } } ...this._cucumberOpts.formatOptions ?? {}, // Our Cucumber Formatter options // Put last so that user does not override them _reporter: this._reporter, _cid: this._cid, _specs: this._specs, _eventEmitter: this._eventEmitter, _scenarioLevelReporter: this._cucumberOpts.scenarioLevelReporter, _tagsInTitle: this._cucumberOpts.tagsInTitle, _ignoreUndefinedDefinitions: this._cucumberOpts.ignoreUndefinedDefinitions, _failAmbiguousDefinitions: this._cucumberOpts.failAmbiguousDefinitions }; const builder = new Gherkin.AstBuilder(uuidFn); const matcher = new Gherkin.GherkinClassicTokenMatcher( this._cucumberOpts.language ); this.gherkinParser = new Gherkin.Parser(builder, matcher); this._specs = this._specs.map( (spec) => spec.startsWith(FILE_PROTOCOL) ? url.fileURLToPath(spec) : spec ); this._cucumberOpts.tags = this._cucumberOpts.tags || this._cucumberOpts.tagExpression; if (this._cucumberOpts.tagExpression) { log2.warn("'tagExpression' is deprecated. Use 'tags' instead."); } } _cwd = process.cwd(); _newId = IdGenerator.incrementing(); _cucumberOpts; _hasTests = true; gherkinParser; readFiles(filePaths = []) { return filePaths.map((filePath) => { return Array.isArray(filePath) ? filePath.map( (file) => fs.readFileSync(path2.resolve(file), "utf8") ) : fs.readFileSync(path2.resolve(filePath), "utf8"); }); } getGherkinDocuments(files = []) { return this.readFiles(files).map((specContent, idx) => { const docs = [specContent].flat(1).map( (content, ctIdx) => ({ ...this.gherkinParser.parse(content), uri: Array.isArray(specContent) ? files[idx][ctIdx] : files[idx] }) ); const [doc, ...etc] = docs; return etc.length ? docs : doc; }); } generateDynamicSkipTags() { return this.getGherkinDocuments([this._specs]).map((specDoc) => { const [doc] = [specDoc].flat(1); const pickles = Gherkin.compile(doc, "", uuidFn); const tags = pickles.map((pickle) => pickle.tags.map((tag) => tag.name)); const generatedTag = generateSkipTagsFromCapabilities(this._capabilities, tags); return generatedTag.length > 0 ? generatedTag.join(" and ") : []; }).flat(1); } async init() { if (this._generateSkipTags) { this._cucumberOpts.tags = this.generateDynamicSkipTags().concat(this._cucumberOpts.tags || []).join(" and "); } const { plan } = await loadSources({ paths: this._specs, defaultDialect: this._cucumberOpts.language, order: this._cucumberOpts.order, names: this._cucumberOpts.name, tagExpression: this._cucumberOpts.tags }); this._specs = plan?.map((pl) => path2.resolve(pl.uri)); const lineNumbers = this._config.cucumberFeaturesWithLineNumbers?.length ?? 0; if (lineNumbers > 0) { this._specs = this._config.cucumberFeaturesWithLineNumbers.filter( (feature) => this._specs.some((spec) => path2.resolve(feature).startsWith(spec)) ); } this._specs = [...new Set(this._specs)]; this._cucumberOpts.paths = this._specs; this._hasTests = this._specs.length > 0; return this; } hasTests() { return this._hasTests; } async run() { let runtimeError; let result; let failedCount; let outStream; try { await this.registerRequiredModules(); supportCodeLibraryBuilder.reset(this._cwd, this._newId, { requireModules: this._cucumberOpts.requireModule, requirePaths: this._cucumberOpts.require, importPaths: this._cucumberOpts.import, loaders: [] }); this.addWdioHooks(this._config, supportCodeLibraryBuilder); await this.loadFiles(); this.wrapSteps(this._config); setUserHookNames(supportCodeLibraryBuilder); setDefaultTimeout(this._cucumberOpts.timeout); const supportCodeLibrary = supportCodeLibraryBuilder.finalize(); outStream = new Writable({ write(chunk, encoding, callback) { callback(); } }); this._eventEmitter.on("getFailedCount", (payload) => { failedCount = payload; }); const environment = { cwd: this._cwd, stderr: outStream, stdout: outStream }; const { runConfiguration } = await loadConfiguration( { profiles: this._cucumberOpts.profiles, provided: this._cucumberOpts, file: this._cucumberOpts.file }, environment ); const { success } = await runCucumber( { ...runConfiguration, support: supportCodeLibrary || runConfiguration.support }, environment ); result = success ? 0 : 1; if (this._cucumberOpts.ignoreUndefinedDefinitions && result) { result = failedCount; } } catch (err) { runtimeError = err; result = 1; } finally { outStream?.end(); } await executeHooksWithArgs("after", this._config.after, [ runtimeError || result, this._capabilities, this._specs ]); if (runtimeError) { throw runtimeError; } return result; } /** * Transpilation https://github.com/cucumber/cucumber-js/blob/master/docs/cli.md#transpilation * Usage: `['module']` * we extend it a bit with ability to init and pass configuration to modules. * Pass an array with path to module and its configuration instead: * Usage: `[['module', {}]]` * Or pass your own function * Usage: `[() => { require('@babel/register')({ ignore: [] }) }]` */ registerRequiredModules() { return Promise.all( this._cucumberOpts.requireModule.map( async (requiredModule) => { if (Array.isArray(requiredModule)) { (await import(requiredModule[0])).default(requiredModule[1]); } else if (typeof requiredModule === "function") { requiredModule(); } else { await import(requiredModule); } } ) ); } async loadFilesWithType(fileList) { return fileList.reduce( (files, file) => { const filePath = os.platform() === "win32" ? url.pathToFileURL(file).href : file; return files.concat(isGlob(filePath) ? globSync(filePath) : [filePath]); }, [] ); } async loadAndRefreshModule(modules) { const importedModules = []; for (const module of modules) { const filepath = module.startsWith(FILE_PROTOCOL) ? module : path2.isAbsolute(module) ? url.pathToFileURL(module).href : url.pathToFileURL(path2.join(process.cwd(), module)).href; const stepDefPath = url.pathToFileURL( require2.resolve(url.fileURLToPath(filepath)) ).href; const cacheEntryToDelete = Object.keys(require2.cache).find( (u) => url.pathToFileURL(u).href === stepDefPath ); if (cacheEntryToDelete) { delete require2.cache[cacheEntryToDelete]; } const importedModule = await import(filepath); importedModules.push(importedModule); } return importedModules; } async loadFiles() { await Promise.all([ this.loadAndRefreshModule( await this.loadFilesWithType(this._cucumberOpts.require) ), this.loadAndRefreshModule( await this.loadFilesWithType(this._cucumberOpts.import) ) ]); } /** * set `beforeFeature`, `afterFeature`, `beforeScenario`, `afterScenario`, 'beforeStep', 'afterStep' * @param {object} config config */ addWdioHooks(config, supportCodeLibraryBuilder2) { const params = {}; this._eventEmitter.on("getHookParams", (payload) => { params.uri = payload.uri; params.feature = payload.feature; }); supportCodeLibraryBuilder2.methods.BeforeAll(async function wdioHookBeforeFeature() { await executeHooksWithArgs("beforeFeature", config.beforeFeature, [ params.uri, params.feature ]); }); supportCodeLibraryBuilder2.methods.Before(async function wdioHookBeforeScenario(world) { await executeHooksWithArgs( "beforeScenario", config.beforeScenario, [world, this] ); }); supportCodeLibraryBuilder2.methods.BeforeStep(async function wdioHookBeforeStep(world) { await executeHooksWithArgs("beforeStep", config.beforeStep, [ world.pickleStep, world.pickle, this ]); }); supportCodeLibraryBuilder2.methods.AfterStep(async function wdioHookAfterStep(world) { await executeHooksWithArgs("afterStep", config.afterStep, [ world.pickleStep, world.pickle, getResultObject(world), this ]); }); supportCodeLibraryBuilder2.methods.After(async function wdioHookAfterScenario(world) { await executeHooksWithArgs("afterScenario", config.afterScenario, [ world, getResultObject(world), this ]); }); supportCodeLibraryBuilder2.methods.AfterAll(async function wdioHookAfterFeature() { await executeHooksWithArgs("afterFeature", config.afterFeature, [ params.uri, params.feature ]); }); } /** * wraps step definition code with sync/async runner with a retry option * @param {object} config */ wrapSteps(config) { const wrapStep = this.wrapStep; const cid = this._cid; let params; this._eventEmitter.on("getHookParams", (payload) => { params = payload; }); const getHookParams = () => params; setDefinitionFunctionWrapper( (fn, options = { retry: 0 }) => { if (fn.name.startsWith("wdioHook")) { return fn; } const isStep = !fn.name.startsWith("userHook"); if (isStep && !options.retry) { return fn; } return wrapStep(fn, isStep, config, cid, options, getHookParams, this._cucumberOpts.timeout); } ); } /** * wrap step definition to enable retry ability * @param {Function} code step definition * @param {boolean} isStep * @param {object} config * @param {string} cid cid * @param {StepDefinitionOptions} options * @param {Function} getHookParams step definition * @param {number} timeout the maximum time (in milliseconds) to wait for * @return {Function} wrapped step definition for sync WebdriverIO code */ wrapStep(code, isStep, config, cid, options, getHookParams, timeout, hookName = void 0) { return function(...args) { const hookParams = getHookParams(); const retryTest = isStep && isFinite(options.retry) ? options.retry : 0; const beforeFn = config.beforeHook; const afterFn = config.afterHook; return testFnWrapper.call( this, isStep ? "Step" : "Hook", { specFn: code, specFnArgs: args }, { beforeFn, beforeFnArgs: (context) => [hookParams?.step, context] }, { afterFn, afterFnArgs: (context) => [hookParams?.step, context] }, cid, retryTest, hookName, timeout ); }; } }; var publishCucumberReport = async (cucumberMessageDir) => { const url2 = process.env.CUCUMBER_PUBLISH_REPORT_URL || "https://messages.cucumber.io/api/reports"; const token = process.env.CUCUMBER_PUBLISH_REPORT_TOKEN; if (!token) { log2.debug("Publishing reports are skipped because `CUCUMBER_PUBLISH_REPORT_TOKEN` environment variable value is not set."); return; } const response = await fetch(url2, { method: "get", headers: { Authorization: `Bearer ${token}` } }); const location = response.headers.get("location"); const files = (await readdir(path2.normalize(cucumberMessageDir))).filter((file) => path2.extname(file) === ".ndjson"); const cucumberMessage = (await Promise.all( files.map( (file) => readFile( path2.normalize(path2.join(cucumberMessageDir, file)), "utf8" ) ) )).join(""); await fetch(location, { method: "put", headers: { "Content-Type": "application/json" }, body: `${cucumberMessage}` }); }; var _CucumberAdapter = CucumberAdapter; var adapterFactory = {}; adapterFactory.init = async function(...args) { const adapter = new _CucumberAdapter(...args); const instance = await adapter.init(); return instance; }; var index_default = adapterFactory; export { CucumberAdapter, FILE_PROTOCOL, adapterFactory, index_default as default, publishCucumberReport };