import path from 'node:path'
import logger from '@wdio/logger'
import { isFunctionAsync } from '@wdio/utils'
import type { supportCodeLibraryBuilder, World } from '@cucumber/cucumber'
import type {
TableRow,
TableCell,
PickleStep,
TestStep,
Feature,
Pickle,
TestStepResultStatus,
FeatureChild
} from '@cucumber/messages'
import type { Capabilities } from '@wdio/types'
import type { ReporterStep, TestHookDefinitionConfig, Payload } from './types.js'
import { CUCUMBER_HOOK_DEFINITION_TYPES } from './constants.js'
const log = logger('@wdio/cucumber-framework:utils')
* NOTE: this function is exported for testing only
*/
export function createStepArgument ({ argument }: PickleStep) {
if (!argument) {
return undefined
}
if (argument.dataTable) {
return {
rows: argument.dataTable.rows?.map((row: TableRow) => (
{
cells: row.cells?.map((cell: TableCell) => cell.value)
}
))
}
}
if (argument.docString) {
return argument.docString.content
}
return undefined
}
* format message
* @param {object} message { type: string, payload: object }
*/
export function formatMessage ({ payload = {} }: { payload: Payload }): Payload {
const content = { ...payload }
* need to convert Error to plain object, otherwise it is lost on process.send
*/
if (payload.error && (payload.error.message || payload.error.stack)) {
const { name, message, stack } = payload.error
content.error = { name, message, stack }
}
if (payload.title && payload.parent) {
content.fullTitle = `${payload.parent}: ${payload.title}`
}
return content
}
enum StepType {
hook = 'hook',
test = 'test'
}
* Get step type
* @param {string} type `Step` or `Hook`
*/
export function getStepType (step: TestStep) {
return step.hookId ? StepType.hook : StepType.test
}
export function getFeatureId (uri: string, feature: Feature) {
return `${path.basename(uri)}:${feature.location?.line}:${feature.location?.column}`
}
* Builds test title from step keyword and text
* @param {string} keyword
* @param {string} text
* @param {string} type
*/
export function getTestStepTitle (keyword:string = '', text:string = '', type:string) {
const title = (!text && type.toLowerCase() !== 'hook') ? 'Undefined Step' : text
return `${keyword.trim()} ${title.trim()}`.trim()
}
* build payload for test/hook event
*/
export function buildStepPayload(
uri: string,
feature: Feature,
scenario: Pickle,
step: ReporterStep,
params: {
type: string
state?: TestStepResultStatus | string | null
error?: Error
duration?: number
title?: string | null
passed?: boolean
file?: string
}
) {
return {
...params,
uid: step.id,
title: getTestStepTitle(step.keyword, step.text, params.type),
parent: scenario.id,
argument: createStepArgument(step),
file: uri,
tags: scenario.tags,
featureName: feature.name,
scenarioName: scenario.name,
}
}
* The reporters need to have the rule.
* They are NOT available on the scenario, they ARE on the feature.
* This will add them to it
*/
export function getRule(feature: Feature, scenarioId: string){
const rules = feature.children?.filter((child) => Object.keys(child)[0] === 'rule')
const rule = rules.find((rule) => {
const scenarioRule = rule.rule?.children?.find((child) => child.scenario?.id === scenarioId)
if (scenarioRule) {
return rule
}
})
return rule?.rule?.name
}
* The reporters need to have the keywords, like `Given|When|Then`. They are NOT available
* on the scenario, they ARE on the feature.
* This will aad them
*/
export function addKeywordToStep(steps: ReporterStep[], feature: Feature){
return steps.map(step => {
if (step.astNodeIds && step.astNodeIds.length > 0 && feature.children) {
const astNodeId = step.astNodeIds[0]
const rules = feature.children.filter((child)=> Object.keys(child)[0]=== 'rule')
let featureChildren = feature.children.filter((child)=> Object.keys(child)[0]!== 'rule')
const rulesChildrens = rules.map((child) => child.rule?.children as FeatureChild).flat()
featureChildren = featureChildren.concat(rulesChildrens)
featureChildren.find((child) =>
child[Object.keys(child)[0]].steps.find((featureScenarioStep:ReporterStep) => {
if (featureScenarioStep.id === astNodeId.toString()) {
step.keyword = featureScenarioStep.keyword
}
return
})
)
return step
}
return step
})
}
* Generates skip tags based on capabilities and provided tags.
*
* @param {Capabilities.RemoteCapability} capabilities - The capabilities for which skip tags will be generated.
* @param {string[][]} tags - The original tags of scenarios.
* @returns {string[]} - An array of generated skip tags in Cucumber tag expression format.
*/
export function generateSkipTagsFromCapabilities(capabilities: Capabilities.ResolvedTestrunnerCapabilities, tags: string[][]): string[] {
const generatedTags: string[] = []
const skipTag = /^@skip$|^@skip\((.*)\)$/
const match = (value: string, expr: RegExp) => {
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: string) =>
skipExpr.split(';').reduce((acc: Record<string, string>, splitItem: string) => {
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: WebdriverIO.Capabilities) => Object.keys(filter)
.every((key: keyof WebdriverIO.Capabilities) => match((capabilities as Record<string, string>)[key], filter[key] as RegExp)))
if (isSkip) {
generatedTags.push(`(not ${tag.replace(/[(){}^$*+?.|\\]/g, '\\$&')})`)
}
}
})
return generatedTags
}
* Retrives scenario description if available.
*/
export function getScenarioDescription(feature: Feature, scenarioId: string){
const children = feature.children?.find((child) => child?.scenario?.id === scenarioId)
return children?.scenario?.description || ''
}
* wrap every user defined hook with function named `userHookFn`
* to identify later on is function a step, user hook or wdio hook.
* @param {object} options `Cucumber.supportCodeLibraryBuilder.options`
*/
export function setUserHookNames (options: typeof supportCodeLibraryBuilder) {
CUCUMBER_HOOK_DEFINITION_TYPES.forEach(hookName => {
options[hookName].forEach((testRunHookDefinition: TestHookDefinitionConfig) => {
const hookFn = testRunHookDefinition.code
if (!hookFn.name.startsWith('wdioHook')) {
const userHookAsyncFn = async function (this: World, ...args: unknown[]) {
return hookFn.apply(this, args)
}
const userHookFn = function (this: World, ...args: unknown[]) {
return hookFn.apply(this, args)
}
testRunHookDefinition.code = (isFunctionAsync(hookFn)) ? userHookAsyncFn : userHookFn
}
})
})
}
* Convert Cucumber status to WebdriverIO test status for reporting.
* Maps statuses like PASSED, PENDING, etc., to WebdriverIO's shorthand test status values.
* @param {TestStepResultStatus} status - The Cucumber status (e.g., 'PENDING')
* @returns {'pass' | 'fail' | 'skip' | 'pending'} - The corresponding WebdriverIO test status
*/
export function convertStatus(status: TestStepResultStatus): 'pass' | 'fail' | 'skip' | 'pending' {
switch (status) {
case 'PASSED': return 'pass'
case 'PENDING': return 'pending'
case 'SKIPPED': return 'skip'
case 'AMBIGUOUS': return 'skip'
case 'FAILED': return 'fail'
case 'UNDEFINED': return 'pass'
default: return 'fail'
}
}