@wdio/cucumber-framework
Version:
A WebdriverIO plugin. Adapter for Cucumber.js testing framework.
519 lines (515 loc) • 17.8 kB
JavaScript
// 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
};