diff --git a/packages/cli/package.json b/packages/cli/package.json index b918761bc3..04025375d6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,6 +19,8 @@ "test:ai:temp": "AITEST=true BRIDGE_MODE=true vitest tests/bridge.test.ts" }, "dependencies": { + "@cucumber/gherkin": "41.0.0", + "@cucumber/messages": "33.0.3", "@midscene/android": "workspace:*", "@midscene/computer": "workspace:*", "@midscene/core": "workspace:*", diff --git a/packages/cli/rslib.config.ts b/packages/cli/rslib.config.ts index ea13034d6a..a613595d1d 100644 --- a/packages/cli/rslib.config.ts +++ b/packages/cli/rslib.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', 'framework/index': 'src/framework/index.ts', + 'framework/feature-loader': 'src/framework/feature-loader.ts', }, define: { __VERSION__: JSON.stringify(version), diff --git a/packages/cli/src/cli-utils.ts b/packages/cli/src/cli-utils.ts index 5949d1a2fa..29c5dce7a1 100644 --- a/packages/cli/src/cli-utils.ts +++ b/packages/cli/src/cli-utils.ts @@ -139,7 +139,11 @@ Examples: }; }; -// match yml or yaml files +const RUNNABLE_FILE_EXTENSIONS = ['.yml', '.yaml', '.feature']; + +const isRunnableFile = (file: string): boolean => + RUNNABLE_FILE_EXTENSIONS.some((ext) => file.endsWith(ext)); + export async function matchYamlFiles( fileGlob: string, options?: { @@ -147,7 +151,7 @@ export async function matchYamlFiles( }, ) { if (existsSync(fileGlob) && statSync(fileGlob).isDirectory()) { - fileGlob = join(fileGlob, '**/*.{yml,yaml}'); + fileGlob = join(fileGlob, '**/*.{yml,yaml,feature}'); } const { cwd } = options || {}; @@ -160,7 +164,5 @@ export async function matchYamlFiles( cwd, }); - return files - .filter((file) => file.endsWith('.yml') || file.endsWith('.yaml')) - .sort(); + return files.filter(isRunnableFile).sort(); } diff --git a/packages/cli/src/config-factory.ts b/packages/cli/src/config-factory.ts index d851756ab6..ef1487c9a2 100644 --- a/packages/cli/src/config-factory.ts +++ b/packages/cli/src/config-factory.ts @@ -105,7 +105,9 @@ export async function parseConfigYaml( // Validate that at least one file was found if (files.length === 0) { - throw new Error('No YAML files found matching the patterns in "files"'); + throw new Error( + 'No YAML or feature files found matching the patterns in "files"', + ); } // Generate default summary filename diff --git a/packages/cli/src/execution-summary.ts b/packages/cli/src/execution-summary.ts index b081d3389b..7804e8c0bf 100644 --- a/packages/cli/src/execution-summary.ts +++ b/packages/cli/src/execution-summary.ts @@ -157,10 +157,19 @@ const toTestStatus = ( const safeReportNamePart = (value: string): string => value.replace(/[:*?"<>|# ]/g, '-'); -const createRetryReportName = (file: string): string => { - const fileName = basename(file, extname(file)) || 'yaml'; +const resultDisplayName = (result: MidsceneYamlConfigResult): string => + result.testName ?? result.file; + +const retryAttemptDisplayName = (result: MidsceneYamlConfigResult): string => + result.testName ?? basename(result.file); + +const createRetryReportName = (result: MidsceneYamlConfigResult): string => { + const label = resultDisplayName(result); + const fileName = result.testName + ? basename(label) + : basename(result.file, extname(result.file)) || 'yaml'; const fileHash = createHash('sha1') - .update(resolve(file)) + .update(`${resolve(result.file)}\0${result.testName ?? ''}`) .digest('hex') .slice(0, 8); return `${safeReportNamePart(fileName)}-${fileHash}-retry-attempts`; @@ -179,13 +188,14 @@ const createRetryAttemptReport = ( const tool = new ReportMergingTool(); for (const attempt of attemptsWithReports) { const status = toTestStatus(attempt); + const displayName = retryAttemptDisplayName(result); tool.append({ reportFilePath: attempt.report!, reportAttributes: { testDuration: attempt.duration ?? 0, testStatus: status, - testTitle: `Attempt ${attempt.attempt}: ${status} - ${basename(result.file)}`, - testId: `${safeReportNamePart(basename(result.file))}-attempt-${attempt.attempt}`, + testTitle: `Attempt ${attempt.attempt}: ${status} - ${displayName}`, + testId: `${safeReportNamePart(displayName)}-attempt-${attempt.attempt}`, testDescription: attempt.error ?? (attempt.success ? 'YAML attempt passed' : 'YAML attempt failed'), @@ -194,7 +204,7 @@ const createRetryAttemptReport = ( } return ( - tool.mergeReports(createRetryReportName(result.file), { + tool.mergeReports(createRetryReportName(result), { outputDir: getMidsceneRunSubDir('report'), overwrite: true, }) || undefined @@ -249,7 +259,7 @@ export function writeExecutionSummaryFile( const retryReport = createRetryAttemptReportSafely(result); return { - script: relative(outputDir, result.file), + script: result.testName ?? relative(outputDir, result.file), success: result.success, resultType: result.resultType, output: result.output @@ -340,7 +350,7 @@ export function printExecutionSummary( if (successfulFiles.length > 0) { console.log('\n✅ Successful files:'); successfulFiles.forEach((result) => { - console.log(` ${result.file}`); + console.log(` ${resultDisplayName(result)}`); printResultArtifacts(result); }); } @@ -348,7 +358,7 @@ export function printExecutionSummary( if (failedFiles.length > 0) { console.log('\n❌ Failed files'); failedFiles.forEach((result) => { - console.log(` ${result.file}`); + console.log(` ${resultDisplayName(result)}`); if (result.error) { console.log(` Error: ${result.error}`); } @@ -360,7 +370,7 @@ export function printExecutionSummary( '\n⚠️ Partial failed files (some tasks failed with continueOnError)', ); partialFailedFiles.forEach((result) => { - console.log(` ${result.file}`); + console.log(` ${resultDisplayName(result)}`); if (result.error) { console.log(` Error: ${result.error}`); } @@ -370,7 +380,7 @@ export function printExecutionSummary( if (notExecutedFiles.length > 0) { console.log('\n⏸️ Not executed files'); notExecutedFiles.forEach((result) => { - console.log(` ${result.file}`); + console.log(` ${resultDisplayName(result)}`); }); } diff --git a/packages/cli/src/framework/command.ts b/packages/cli/src/framework/command.ts index 4cca3a635c..bacdb9808d 100644 --- a/packages/cli/src/framework/command.ts +++ b/packages/cli/src/framework/command.ts @@ -9,6 +9,7 @@ import { printExecutionSummary, writeExecutionSummaryFile, } from '../execution-summary'; +import { isFeatureFile } from './feature-file'; import { type GeneratedRstestYamlProject, type RstestYamlCaseOptions, @@ -68,7 +69,10 @@ const readProjectResults = ( ) as MidsceneYamlConfigResult; } - return createNotExecutedYamlResult(item.yamlFile); + return { + ...createNotExecutedYamlResult(item.yamlFile), + testName: item.testName, + }; }); export async function runFrameworkTestConfig( @@ -76,6 +80,9 @@ export async function runFrameworkTestConfig( commandOptions: FrameworkTestCommandOptions = {}, ): Promise { printExecutionPlan(config); + if (config.shareBrowserContext && config.files.some(isFeatureFile)) { + throw new Error('shareBrowserContext is not supported for .feature files'); + } const projectDir = resolve(commandOptions.projectDir || process.cwd()); const project = createRstestYamlProject({ diff --git a/packages/cli/src/framework/feature-file.ts b/packages/cli/src/framework/feature-file.ts new file mode 100644 index 0000000000..251c66cc57 --- /dev/null +++ b/packages/cli/src/framework/feature-file.ts @@ -0,0 +1,285 @@ +import { + AstBuilder, + GherkinClassicTokenMatcher, + Parser, + compile, +} from '@cucumber/gherkin'; +import { + type FeatureChild, + IdGenerator, + type Pickle, + type PickleStep, + type Scenario, + type Step, +} from '@cucumber/messages'; +import type { MidsceneYamlScript } from '@midscene/core'; + +type ConcreteStepKeyword = 'Given' | 'When' | 'Then'; +type StepKeyword = ConcreteStepKeyword | 'And' | 'But'; + +export interface CompiledFeatureScenario { + caseId: string; + scenarioName: string; + testName: string; + executionConfig: MidsceneYamlScript; +} + +export const isFeatureFile = (file: string): boolean => + file.toLowerCase().endsWith('.feature'); + +const locationOf = (node: { location?: { line?: number } }): number => + node.location?.line ?? 1; + +const lineError = (file: string, line: number, message: string): Error => + new Error(`${file}:${line}: ${message}`); + +const parseGherkinDocument = (content: string, file: string) => { + const newId = IdGenerator.incrementing(); + const parser = new Parser( + new AstBuilder(newId), + new GherkinClassicTokenMatcher(), + ); + + try { + return parser.parse(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${file}: Failed to parse feature file: ${message}`); + } +}; + +const assertNoTags = ( + file: string, + node: { tags?: readonly { location?: { line?: number } }[] }, +) => { + const tag = node.tags?.[0]; + if (!tag) return; + + throw lineError( + file, + locationOf(tag), + 'Tags are not supported by the Midscene feature runner', + ); +}; + +const assertNoDescription = ( + file: string, + node: { description?: string; location?: { line?: number } }, + owner: string, +) => { + if (!node.description?.trim()) return; + + throw lineError( + file, + locationOf(node), + `${owner} descriptions are not supported by the Midscene feature runner`, + ); +}; + +const assertSupportedScenario = (file: string, scenario: Scenario) => { + assertNoTags(file, scenario); + assertNoDescription(file, scenario, 'Scenario'); + + if ( + scenario.keyword !== 'Scenario' && + scenario.keyword !== 'Scenario Outline' + ) { + throw lineError( + file, + locationOf(scenario), + `${scenario.keyword} is not supported by the Midscene feature runner`, + ); + } + + for (const example of scenario.examples ?? []) { + assertNoTags(file, example); + assertNoDescription(file, example, 'Examples'); + } + + if ( + scenario.keyword === 'Scenario Outline' && + !scenario.examples?.some((example) => example.tableBody.length > 0) + ) { + throw lineError( + file, + locationOf(scenario), + 'Scenario Outline requires at least one Examples row', + ); + } +}; + +const assertSupportedStep = (file: string, step: Step) => { + if (step.dataTable) { + throw lineError( + file, + locationOf(step.dataTable), + 'Data tables are not supported by the Midscene feature runner', + ); + } + + if (step.docString) { + throw lineError( + file, + locationOf(step.docString), + 'Doc strings are not supported by the Midscene feature runner', + ); + } +}; + +const stepKeyword = (file: string, step: Step): StepKeyword => { + const keyword = step.keyword.trim(); + switch (keyword) { + case 'Given': + case 'When': + case 'Then': + case 'And': + case 'But': + return keyword; + default: + throw lineError( + file, + locationOf(step), + `Step keyword "${keyword}" is not supported by the Midscene feature runner`, + ); + } +}; + +const validateSteps = (file: string, steps: readonly Step[] | undefined) => { + for (const step of steps ?? []) { + assertSupportedStep(file, step); + stepKeyword(file, step); + } +}; + +interface ScenarioInfo { + id: string; + name: string; + ruleName?: string; +} + +const collectScenarioInfo = ( + file: string, + child: FeatureChild, + ruleName?: string, +): ScenarioInfo[] => { + if (child.background) { + assertNoDescription(file, child.background, 'Background'); + validateSteps(file, child.background.steps); + return []; + } + + if (child.rule) { + const rule = child.rule; + assertNoTags(file, rule); + assertNoDescription(file, rule, 'Rule'); + return rule.children.flatMap((ruleChild) => + collectScenarioInfo(file, ruleChild, rule.name), + ); + } + + const scenario = child.scenario; + if (!scenario) return []; + + assertSupportedScenario(file, scenario); + validateSteps(file, scenario.steps); + return [{ id: scenario.id, name: scenario.name, ruleName }]; +}; + +const pickleStepToFlowItem = (step: PickleStep) => { + switch (step.type) { + case 'Outcome': + return { aiAssert: step.text }; + case 'Context': + case 'Action': + return { aiAct: step.text }; + default: + throw new Error(`Unsupported Gherkin step type: ${String(step.type)}`); + } +}; + +const buildScenarioInfos = ( + file: string, + document: ReturnType, +) => { + const feature = document.feature; + if (!feature) return []; + return feature.children.flatMap((child) => collectScenarioInfo(file, child)); +}; + +const caseIdOf = (pickle: Pickle): string => pickle.astNodeIds.join(':'); + +const logicalNameKey = ( + ruleName: string | undefined, + scenarioName: string, +): string => `${ruleName ?? ''}\0${scenarioName}`; + +const pickleNameCounts = ( + pickles: readonly Pickle[], + scenarioInfoById: Map, +): Map => { + const counts = new Map(); + for (const pickle of pickles) { + const scenarioInfo = scenarioInfoById.get(pickle.astNodeIds[0]); + const key = logicalNameKey(scenarioInfo?.ruleName, pickle.name); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; +}; + +export function compileFeatureFile( + content: string, + file: string, +): CompiledFeatureScenario[] { + const document = parseGherkinDocument(content, file); + const feature = document.feature; + if (!feature?.name) { + throw new Error(`${file}: Feature title is required`); + } + + assertNoTags(file, feature); + assertNoDescription(file, feature, 'Feature'); + + const scenarioInfos = buildScenarioInfos(file, document); + if (scenarioInfos.length === 0) { + throw new Error(`${file}: At least one Scenario is required`); + } + const scenarioInfoById = new Map( + scenarioInfos.map((info) => [info.id, info]), + ); + + const newId = IdGenerator.incrementing(); + const pickles = compile(document, file, newId); + const nameCounts = pickleNameCounts(pickles, scenarioInfoById); + const seenByName = new Map(); + + return pickles.map((pickle, index) => { + const scenarioInfo = scenarioInfoById.get(pickle.astNodeIds[0]) ?? + scenarioInfos[index] ?? { id: pickle.id, name: pickle.name }; + const nameKey = logicalNameKey(scenarioInfo.ruleName, pickle.name); + const occurrence = (seenByName.get(nameKey) ?? 0) + 1; + seenByName.set(nameKey, occurrence); + const scenarioName = + (nameCounts.get(nameKey) ?? 0) > 1 + ? `${pickle.name} #${occurrence}` + : pickle.name; + const nameParts = [ + feature.name, + scenarioInfo.ruleName, + scenarioName, + ].filter(Boolean); + const flow = pickle.steps.map(pickleStepToFlowItem); + return { + caseId: caseIdOf(pickle), + scenarioName, + testName: nameParts.join(' > '), + executionConfig: { + tasks: [ + { + name: scenarioName, + flow, + }, + ], + }, + }; + }); +} diff --git a/packages/cli/src/framework/feature-loader.ts b/packages/cli/src/framework/feature-loader.ts new file mode 100644 index 0000000000..20ac7890d9 --- /dev/null +++ b/packages/cli/src/framework/feature-loader.ts @@ -0,0 +1,44 @@ +import type { GeneratedFeatureLoaderCase } from './rstest-project'; + +export interface FeatureLoaderOptions { + frameworkImport: string; + rstestCoreImport: string; + cases: GeneratedFeatureLoaderCase[]; +} + +interface FeatureLoaderContext { + resourcePath: string; + getOptions(): Omit & { + featureCasesByFile: Record; + }; +} + +const toImportLiteral = (value: string): string => JSON.stringify(value); + +export function transformFeatureFileToRstestModule( + options: FeatureLoaderOptions, +): string { + return `import { test } from ${toImportLiteral(options.rstestCoreImport)}; +import { defineYamlCaseTest } from ${toImportLiteral(options.frameworkImport)}; + +const testCases = ${JSON.stringify(options.cases, null, 2)}; + +for (const testOptions of testCases) { + defineYamlCaseTest(test, testOptions); +} +`; +} + +export default function featureLoader( + this: FeatureLoaderContext, + _source: string, +): string { + const loaderOptions = this.getOptions(); + const featureFile = this.resourcePath; + + return transformFeatureFileToRstestModule({ + frameworkImport: loaderOptions.frameworkImport, + rstestCoreImport: loaderOptions.rstestCoreImport, + cases: loaderOptions.featureCasesByFile[featureFile] ?? [], + }); +} diff --git a/packages/cli/src/framework/rstest-entry.ts b/packages/cli/src/framework/rstest-entry.ts index d13934180f..81a3381c37 100644 --- a/packages/cli/src/framework/rstest-entry.ts +++ b/packages/cli/src/framework/rstest-entry.ts @@ -91,10 +91,12 @@ const appendAttemptHistory = ( const createRuntimeFailureResult = ( file: string, + testName: string, startTime: number, error: unknown, ): MidsceneYamlConfigResult => ({ file, + testName, success: false, executed: true, duration: Date.now() - startTime, @@ -117,7 +119,10 @@ export const defineYamlCaseTest = ( ...options.webRuntimeOptions, file, }); - result = appendAttemptHistory(options.resultFile, result); + result = appendAttemptHistory(options.resultFile, { + ...result, + testName: options.testName, + }); writeResultFile(options.resultFile, result); if (!result.success) { @@ -127,7 +132,7 @@ export const defineYamlCaseTest = ( if (!result) { const failureResult = appendAttemptHistory( options.resultFile, - createRuntimeFailureResult(file, startTime, error), + createRuntimeFailureResult(file, options.testName, startTime, error), ); writeResultFile(options.resultFile, failureResult); } diff --git a/packages/cli/src/framework/rstest-project.ts b/packages/cli/src/framework/rstest-project.ts index dbc6fbed57..4923374174 100644 --- a/packages/cli/src/framework/rstest-project.ts +++ b/packages/cli/src/framework/rstest-project.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; import { basename, dirname, @@ -10,6 +10,7 @@ import { } from 'node:path'; import { getMidsceneRunSubDir } from '@midscene/shared/common'; import type { BatchRunnerConfig } from '../batch-runner'; +import { compileFeatureFile, isFeatureFile } from './feature-file'; import { resolveRstestCoreImportPath } from './rstest-dependencies'; import type { RunYamlCaseOptions } from './yaml-case'; @@ -51,6 +52,21 @@ export interface GeneratedYamlTestCase { testName: string; } +export interface GeneratedFeatureLoaderCase { + caseId: string; + yamlFile: string; + testName: string; + resultFile: string; + caseOptions?: RstestYamlCaseOptions; + webRuntimeOptions?: WebYamlRuntimeOptions; +} + +export interface GeneratedFeatureLoaderOptions { + frameworkImport: string; + rstestCoreImport: string; + featureCasesByFile: Record; +} + export interface GeneratedYamlBatchTest { testModule: string; testName: string; @@ -68,6 +84,7 @@ export interface GeneratedRstestYamlProject { testTimeout: number; bail?: number; retry?: number; + featureLoaderOptions?: GeneratedFeatureLoaderOptions; } const toPosixPath = (value: string): string => value.split(sep).join('/'); @@ -143,6 +160,129 @@ defineYamlBatchTest(test, testOptions); `; }; +const createGeneratedFailureTestContent = (options: { + rstestCoreImport: string; + testName: string; + error: string; +}): string => `import { test } from ${toImportLiteral(options.rstestCoreImport)}; + +test(${JSON.stringify(options.testName)}, () => { + throw new Error(${JSON.stringify(options.error)}); +}); +`; + +const createGeneratedProjectEntries = (options: { + files: string[]; + projectDir: string; + resultDir: string; + frameworkImport: string; + rstestCoreImport: string; + caseOptions?: Record; + webRuntimeOptions?: Record; +}) => { + let caseIndex = 0; + const include: string[] = []; + const virtualModules: Record = {}; + const cases: GeneratedYamlTestCase[] = []; + const featureCasesByFile: Record = {}; + + for (const file of options.files) { + const yamlFile = resolve(file); + const relativeTestName = resolveTestName(options.projectDir, yamlFile); + + if (!isFeatureFile(yamlFile)) { + const fileStem = safeFileStem(yamlFile, caseIndex); + caseIndex += 1; + const testModule = toVirtualModuleId(fileStem); + const resultFile = join(options.resultDir, `${fileStem}.json`); + virtualModules[testModule] = createGeneratedTestContent({ + rstestCoreImport: options.rstestCoreImport, + frameworkImport: options.frameworkImport, + yamlFile, + resultFile, + testName: relativeTestName, + caseOptions: options.caseOptions?.[yamlFile], + webRuntimeOptions: options.webRuntimeOptions?.[yamlFile], + }); + include.push(testModule); + cases.push({ + yamlFile, + testModule, + resultFile, + testName: relativeTestName, + }); + continue; + } + + let scenarios: ReturnType; + try { + scenarios = compileFeatureFile(readFileSync(yamlFile, 'utf8'), yamlFile); + } catch (error) { + const fileStem = safeFileStem(yamlFile, caseIndex); + caseIndex += 1; + const testModule = toVirtualModuleId(fileStem); + const resultFile = join(options.resultDir, `${fileStem}.json`); + const message = error instanceof Error ? error.message : String(error); + virtualModules[testModule] = createGeneratedFailureTestContent({ + rstestCoreImport: options.rstestCoreImport, + testName: relativeTestName, + error: message, + }); + include.push(testModule); + cases.push({ + yamlFile, + testModule, + resultFile, + testName: relativeTestName, + }); + continue; + } + + include.push(yamlFile); + + featureCasesByFile[yamlFile] = scenarios.map((scenario) => { + const scenarioFileName = `${basename( + yamlFile, + extname(yamlFile), + )}-${scenario.scenarioName.toLowerCase()}`; + const fileStem = safeFileStem( + join(dirname(yamlFile), scenarioFileName), + caseIndex, + ); + caseIndex += 1; + const resultFile = join(options.resultDir, `${fileStem}.json`); + const testName = `${relativeTestName} > ${scenario.testName}`; + cases.push({ + yamlFile, + testModule: yamlFile, + resultFile, + testName, + }); + return { + caseId: scenario.caseId, + yamlFile, + testName, + resultFile, + caseOptions: { + ...options.caseOptions?.[yamlFile], + executionConfig: scenario.executionConfig, + }, + webRuntimeOptions: options.webRuntimeOptions?.[yamlFile], + }; + }); + } + + const featureLoaderOptions = Object.keys(featureCasesByFile).length + ? { + frameworkImport: options.frameworkImport, + rstestCoreImport: options.rstestCoreImport, + featureCasesByFile, + } + : undefined; + + return { include, virtualModules, cases, featureLoaderOptions }; +}; + // Anchor the framework entry on this bundle's own directory rather than // `process.argv[1]`. The command-line entry can be a `.bin` symlink, an // `npx` cache path, or a wrapper script whose directory does not lead to the @@ -193,28 +333,19 @@ export function createRstestYamlProject( rmSync(outputDir, { recursive: true, force: true }); mkdirSync(resultDir, { recursive: true }); - const virtualModules: Record = {}; - const cases = options.files.map((file, index) => { - const yamlFile = resolve(file); - const testName = resolveTestName(projectDir, yamlFile); - const fileStem = safeFileStem(yamlFile, index); - const resultFile = join(resultDir, `${fileStem}.json`); - const testModule = toVirtualModuleId(fileStem); - virtualModules[testModule] = createGeneratedTestContent({ - rstestCoreImport, - frameworkImport, - yamlFile, - resultFile, - testName, - caseOptions: options.caseOptions?.[yamlFile], - webRuntimeOptions: options.webRuntimeOptions?.[yamlFile], - }); - return { yamlFile, testModule, resultFile, testName }; + const generated = createGeneratedProjectEntries({ + files: options.files, + projectDir, + resultDir, + frameworkImport, + rstestCoreImport, + caseOptions: options.caseOptions, + webRuntimeOptions: options.webRuntimeOptions, }); if (options.batchConfig) { const resultFiles = Object.fromEntries( - cases.map((item) => [item.yamlFile, item.resultFile]), + generated.cases.map((item) => [item.yamlFile, item.resultFile]), ); const batchTest = { testModule: RSTEST_YAML_BATCH_TEST_MODULE, @@ -234,7 +365,7 @@ export function createRstestYamlProject( resultFiles, }), }, - cases, + cases: generated.cases, batchTest, maxConcurrency: 1, testTimeout, @@ -246,9 +377,10 @@ export function createRstestYamlProject( projectDir, outputDir, resultDir, - include: cases.map((item) => item.testModule), - virtualModules, - cases, + include: generated.include, + virtualModules: generated.virtualModules, + cases: generated.cases, + featureLoaderOptions: generated.featureLoaderOptions, maxConcurrency: options.maxConcurrency, testTimeout, bail: options.bail, diff --git a/packages/cli/src/framework/rstest-runner.ts b/packages/cli/src/framework/rstest-runner.ts index a204b9d59b..6471a5f361 100644 --- a/packages/cli/src/framework/rstest-runner.ts +++ b/packages/cli/src/framework/rstest-runner.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import type { MidsceneYamlConfigResult } from '@midscene/core'; import type { RstestUserConfig, TestRunResult } from '@rstest/core/api'; @@ -17,6 +17,36 @@ export interface RunRstestYamlProjectOptions { stdio?: 'inherit' | 'pipe'; } +export const resolveDefaultFeatureLoaderPath = ( + moduleDir = __dirname, +): string => + basename(moduleDir) === 'framework' + ? join(moduleDir, 'feature-loader.js') + : join(moduleDir, 'framework', 'feature-loader.js'); + +type RspackPluginInput = Parameters< + Parameters< + Extract< + NonNullable['rspack']>, + ( + config: unknown, + utils: { appendPlugins: (plugin: never) => void }, + ) => unknown + > + >[1]['appendPlugins'] +>[0]; + +export interface RstestRspackDeps { + rspack: { + experiments: { + VirtualModulesPlugin: new ( + modules: Record, + ) => RspackPluginInput; + }; + }; + featureLoaderPath?: string; +} + const formatRunError = ( error: TestRunResult['unhandledErrors'][number], ): string => error.stack || `${error.name}: ${error.message}`; @@ -58,7 +88,7 @@ const errorMessage = ( ): string => error.message || error.name || 'YAML case failed'; // Attribute each rstest failure back to the YAML case it came from, keyed by the -// resolved YAML file path. A test-level failure matches on the test name (which +// per-case result file. A test-level failure matches on the test name (which // equals the case's `testName`); a file-level failure (e.g. the test module // could not be loaded) matches on the generated virtual module id. const mapRunErrorsToCases = ( @@ -68,10 +98,19 @@ const mapRunErrorsToCases = ( const byTestName = new Map( project.cases.map((item) => [item.testName, item]), ); + const byTestModule = new Map(); + for (const item of project.cases) { + const items = byTestModule.get(item.testModule); + if (items) { + items.push(item); + } else { + byTestModule.set(item.testModule, [item]); + } + } const errors = new Map(); const add = (item: GeneratedYamlTestCase | undefined, message: string) => { - if (item && message && !errors.has(item.yamlFile)) { - errors.set(item.yamlFile, message); + if (item && message && !errors.has(item.resultFile)) { + errors.set(item.resultFile, message); } }; const addAll = (message: string) => { @@ -79,17 +118,24 @@ const mapRunErrorsToCases = ( add(item, message); } }; - const matchFileCase = ( + const matchFileCases = ( file: TestRunResult['files'][number], - ): GeneratedYamlTestCase | undefined => { - for (const key of [file.name, file.testPath]) { - if (!key) continue; - const matched = project.cases.find( - (item) => key === item.testModule || key.includes(item.testModule), - ); + ): GeneratedYamlTestCase[] => { + const fileKeys = [file.name, file.testPath].filter((key): key is string => + Boolean(key), + ); + for (const key of fileKeys) { + const matched = byTestModule.get(key); if (matched) return matched; } - return undefined; + + for (const key of fileKeys) { + for (const [testModule, matched] of byTestModule) { + if (key.includes(testModule)) return matched; + } + } + + return []; }; const isBatchFile = (file: TestRunResult['files'][number]): boolean => { if (!project.batchTest) return false; @@ -108,20 +154,23 @@ const mapRunErrorsToCases = ( testName === project.batchTest?.testName; for (const file of result.files ?? []) { - const fileCase = matchFileCase(file); + const fileCases = matchFileCases(file); + const batchFile = isBatchFile(file); for (const error of file.errors ?? []) { const message = errorMessage(error); - if (isBatchFile(file)) { + if (batchFile) { addAll(message); + } else if (fileCases.length > 1) { + for (const item of fileCases) add(item, message); } else { - add(fileCase, message); + add(fileCases[0], message); } } for (const testResult of file.results ?? []) { - const item = byTestName.get(testResult.name) ?? fileCase; + const item = byTestName.get(testResult.name) ?? fileCases[0]; for (const error of testResult.errors ?? []) { const message = errorMessage(error); - if (isBatchTest(testResult.name) || isBatchFile(file)) { + if (isBatchTest(testResult.name) || batchFile) { addAll(message); } else { add(item, message); @@ -164,10 +213,11 @@ const recordUnreportedCaseFailures = ( const caseErrors = mapRunErrorsToCases(project, result); for (const item of project.cases) { if (existsSync(item.resultFile)) continue; - const error = caseErrors.get(item.yamlFile); + const error = caseErrors.get(item.resultFile); if (!error) continue; const failure: MidsceneYamlConfigResult = { file: item.yamlFile, + testName: item.testName, success: false, executed: true, output: undefined, @@ -181,19 +231,16 @@ const recordUnreportedCaseFailures = ( } }; -export async function runRstestYamlProject( - options: RunRstestYamlProjectOptions, -): Promise { - const [{ runRstest }, { rspack }] = await Promise.all([ - import('@rstest/core/api'), - import(pathToFileURL(resolvePackageFromRstestCore('@rsbuild/core')).href), - ]); - const { project } = options; +export function createRstestInlineConfig( + project: GeneratedRstestYamlProject, + deps: RstestRspackDeps, +): RstestUserConfig { const maxConcurrency = project.maxConcurrency !== undefined ? Math.max(1, project.maxConcurrency) : undefined; - const inlineConfig: RstestUserConfig = { + + return { root: project.projectDir, include: project.include, testEnvironment: 'node', @@ -208,13 +255,37 @@ export async function runRstestYamlProject( : {}), reporters: [], tools: { - rspack: (_config, { appendPlugins }) => { + rspack: (config, { appendPlugins }) => { appendPlugins( - new rspack.experiments.VirtualModulesPlugin(project.virtualModules), + new deps.rspack.experiments.VirtualModulesPlugin( + project.virtualModules, + ), ); + + if (!project.featureLoaderOptions) return; + + config.module ??= {}; + config.module.rules ??= []; + config.module.rules.push({ + test: /\.feature$/, + type: 'javascript/auto', + loader: deps.featureLoaderPath ?? resolveDefaultFeatureLoaderPath(), + options: project.featureLoaderOptions, + }); }, }, }; +} + +export async function runRstestYamlProject( + options: RunRstestYamlProjectOptions, +): Promise { + const [{ runRstest }, { rspack }] = await Promise.all([ + import('@rstest/core/api'), + import(pathToFileURL(resolvePackageFromRstestCore('@rsbuild/core')).href), + ]); + const { project } = options; + const inlineConfig = createRstestInlineConfig(project, { rspack }); const result = await runRstest({ cwd: options.cwd || project.projectDir, diff --git a/packages/cli/src/framework/yaml-case.ts b/packages/cli/src/framework/yaml-case.ts index cc4234e259..cc56689e17 100644 --- a/packages/cli/src/framework/yaml-case.ts +++ b/packages/cli/src/framework/yaml-case.ts @@ -61,18 +61,33 @@ const normalizeTargetConfig = ( } }; -const createExecutionConfig = ( - file: string, - globalConfig: RunYamlCaseGlobalConfig, +const mergeGlobalConfig = ( + fileConfig: MidsceneYamlScript, + globalConfig?: RunYamlCaseGlobalConfig, ): MidsceneYamlScript => { - const content = readFileSync(file, 'utf8'); - const fileConfig = cloneJson(parseYamlScript(content, file)); - normalizeTargetConfig(fileConfig); + const clonedFileConfig = cloneJson(fileConfig); + normalizeTargetConfig(clonedFileConfig); + + if (!globalConfig) return clonedFileConfig; const clonedGlobalConfig = cloneJson(globalConfig); normalizeTargetConfig(clonedGlobalConfig); - return merge(fileConfig, clonedGlobalConfig); + return merge(clonedFileConfig, clonedGlobalConfig); +}; + +const createExecutionConfig = ( + file: string, + globalConfig?: RunYamlCaseGlobalConfig, + executionConfig?: MidsceneYamlScript, +): MidsceneYamlScript | undefined => { + if (executionConfig) { + return mergeGlobalConfig(executionConfig, globalConfig); + } + if (!globalConfig) return undefined; + + const content = readFileSync(file, 'utf8'); + return mergeGlobalConfig(parseYamlScript(content, file), globalConfig); }; export const getYamlPlayerFailure = ( @@ -161,11 +176,11 @@ export async function runYamlCaseResult( ): Promise { const file = resolve(options.file); const startTime = Date.now(); - const executionConfig = - options.executionConfig || - (options.globalConfig - ? createExecutionConfig(file, options.globalConfig) - : undefined); + const executionConfig = createExecutionConfig( + file, + options.globalConfig, + options.executionConfig, + ); const player = await createYamlPlayer(file, executionConfig, { headed: options.headed, keepWindow: options.keepWindow, diff --git a/packages/cli/tests/unit-test/cli-utils.test.ts b/packages/cli/tests/unit-test/cli-utils.test.ts index c73cdf5274..141d6b974c 100644 --- a/packages/cli/tests/unit-test/cli-utils.test.ts +++ b/packages/cli/tests/unit-test/cli-utils.test.ts @@ -1,4 +1,6 @@ -import { join } from 'node:path'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; import { matchYamlFiles, parseProcessArgs } from '@/cli-utils'; import { launchServer } from '@/create-yaml-player'; import { afterEach, describe, expect, test } from 'vitest'; @@ -40,6 +42,25 @@ describe('matchYamlFiles', () => { files3.every((file) => file.endsWith('.yml') || file.endsWith('.yaml')), ).toBe(true); }); + + test('matches yaml and feature files from a directory', async () => { + const root = mkdtempSync(join(tmpdir(), 'midscene-cli-utils-')); + const casesDir = join(root, 'cases'); + mkdirSync(casesDir, { recursive: true }); + writeFileSync(join(casesDir, 'login.yaml'), 'tasks: []'); + writeFileSync(join(casesDir, 'checkout.feature'), 'Feature: Checkout'); + writeFileSync(join(casesDir, 'notes.txt'), 'ignore'); + + try { + const matchedFiles = await matchYamlFiles(casesDir); + expect(matchedFiles.map((file) => basename(file)).sort()).toEqual([ + 'checkout.feature', + 'login.yaml', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); describe('parseProcessArgs', () => { diff --git a/packages/cli/tests/unit-test/config-factory.test.ts b/packages/cli/tests/unit-test/config-factory.test.ts index 848d77cec4..51730a9b22 100644 --- a/packages/cli/tests/unit-test/config-factory.test.ts +++ b/packages/cli/tests/unit-test/config-factory.test.ts @@ -147,10 +147,26 @@ summary: "yaml-summary.json" vi.mocked(matchYamlFiles).mockResolvedValue([]); // No files found await expect(parseConfigYaml(mockIndexPath)).rejects.toThrow( - 'No YAML files found matching the patterns in "files"', + 'No YAML or feature files found matching the patterns in "files"', ); }); + test('should accept feature files in config patterns', async () => { + const mockYamlContent = `files: ["features/*.feature"]`; + const mockParsedYaml = { files: ['features/*.feature'] }; + + vi.mocked(readFileSync).mockReturnValue(mockYamlContent); + vi.mocked(interpolateEnvVars).mockReturnValue(mockYamlContent); + vi.mocked(yamlLoad).mockReturnValue(mockParsedYaml); + vi.mocked(matchYamlFiles).mockResolvedValue([ + '/test/features/checkout.feature', + ]); + + const result = await parseConfigYaml(mockIndexPath); + + expect(result.files).toEqual(['/test/features/checkout.feature']); + }); + test('should preserve duplicate file entries', async () => { const mockYamlContent = ` files: diff --git a/packages/cli/tests/unit-test/execution-summary.test.ts b/packages/cli/tests/unit-test/execution-summary.test.ts index 3c7dc2d914..5e2fa7e6e0 100644 --- a/packages/cli/tests/unit-test/execution-summary.test.ts +++ b/packages/cli/tests/unit-test/execution-summary.test.ts @@ -171,6 +171,137 @@ describe('execution summary', () => { } }); + test('uses testName as the summary script label when present', () => { + const root = mkdtempSync(join(tmpdir(), 'midscene-summary-')); + const runDir = join(root, 'midscene-run'); + const previousRunDir = process.env.MIDSCENE_RUN_DIR; + process.env.MIDSCENE_RUN_DIR = runDir; + + try { + const summaryPath = writeExecutionSummaryFile('summary.json', [ + { + file: join(root, 'features', 'checkout.feature'), + testName: 'features/checkout.feature > Checkout > Add item', + success: true, + executed: true, + duration: 10, + resultType: 'success', + }, + ]); + + const summary = JSON.parse(readFileSync(summaryPath, 'utf8')); + expect(summary.results[0].script).toBe( + 'features/checkout.feature > Checkout > Add item', + ); + } finally { + if (previousRunDir === undefined) { + Reflect.deleteProperty(process.env, 'MIDSCENE_RUN_DIR'); + } else { + process.env.MIDSCENE_RUN_DIR = previousRunDir; + } + rmSync(root, { recursive: true, force: true }); + } + }); + + test('writes distinct retry reports for scenarios in the same feature file', () => { + const root = mkdtempSync(join(tmpdir(), 'midscene-summary-')); + const runDir = join(root, 'midscene-run'); + const reportDir = join(runDir, 'report'); + const feature = join(root, 'features', 'checkout.feature'); + const previousRunDir = process.env.MIDSCENE_RUN_DIR; + + process.env.MIDSCENE_RUN_DIR = runDir; + writeFakeReport( + join(reportDir, 'add-attempt-1.html'), + 'add-attempt-one', + 'add-failed-before-retry', + ); + writeFakeReport( + join(reportDir, 'add-attempt-2.html'), + 'add-attempt-two', + 'add-passed-after-retry', + ); + writeFakeReport( + join(reportDir, 'remove-attempt-1.html'), + 'remove-attempt-one', + 'remove-failed-before-retry', + ); + writeFakeReport( + join(reportDir, 'remove-attempt-2.html'), + 'remove-attempt-two', + 'remove-passed-after-retry', + ); + + try { + const summaryPath = writeExecutionSummaryFile('summary.json', [ + { + file: feature, + testName: 'features/checkout.feature > Checkout > Add item', + success: true, + executed: true, + report: join(reportDir, 'add-attempt-2.html'), + duration: 20, + resultType: 'success', + attempts: [ + { + attempt: 1, + success: false, + report: join(reportDir, 'add-attempt-1.html'), + duration: 10, + resultType: 'failed', + }, + { + attempt: 2, + success: true, + report: join(reportDir, 'add-attempt-2.html'), + duration: 10, + resultType: 'success', + }, + ], + }, + { + file: feature, + testName: 'features/checkout.feature > Checkout > Remove item', + success: true, + executed: true, + report: join(reportDir, 'remove-attempt-2.html'), + duration: 20, + resultType: 'success', + attempts: [ + { + attempt: 1, + success: false, + report: join(reportDir, 'remove-attempt-1.html'), + duration: 10, + resultType: 'failed', + }, + { + attempt: 2, + success: true, + report: join(reportDir, 'remove-attempt-2.html'), + duration: 10, + resultType: 'success', + }, + ], + }, + ]); + + const summary = JSON.parse(readFileSync(summaryPath, 'utf8')); + expect(summary.results[0].retryReport).toBeDefined(); + expect(summary.results[1].retryReport).toBeDefined(); + expect(summary.results[0].retryReport).not.toBe( + summary.results[1].retryReport, + ); + } finally { + if (previousRunDir === undefined) { + Reflect.deleteProperty(process.env, 'MIDSCENE_RUN_DIR'); + } else { + process.env.MIDSCENE_RUN_DIR = previousRunDir; + } + rmSync(root, { recursive: true, force: true }); + } + }); + test('writes distinct retry reports for YAML files with duplicate basenames', () => { const root = mkdtempSync(join(tmpdir(), 'midscene-summary-')); const runDir = join(root, 'midscene-run'); diff --git a/packages/cli/tests/unit-test/framework/command.test.ts b/packages/cli/tests/unit-test/framework/command.test.ts index d5e31faf1f..ee05f4ff38 100644 --- a/packages/cli/tests/unit-test/framework/command.test.ts +++ b/packages/cli/tests/unit-test/framework/command.test.ts @@ -170,6 +170,43 @@ export function defineYamlCaseTest(test: any, options: any) { } }); + test('rejects feature files with shared browser context', async () => { + const root = createTempDir(); + const feature = join(root, 'checkout.feature'); + writeFileSync( + feature, + 'Feature: Checkout\nScenario: Add item\nGiven I open the page\n', + ); + + try { + await expect( + runFrameworkTestConfig( + { + files: [feature], + concurrent: 1, + continueOnError: false, + summary: 'summary.json', + shareBrowserContext: true, + globalConfig: {}, + headed: false, + keepWindow: false, + dotenvOverride: false, + dotenvDebug: false, + }, + { + outputDir: join(root, 'generated-runner'), + frameworkImport: '@test/framework', + stdio: 'pipe', + }, + ), + ).rejects.toThrow( + 'shareBrowserContext is not supported for .feature files', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test('lets Rstest schedule virtual entries by concurrency and stops after failure', async () => { const root = createTempDir(); const runDir = join(root, 'midscene-run'); diff --git a/packages/cli/tests/unit-test/framework/feature-file.test.ts b/packages/cli/tests/unit-test/framework/feature-file.test.ts new file mode 100644 index 0000000000..5aebec7544 --- /dev/null +++ b/packages/cli/tests/unit-test/framework/feature-file.test.ts @@ -0,0 +1,244 @@ +import { compileFeatureFile } from '@/framework/feature-file'; +import { describe, expect, test } from 'vitest'; + +describe('feature-file parser', () => { + test('compiles scenarios into Midscene aiAct and aiAssert tasks', () => { + const compiled = compileFeatureFile( + [ + 'Feature: Login', + 'Scenario: Failed login', + ' Given I open the login page', + ' And I type a bad password', + ' Then an error is shown', + ' But the user stays signed out', + '', + ].join('\n'), + '/repo/features/login.feature', + ); + + expect(compiled).toEqual([ + { + caseId: '4', + scenarioName: 'Failed login', + testName: 'Login > Failed login', + executionConfig: { + tasks: [ + { + name: 'Failed login', + flow: [ + { aiAct: 'I open the login page' }, + { aiAct: 'I type a bad password' }, + { aiAssert: 'an error is shown' }, + { aiAssert: 'the user stays signed out' }, + ], + }, + ], + }, + }, + ]); + }); + + test('compiles feature and rule backgrounds into each scenario', () => { + const compiled = compileFeatureFile( + [ + 'Feature: Checkout', + ' Background:', + ' Given I am signed in', + '', + ' Rule: Cart management', + ' Background:', + ' Given the cart is empty', + '', + ' Scenario: Add item', + ' When I add a hat', + ' Then the cart has 1 item', + '', + ].join('\n'), + '/repo/features/checkout.feature', + ); + + expect(compiled).toEqual([ + { + caseId: '6', + scenarioName: 'Add item', + testName: 'Checkout > Cart management > Add item', + executionConfig: { + tasks: [ + { + name: 'Add item', + flow: [ + { aiAct: 'I am signed in' }, + { aiAct: 'the cart is empty' }, + { aiAct: 'I add a hat' }, + { aiAssert: 'the cart has 1 item' }, + ], + }, + ], + }, + }, + ]); + }); + + test('compiles scenario outlines into one scenario per example row', () => { + const compiled = compileFeatureFile( + [ + 'Feature: Checkout', + 'Scenario Outline: Add quantities', + ' When I add ', + ' Then the cart has item', + '', + ' Examples:', + ' | qty | item |', + ' | 2 | hats |', + ' | 3 | shoes |', + '', + ].join('\n'), + '/repo/features/checkout.feature', + ); + + expect(compiled.map((item) => item.testName)).toEqual([ + 'Checkout > Add quantities #1', + 'Checkout > Add quantities #2', + ]); + expect(compiled.map((item) => item.caseId)).toEqual(['6:3', '6:4']); + expect(compiled.map((item) => item.executionConfig.tasks[0].flow)).toEqual([ + [{ aiAct: 'I add 2 hats' }, { aiAssert: 'the cart has 2 item' }], + [{ aiAct: 'I add 3 shoes' }, { aiAssert: 'the cart has 3 item' }], + ]); + }); + + test('compiles multiple examples blocks and placeholders in scenario names', () => { + const compiled = compileFeatureFile( + [ + 'Feature: Checkout', + 'Scenario Outline: Add ', + ' Then the cart has ', + '', + ' Examples: Hats', + ' | qty | item |', + ' | 2 | hats |', + '', + ' Examples: Shoes', + ' | qty | item |', + ' | 3 | shoes |', + '', + ].join('\n'), + '/repo/features/checkout.feature', + ); + + expect(compiled.map((item) => item.testName)).toEqual([ + 'Checkout > Add 2 hats', + 'Checkout > Add 3 shoes', + ]); + expect(compiled.map((item) => item.executionConfig.tasks[0].flow)).toEqual([ + [{ aiAssert: 'the cart has 2 hats' }], + [{ aiAssert: 'the cart has 3 shoes' }], + ]); + }); + + test('lets And inherit from background steps but rejects leading And', () => { + const compiled = compileFeatureFile( + [ + 'Feature: Checkout', + 'Background:', + ' Given I am signed in', + 'Scenario: Continue from background', + ' And I open the cart', + ' Then the cart is visible', + '', + ].join('\n'), + '/repo/features/checkout.feature', + ); + + expect(compiled[0].executionConfig.tasks[0].flow).toEqual([ + { aiAct: 'I am signed in' }, + { aiAct: 'I open the cart' }, + { aiAssert: 'the cart is visible' }, + ]); + + expect(() => + compileFeatureFile( + [ + 'Feature: Checkout', + 'Scenario: Bad start', + ' And I open the cart', + '', + ].join('\n'), + '/repo/features/checkout.feature', + ), + ).toThrow('Unsupported Gherkin step type: Unknown'); + }); + + test('does not suffix same scenario names under different rules', () => { + const compiled = compileFeatureFile( + [ + 'Feature: Checkout', + 'Rule: Buyer cart', + ' Scenario: Review cart', + ' Then the buyer cart is visible', + 'Rule: Admin cart', + ' Scenario: Review cart', + ' Then the admin cart is visible', + '', + ].join('\n'), + '/repo/features/checkout.feature', + ); + + expect(compiled.map((item) => item.testName)).toEqual([ + 'Checkout > Buyer cart > Review cart', + 'Checkout > Admin cart > Review cart', + ]); + }); + + test('throws for scenario outlines without example rows', () => { + expect(() => + compileFeatureFile( + [ + 'Feature: Checkout', + 'Scenario Outline: Add quantities', + ' When I add items', + ' Then the cart has items', + '', + ' Examples:', + ' | qty |', + '', + ].join('\n'), + '/repo/features/checkout.feature', + ), + ).toThrow( + '/repo/features/checkout.feature:2: Scenario Outline requires at least one Examples row', + ); + }); + + test('throws for unsupported feature and scenario descriptions', () => { + expect(() => + compileFeatureFile( + [ + 'Feature: Checkout', + ' Extra prose is not supported', + 'Scenario: Add item', + ' Given I open the page', + '', + ].join('\n'), + '/repo/features/checkout.feature', + ), + ).toThrow( + '/repo/features/checkout.feature:1: Feature descriptions are not supported by the Midscene feature runner', + ); + + expect(() => + compileFeatureFile( + [ + 'Feature: Checkout', + 'Scenario: Add item', + ' Extra scenario prose is not supported', + ' Given I open the page', + '', + ].join('\n'), + '/repo/features/checkout.feature', + ), + ).toThrow( + '/repo/features/checkout.feature:2: Scenario descriptions are not supported by the Midscene feature runner', + ); + }); +}); diff --git a/packages/cli/tests/unit-test/framework/feature-loader.test.ts b/packages/cli/tests/unit-test/framework/feature-loader.test.ts new file mode 100644 index 0000000000..9c3086591e --- /dev/null +++ b/packages/cli/tests/unit-test/framework/feature-loader.test.ts @@ -0,0 +1,128 @@ +import { transformFeatureFileToRstestModule } from '@/framework/feature-loader'; +import { describe, expect, test } from 'vitest'; + +describe('feature file loader', () => { + test('emits one Rstest case per scenario using precomputed result files', () => { + const output = transformFeatureFileToRstestModule({ + frameworkImport: '/repo/packages/cli/dist/lib/framework/index.js', + rstestCoreImport: '/repo/node_modules/@rstest/core/dist/index.js', + cases: [ + { + caseId: '2', + testName: 'features/checkout.feature > Checkout > Add item', + resultFile: '/tmp/results/001-add-item.json', + caseOptions: { + globalConfig: { + web: { + url: 'https://shop.example', + }, + }, + executionConfig: { + tasks: [ + { + name: 'Add item', + flow: [ + { aiAct: 'I open the product page' }, + { aiAssert: 'the cart shows one item' }, + ], + }, + ], + }, + }, + webRuntimeOptions: { + headed: true, + }, + }, + { + caseId: '5', + testName: 'features/checkout.feature > Checkout > Remove item', + resultFile: '/tmp/results/002-remove-item.json', + caseOptions: { + globalConfig: { + web: { + url: 'https://shop.example', + }, + }, + executionConfig: { + tasks: [ + { + name: 'Remove item', + flow: [ + { aiAct: 'the cart has one item' }, + { aiAssert: 'the cart is empty' }, + ], + }, + ], + }, + }, + webRuntimeOptions: { + headed: true, + }, + }, + ], + }); + + expect(output).toContain('import { test } from'); + expect(output).toContain('defineYamlCaseTest(test'); + expect(output).toContain( + '"testName": "features/checkout.feature > Checkout > Add item"', + ); + expect(output).toContain('"resultFile": "/tmp/results/001-add-item.json"'); + expect(output).toContain('"aiAct": "I open the product page"'); + expect(output).toContain('"aiAssert": "the cart shows one item"'); + expect(output).toContain( + '"testName": "features/checkout.feature > Checkout > Remove item"', + ); + expect(output).toContain( + '"resultFile": "/tmp/results/002-remove-item.json"', + ); + }); + + test('keeps duplicate scenario names mapped to distinct result files by order', () => { + const output = transformFeatureFileToRstestModule({ + frameworkImport: '/repo/packages/cli/dist/lib/framework/index.js', + rstestCoreImport: '/repo/node_modules/@rstest/core/dist/index.js', + cases: [ + { + caseId: '2', + testName: 'features/checkout.feature > Checkout > Retry checkout #1', + resultFile: '/tmp/results/001-retry-checkout.json', + caseOptions: { + executionConfig: { + tasks: [ + { + name: 'Retry checkout #1', + flow: [{ aiAct: 'I open checkout' }], + }, + ], + }, + }, + }, + { + caseId: '5', + testName: 'features/checkout.feature > Checkout > Retry checkout #2', + resultFile: '/tmp/results/002-retry-checkout.json', + caseOptions: { + executionConfig: { + tasks: [ + { + name: 'Retry checkout #2', + flow: [{ aiAct: 'I refresh checkout' }], + }, + ], + }, + }, + }, + ], + }); + + expect(output).toContain( + '"resultFile": "/tmp/results/001-retry-checkout.json"', + ); + expect(output).toContain('"aiAct": "I open checkout"'); + expect(output).toContain( + '"resultFile": "/tmp/results/002-retry-checkout.json"', + ); + expect(output).toContain('"aiAct": "I refresh checkout"'); + }); +}); diff --git a/packages/cli/tests/unit-test/framework/rstest-entry.test.ts b/packages/cli/tests/unit-test/framework/rstest-entry.test.ts index 3276f3bad5..95ff6d7a99 100644 --- a/packages/cli/tests/unit-test/framework/rstest-entry.test.ts +++ b/packages/cli/tests/unit-test/framework/rstest-entry.test.ts @@ -137,4 +137,41 @@ describe('defineYamlCaseTest', () => { rmSync(root, { recursive: true, force: true }); } }); + + test('writes the Rstest test name into case result metadata', async () => { + const root = createTempDir(); + const feature = join(root, 'checkout.feature'); + const resultFile = join(root, 'results', 'checkout-add-item.json'); + writeFileSync( + feature, + 'Feature: Checkout\nScenario: Add item\nGiven I open the page\n', + ); + mocks.runYamlCaseResult.mockResolvedValueOnce({ + file: feature, + success: true, + executed: true, + duration: 10, + resultType: 'success', + }); + + try { + defineYamlCaseTest(injectedRstestTest(), { + testName: 'features/checkout.feature > Checkout > Add item', + yamlFile: feature, + resultFile, + }); + + const [, runCase] = mocks.rstestTest.mock.calls[0]; + await expect(runCase()).resolves.toBeUndefined(); + + const result = JSON.parse(readFileSync(resultFile, 'utf8')); + expect(result).toMatchObject({ + file: feature, + testName: 'features/checkout.feature > Checkout > Add item', + success: true, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/tests/unit-test/framework/rstest-project.test.ts b/packages/cli/tests/unit-test/framework/rstest-project.test.ts index ef23755c12..3cd4ba50d1 100644 --- a/packages/cli/tests/unit-test/framework/rstest-project.test.ts +++ b/packages/cli/tests/unit-test/framework/rstest-project.test.ts @@ -204,6 +204,259 @@ describe('rstest yaml project generation', () => { } }); + test('includes feature files directly and precomputes scenario loader metadata', () => { + const root = createTempDir(); + const outputDir = join(root, 'runner'); + const feature = join(root, 'features', 'checkout.feature'); + mkdirSync(join(root, 'features'), { recursive: true }); + writeFileSync( + feature, + [ + 'Feature: Checkout', + 'Scenario: Add item', + ' Given I open the product page', + ' When I add the item to the cart', + ' Then the cart shows one item', + '', + 'Scenario: Remove item', + ' Given the cart has one item', + ' When I remove it', + ' Then the cart is empty', + '', + ].join('\n'), + ); + + try { + const project = createRstestYamlProject({ + files: [feature], + projectDir: root, + outputDir, + frameworkImport: '@test/framework', + rstestCoreImport: '@test/rstest-core', + }); + + expect(project.cases).toHaveLength(2); + expect(project.cases.map((item) => item.testName)).toEqual([ + 'features/checkout.feature > Checkout > Add item', + 'features/checkout.feature > Checkout > Remove item', + ]); + expect(project.include).toEqual([feature]); + expect(project.virtualModules).toEqual({}); + expect(project.featureLoaderOptions?.featureCasesByFile[feature]).toEqual( + [ + expect.objectContaining({ + caseId: '3', + testName: 'features/checkout.feature > Checkout > Add item', + resultFile: join( + outputDir, + 'results', + '001-checkout-add-item.json', + ), + }), + expect.objectContaining({ + caseId: '7', + testName: 'features/checkout.feature > Checkout > Remove item', + resultFile: join( + outputDir, + 'results', + '002-checkout-remove-item.json', + ), + }), + ], + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('adds deterministic suffixes for duplicate feature scenario test names', () => { + const root = createTempDir(); + const outputDir = join(root, 'runner'); + const feature = join(root, 'features', 'checkout.feature'); + mkdirSync(join(root, 'features'), { recursive: true }); + writeFileSync( + feature, + [ + 'Feature: Checkout', + 'Scenario: Retry checkout', + ' Given I open checkout', + 'Scenario: Complete payment', + ' Given I pay for the order', + 'Scenario: Retry checkout', + ' Given I refresh checkout', + '', + ].join('\n'), + ); + + try { + const project = createRstestYamlProject({ + files: [feature], + projectDir: root, + outputDir, + frameworkImport: '@test/framework', + rstestCoreImport: '@test/rstest-core', + }); + + expect(project.cases.map((item) => item.testName)).toEqual([ + 'features/checkout.feature > Checkout > Retry checkout #1', + 'features/checkout.feature > Checkout > Complete payment', + 'features/checkout.feature > Checkout > Retry checkout #2', + ]); + expect( + project.featureLoaderOptions?.featureCasesByFile[feature].map( + (item) => item.testName, + ), + ).toEqual([ + 'features/checkout.feature > Checkout > Retry checkout #1', + 'features/checkout.feature > Checkout > Complete payment', + 'features/checkout.feature > Checkout > Retry checkout #2', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('precomputes one feature loader case per scenario outline example', () => { + const root = createTempDir(); + const outputDir = join(root, 'runner'); + const feature = join(root, 'features', 'checkout.feature'); + mkdirSync(join(root, 'features'), { recursive: true }); + writeFileSync( + feature, + [ + 'Feature: Checkout', + 'Rule: Cart quantities', + ' Scenario Outline: Add quantities', + ' When I add ', + ' Then the cart has item', + '', + ' Examples:', + ' | qty | item |', + ' | 2 | hats |', + ' | 3 | shoes |', + '', + ].join('\n'), + ); + + try { + const project = createRstestYamlProject({ + files: [feature], + projectDir: root, + outputDir, + frameworkImport: '@test/framework', + rstestCoreImport: '@test/rstest-core', + }); + + expect(project.cases.map((item) => item.testName)).toEqual([ + 'features/checkout.feature > Checkout > Cart quantities > Add quantities #1', + 'features/checkout.feature > Checkout > Cart quantities > Add quantities #2', + ]); + expect(project.featureLoaderOptions?.featureCasesByFile[feature]).toEqual( + [ + expect.objectContaining({ + caseId: '6:3', + testName: + 'features/checkout.feature > Checkout > Cart quantities > Add quantities #1', + }), + expect.objectContaining({ + caseId: '6:4', + testName: + 'features/checkout.feature > Checkout > Cart quantities > Add quantities #2', + }), + ], + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('turns invalid feature files into Rstest failure cases instead of throwing during project generation', () => { + const root = createTempDir(); + const outputDir = join(root, 'runner'); + const feature = join(root, 'features', 'broken.feature'); + mkdirSync(join(root, 'features'), { recursive: true }); + writeFileSync( + feature, + [ + 'Feature: Broken', + 'Scenario Outline: Missing rows', + ' When I add items', + ' Examples:', + ' | qty |', + '', + ].join('\n'), + ); + + try { + const project = createRstestYamlProject({ + files: [feature], + projectDir: root, + outputDir, + frameworkImport: '@test/framework', + rstestCoreImport: '@test/rstest-core', + }); + + expect(project.cases).toHaveLength(1); + expect(project.cases[0].testName).toBe('features/broken.feature'); + expect(project.include).toEqual([ + 'virtual:midscene-yaml/001-broken.test.ts', + ]); + expect(project.virtualModules[project.include[0]]).toContain( + 'test("features/broken.feature"', + ); + expect(project.virtualModules[project.include[0]]).toContain( + 'Scenario Outline requires at least one Examples row', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('does not suffix same scenario names under different rules in Rstest metadata', () => { + const root = createTempDir(); + const outputDir = join(root, 'runner'); + const feature = join(root, 'features', 'checkout.feature'); + mkdirSync(join(root, 'features'), { recursive: true }); + writeFileSync( + feature, + [ + 'Feature: Checkout', + 'Rule: Buyer cart', + ' Scenario: Review cart', + ' Then the buyer cart is visible', + 'Rule: Admin cart', + ' Scenario: Review cart', + ' Then the admin cart is visible', + '', + ].join('\n'), + ); + + try { + const project = createRstestYamlProject({ + files: [feature], + projectDir: root, + outputDir, + frameworkImport: '@test/framework', + rstestCoreImport: '@test/rstest-core', + }); + + expect(project.cases.map((item) => item.testName)).toEqual([ + 'features/checkout.feature > Checkout > Buyer cart > Review cart', + 'features/checkout.feature > Checkout > Admin cart > Review cart', + ]); + expect( + project.featureLoaderOptions?.featureCasesByFile[feature].map( + (item) => item.testName, + ), + ).toEqual([ + 'features/checkout.feature > Checkout > Buyer cart > Review cart', + 'features/checkout.feature > Checkout > Admin cart > Review cart', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test('generates a single batch virtual entry for shared browser context', () => { const root = createTempDir(); const outputDir = join(root, 'runner'); diff --git a/packages/cli/tests/unit-test/framework/rstest-runner-config.test.ts b/packages/cli/tests/unit-test/framework/rstest-runner-config.test.ts index 2508d9fc8d..8a1840ddbe 100644 --- a/packages/cli/tests/unit-test/framework/rstest-runner-config.test.ts +++ b/packages/cli/tests/unit-test/framework/rstest-runner-config.test.ts @@ -1,7 +1,12 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { runRstestYamlProject } from '@/framework/rstest-runner'; +import { + type RstestRspackDeps, + createRstestInlineConfig, + resolveDefaultFeatureLoaderPath, + runRstestYamlProject, +} from '@/framework/rstest-runner'; import { describe, expect, test, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ @@ -13,6 +18,14 @@ vi.mock('@rstest/core/api', () => ({ })); describe('rstest runner config', () => { + const rspack = { + experiments: { + VirtualModulesPlugin: class VirtualModulesPlugin { + constructor(public modules: Record) {} + }, + }, + } satisfies RstestRspackDeps['rspack']; + test('suppresses Rstest reporter output by default', async () => { const root = mkdtempSync(join(tmpdir(), 'midscene-rstest-config-')); mocks.runRstest.mockResolvedValue({ ok: true, unhandledErrors: [] }); @@ -109,4 +122,144 @@ describe('rstest runner config', () => { rmSync(root, { recursive: true, force: true }); } }); + + test('configures the feature loader when feature loader options are present', () => { + const root = mkdtempSync(join(tmpdir(), 'midscene-rstest-config-')); + const feature = join(root, 'checkout.feature'); + const inlineConfig = createRstestInlineConfig( + { + projectDir: root, + outputDir: join(root, 'output'), + resultDir: join(root, 'results'), + include: [feature], + virtualModules: {}, + cases: [], + maxConcurrency: 1, + testTimeout: 0, + featureLoaderOptions: { + frameworkImport: '@test/framework', + rstestCoreImport: '@test/rstest-core', + featureCasesByFile: { + [feature]: [ + { + caseId: '1', + testName: 'checkout.feature > Checkout > Add item', + resultFile: join(root, 'results', '001-checkout.json'), + }, + ], + }, + }, + }, + { + rspack, + featureLoaderPath: + '/repo/packages/cli/dist/lib/framework/feature-loader.js', + }, + ); + + try { + const config: { + module?: { + rules?: Array<{ options?: unknown }>; + }; + } = {}; + const appendPlugins = vi.fn(); + const configureRspack = inlineConfig.tools?.rspack; + if (typeof configureRspack !== 'function') { + throw new Error('Expected rspack config hook'); + } + configureRspack(config, { appendPlugins }); + expect(config.module?.rules).toEqual([ + expect.objectContaining({ + test: /\.feature$/, + type: 'javascript/auto', + loader: '/repo/packages/cli/dist/lib/framework/feature-loader.js', + }), + ]); + expect(config.module?.rules?.[0]?.options).toEqual( + expect.objectContaining({ + frameworkImport: '@test/framework', + rstestCoreImport: '@test/rstest-core', + }), + ); + expect(appendPlugins).toHaveBeenCalledTimes(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('resolves the feature loader from both root and framework bundle directories', () => { + expect(resolveDefaultFeatureLoaderPath('/repo/dist/lib')).toBe( + '/repo/dist/lib/framework/feature-loader.js', + ); + expect(resolveDefaultFeatureLoaderPath('/repo/dist/lib/framework')).toBe( + '/repo/dist/lib/framework/feature-loader.js', + ); + }); + + test('records unreported test errors against only the matching scenario result file', async () => { + const root = mkdtempSync(join(tmpdir(), 'midscene-rstest-config-')); + const feature = join(root, 'checkout.feature'); + const firstResult = join(root, 'results', '001-checkout.json'); + const secondResult = join(root, 'results', '002-checkout.json'); + mocks.runRstest.mockResolvedValue({ + ok: false, + unhandledErrors: [], + files: [ + { + name: feature, + testPath: feature, + errors: [], + results: [ + { + name: 'checkout.feature > Checkout > Add item', + errors: [ + { message: 'first scenario failed before result write' }, + ], + }, + ], + }, + ], + }); + + try { + const exitCode = await runRstestYamlProject({ + cwd: root, + stdio: 'pipe', + project: { + projectDir: root, + outputDir: join(root, 'output'), + resultDir: join(root, 'results'), + include: [feature], + virtualModules: {}, + cases: [ + { + yamlFile: feature, + testModule: feature, + resultFile: firstResult, + testName: 'checkout.feature > Checkout > Add item', + }, + { + yamlFile: feature, + testModule: feature, + resultFile: secondResult, + testName: 'checkout.feature > Checkout > Remove item', + }, + ], + maxConcurrency: 1, + testTimeout: 0, + }, + }); + + expect(exitCode).toBe(1); + expect(JSON.parse(readFileSync(firstResult, 'utf8'))).toMatchObject({ + file: feature, + testName: 'checkout.feature > Checkout > Add item', + error: 'first scenario failed before result write', + }); + expect(existsSync(secondResult)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/tests/unit-test/framework/yaml-case.test.ts b/packages/cli/tests/unit-test/framework/yaml-case.test.ts index f6a46af706..3e75196fc2 100644 --- a/packages/cli/tests/unit-test/framework/yaml-case.test.ts +++ b/packages/cli/tests/unit-test/framework/yaml-case.test.ts @@ -9,15 +9,18 @@ vi.mock('@/create-yaml-player', () => ({ createYamlPlayer: vi.fn(), })); -const createPlayer = (overrides: Record = {}) => ({ - status: 'done', - output: '/tmp/output.json', - reportFile: '/tmp/report.html', - errorInSetup: undefined, - taskStatusList: [], - run: vi.fn().mockResolvedValue(undefined), - ...overrides, -}); +type YamlPlayer = Awaited>; + +const createPlayer = (overrides: Partial = {}): YamlPlayer => + ({ + status: 'done', + output: '/tmp/output.json', + reportFile: '/tmp/report.html', + errorInSetup: undefined, + taskStatusList: [], + run: vi.fn().mockResolvedValue(undefined), + ...overrides, + }) as YamlPlayer; const createTempDir = () => mkdtempSync(join(tmpdir(), 'midscene-yaml-case-')); @@ -31,7 +34,7 @@ describe('runYamlCase', () => { const output = join(root, 'output.json'); writeFileSync(output, '{}'); const player = createPlayer({ output }); - vi.mocked(createYamlPlayer).mockResolvedValue(player as any); + vi.mocked(createYamlPlayer).mockResolvedValue(player); try { const result = await runYamlCase({ file: 'relative.yaml', headed: true }); @@ -58,7 +61,7 @@ describe('runYamlCase', () => { }, tasks: [], }; - vi.mocked(createYamlPlayer).mockResolvedValue(player as any); + vi.mocked(createYamlPlayer).mockResolvedValue(player); await runYamlCase({ file: 'relative.yaml', executionConfig }); @@ -73,7 +76,7 @@ describe('runYamlCase', () => { const root = createTempDir(); const yaml = join(root, 'case.yaml'); const player = createPlayer(); - vi.mocked(createYamlPlayer).mockResolvedValue(player as any); + vi.mocked(createYamlPlayer).mockResolvedValue(player); writeFileSync(yaml, 'web:\n url: https://file.example\ntasks: []\n'); try { @@ -102,11 +105,51 @@ describe('runYamlCase', () => { } }); + test('merges global config into a provided execution config', async () => { + const player = createPlayer(); + vi.mocked(createYamlPlayer).mockResolvedValue(player); + + await runYamlCase({ + file: 'checkout.feature', + executionConfig: { + tasks: [ + { + name: 'Add item', + flow: [{ aiAct: 'I add an item' }], + }, + ], + }, + globalConfig: { + web: { + url: 'https://shop.example', + viewportWidth: 1280, + }, + }, + }); + + expect(createYamlPlayer).toHaveBeenCalledWith( + expect.stringMatching(/checkout\.feature$/), + { + tasks: [ + { + name: 'Add item', + flow: [{ aiAct: 'I add an item' }], + }, + ], + web: { + url: 'https://shop.example', + viewportWidth: 1280, + }, + }, + { headed: undefined, keepWindow: undefined }, + ); + }); + test('normalizes target config and merges global platform config', async () => { const root = createTempDir(); const yaml = join(root, 'case.yaml'); const player = createPlayer(); - vi.mocked(createYamlPlayer).mockResolvedValue(player as any); + vi.mocked(createYamlPlayer).mockResolvedValue(player); writeFileSync( yaml, [ @@ -171,7 +214,7 @@ describe('runYamlCase', () => { status: 'error', errorInSetup: error, }); - vi.mocked(createYamlPlayer).mockResolvedValue(player as any); + vi.mocked(createYamlPlayer).mockResolvedValue(player); await expect(runYamlCase({ file: 'broken.yaml' })).rejects.toThrow( 'setup failed', @@ -193,7 +236,7 @@ describe('runYamlCase', () => { }, ], }); - vi.mocked(createYamlPlayer).mockResolvedValue(player as any); + vi.mocked(createYamlPlayer).mockResolvedValue(player); try { await expect(runYamlCase({ file: 'failed.yaml' })).rejects.toThrow( @@ -224,7 +267,7 @@ describe('runYamlCase', () => { }, ], }); - vi.mocked(createYamlPlayer).mockResolvedValue(player as any); + vi.mocked(createYamlPlayer).mockResolvedValue(player); try { const result = await runYamlCaseResult({ file: 'partial.yaml' }); diff --git a/packages/core/src/yaml.ts b/packages/core/src/yaml.ts index e632001e47..49c4fb89d5 100644 --- a/packages/core/src/yaml.ts +++ b/packages/core/src/yaml.ts @@ -370,6 +370,7 @@ export interface MidsceneYamlConfigAttempt { export interface MidsceneYamlConfigResult { file: string; + testName?: string; success: boolean; executed: boolean; output?: string | null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2361ef8d0b..d8d851559c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -764,6 +764,12 @@ importers: packages/cli: dependencies: + '@cucumber/gherkin': + specifier: 41.0.0 + version: 41.0.0 + '@cucumber/messages': + specifier: 33.0.3 + version: 33.0.3 '@midscene/android': specifier: workspace:* version: link:../android @@ -2074,6 +2080,12 @@ packages: resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} engines: {node: '>=10'} + '@cucumber/gherkin@41.0.0': + resolution: {integrity: sha512-pKGx1EzNjtWbpw74kEevKDMj71dF3ZSaFJpLYuWVvRZKe+Cwoq5iEkuMaELIg1jxIu8jH/A2HPMpMR8UBvdG0w==} + + '@cucumber/messages@33.0.3': + resolution: {integrity: sha512-5Afh4Yu4sVBlY0KXO/QLIx8UE6QrMJCnFfP0JOp40XS0NUlZ0Om8ItW3alc/QN0pt/melVzoanPLk0j1i4HgIQ==} + '@devicefarmer/adbkit-logcat@2.1.3': resolution: {integrity: sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw==} engines: {node: '>= 4'} @@ -12087,6 +12099,12 @@ snapshots: '@ctrl/tinycolor@3.6.1': {} + '@cucumber/gherkin@41.0.0': + dependencies: + '@cucumber/messages': 33.0.3 + + '@cucumber/messages@33.0.3': {} + '@devicefarmer/adbkit-logcat@2.1.3': {} '@devicefarmer/adbkit-monkey@1.2.1': {}