Skip to content

Commit 5c32050

Browse files
committed
Command capture enhancement for Cucumber test runner
1 parent eae9383 commit 5c32050

5 files changed

Lines changed: 211 additions & 46 deletions

File tree

example/wdio.conf.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const config: Options.Testrunner = {
6363
capabilities: [
6464
{
6565
browserName: 'chrome',
66-
browserVersion: '145.0.7632.117', // specify chromium browser version for testing
66+
browserVersion: '145.0.7632.160', // specify chromium browser version for testing
6767
'goog:chromeOptions': {
6868
args: [
6969
'--headless',

packages/nightwatch-devtools/src/helpers/cucumberHooks.cts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,24 @@ interface CucumberApi {
1818
Before(options: any, fn: (this: any, arg: any) => Promise<void>): void
1919
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2020
After(options: any, fn: (this: any, arg: any) => Promise<void>): void
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
BeforeStep(options: any, fn: (this: any, arg: any) => Promise<void>): void
23+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
24+
AfterStep(options: any, fn: (this: any, arg: any) => Promise<void>): void
2125
}
2226

2327
// @cucumber/cucumber is NOT a direct dependency of this package.
2428
// At runtime this require() is resolved from the user's project (via Cucumber's
2529
// own loader), so it always gets the same singleton the step definitions use.
2630
// eslint-disable-next-line @typescript-eslint/no-var-requires
27-
const { Before, After } = require('@cucumber/cucumber') as CucumberApi
31+
const { Before, After, BeforeStep, AfterStep } = require('@cucumber/cucumber') as CucumberApi
2832

2933
// The plugin instance is stored here by NightwatchDevToolsPlugin.before()
3034
interface CucumberPluginBridge {
3135
cucumberBefore(browser: unknown, pickle: unknown): Promise<void>
3236
cucumberAfter(browser: unknown, result: unknown, pickle: unknown): Promise<void>
37+
cucumberBeforeStep(browser: unknown, pickleStep: unknown, pickle: unknown): Promise<void>
38+
cucumberAfterStep(browser: unknown, result: unknown, pickleStep: unknown, pickle: unknown): Promise<void>
3339
}
3440

3541
Before({ order: 1000 }, async function (this: any, { pickle }: any) {
@@ -45,3 +51,17 @@ After({ order: 1000 }, async function (this: any, { result, pickle }: any) {
4551
await plugin.cucumberAfter(this.browser, result, pickle)
4652
}
4753
})
54+
55+
BeforeStep({ order: 1000 }, async function (this: any, { pickleStep, pickle }: any) {
56+
const plugin = (globalThis as any).__nightwatchDevtoolsPlugin as CucumberPluginBridge | undefined
57+
if (this.browser && plugin) {
58+
await plugin.cucumberBeforeStep(this.browser, pickleStep, pickle)
59+
}
60+
})
61+
62+
AfterStep({ order: 1000 }, async function (this: any, { result, pickleStep, pickle }: any) {
63+
const plugin = (globalThis as any).__nightwatchDevtoolsPlugin as CucumberPluginBridge | undefined
64+
if (this.browser && plugin) {
65+
await plugin.cucumberAfterStep(this.browser, result, pickleStep, pickle)
66+
}
67+
})

packages/nightwatch-devtools/src/helpers/suiteManager.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ export class SuiteManager {
9191
this.testReporter.updateSuites()
9292
}
9393

94+
/**
95+
* Update suite state from current children without marking it as ended.
96+
* Used during Cucumber runs to keep the feature-level suite state fresh.
97+
*/
98+
finalizeSuiteState(suite: SuiteStats): void {
99+
const hasFailures =
100+
suite.tests.some((t: any) => t.state === TEST_STATE.FAILED) ||
101+
suite.suites.some((s) => s.state === TEST_STATE.FAILED)
102+
suite.state = hasFailures ? TEST_STATE.FAILED : TEST_STATE.RUNNING
103+
this.testReporter.updateSuites()
104+
}
105+
94106
/**
95107
* Finalize suite with test results
96108
*/
@@ -102,19 +114,19 @@ export class SuiteManager {
102114
suite.end = new Date()
103115
suite._duration = suite.end.getTime() - (suite.start?.getTime() || 0)
104116

105-
const hasFailures = suite.tests.some(
106-
(t: any) => t.state === TEST_STATE.FAILED
107-
)
108-
const allPassed = suite.tests.every(
109-
(t: any) => t.state === TEST_STATE.PASSED
110-
)
111-
const hasSkipped = suite.tests.some(
112-
(t: any) => t.state === TEST_STATE.SKIPPED
113-
)
117+
// Check direct tests
118+
const hasFailures =
119+
suite.tests.some((t: any) => t.state === TEST_STATE.FAILED) ||
120+
suite.suites.some((s) => s.state === TEST_STATE.FAILED)
121+
const allPassed =
122+
suite.tests.every((t: any) => t.state === TEST_STATE.PASSED || t.state === TEST_STATE.SKIPPED) &&
123+
suite.suites.every((s) => s.state === TEST_STATE.PASSED || s.state === TEST_STATE.SKIPPED)
124+
const hasSkipped = suite.tests.some((t: any) => t.state === TEST_STATE.SKIPPED)
125+
const hasItems = suite.tests.length > 0 || suite.suites.length > 0
114126

115127
if (hasFailures) {
116128
suite.state = TEST_STATE.FAILED
117-
} else if (allPassed) {
129+
} else if (!hasItems || allPassed) {
118130
suite.state = TEST_STATE.PASSED
119131
} else if (hasSkipped) {
120132
suite.state = TEST_STATE.PASSED

packages/nightwatch-devtools/src/index.ts

Lines changed: 166 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
type NightwatchBrowser,
2424
type TestStats
2525
} from './types.js'
26-
import { determineTestState, findTestFileFromStack } from './helpers/utils.js'
26+
import { determineTestState, findTestFileFromStack, generateStableUid } from './helpers/utils.js'
2727
import { DEFAULTS, TIMING, TEST_STATE } from './constants.js'
2828

2929

@@ -38,6 +38,8 @@ class NightwatchDevToolsPlugin {
3838
private browserProxy!: BrowserProxy
3939
private isScriptInjected = false
4040
#currentTest: any = null
41+
#currentScenarioSuite: any = null
42+
#currentStep: any = null
4143
#lastSessionId: string | null = null
4244
#devtoolsBrowser?: WebdriverIO.Browser
4345
#userDataDir?: string
@@ -160,7 +162,7 @@ class NightwatchDevToolsPlugin {
160162
this.browserProxy = new BrowserProxy(
161163
this.sessionCapturer,
162164
this.testManager,
163-
() => this.#currentTest
165+
() => this.#currentTest ?? this.#currentScenarioSuite
164166
)
165167

166168
log.info('✓ Session initialized')
@@ -184,15 +186,6 @@ class NightwatchDevToolsPlugin {
184186
const browserVersion = capabilities.browserVersion || (capabilities as any).version || ''
185187
log.info(`✓ Browser: ${browserName}${browserVersion ? ' ' + browserVersion : ''} (session: ${sessionId})`)
186188

187-
const chromeOptions = (capabilities as any)['goog:chromeOptions'] || (desiredCapabilities as any)['goog:chromeOptions'] || {}
188-
const mobileEmulation = chromeOptions.mobileEmulation
189-
if (mobileEmulation) {
190-
const device = mobileEmulation.deviceName
191-
? `device: ${mobileEmulation.deviceName}`
192-
: `${mobileEmulation.deviceMetrics?.width}x${mobileEmulation.deviceMetrics?.height} @${mobileEmulation.deviceMetrics?.pixelRatio}x`
193-
log.info(`📱 Mobile emulation active — ${device}`)
194-
}
195-
196189
const loggingPrefs = (capabilities as any)['goog:loggingPrefs'] || (desiredCapabilities as any)['goog:loggingPrefs'] || {}
197190
if (!loggingPrefs.performance) {
198191
log.warn('⚠ Network tab will be empty — add \'goog:loggingPrefs\': { performance: \'ALL\' } to your capabilities')
@@ -219,10 +212,11 @@ class NightwatchDevToolsPlugin {
219212
// Derive the feature name from the "Feature: <name>" header in the file,
220213
// falling back to the filename (e.g. "login") only if the file can't be read.
221214
let featureName: string = path.basename(featureUri, '.feature')
215+
let featureContent = ''
222216
const featureAbsPath = path.resolve(process.cwd(), featureUri)
223217
if (featureUri !== 'unknown.feature' && fs.existsSync(featureAbsPath)) {
224-
const content = fs.readFileSync(featureAbsPath, 'utf-8')
225-
const match = content.match(/^\s*Feature:\s*(.+)/m)
218+
featureContent = fs.readFileSync(featureAbsPath, 'utf-8')
219+
const match = featureContent.match(/^\s*Feature:\s*(.+)/m)
226220
if (match) featureName = match[1].trim()
227221

228222
this.sessionCapturer.captureSource(featureAbsPath).catch(() => {})
@@ -242,20 +236,73 @@ class NightwatchDevToolsPlugin {
242236
}
243237
}
244238

245-
const suite = this.suiteManager.getOrCreateSuite(
246-
featureUri, featureName, featureUri, [scenarioName]
239+
// Get or create the feature-level suite (no individual test names — scenarios go into suites[])
240+
const featureSuite = this.suiteManager.getOrCreateSuite(
241+
featureUri, featureName, featureUri, []
247242
)
248-
this.suiteManager.markSuiteAsRunning(suite)
249-
250-
const test = this.testManager.findTestInSuite(suite, scenarioName)
251-
if (test) {
252-
test.state = TEST_STATE.RUNNING as TestStats['state']
253-
test.start = new Date()
254-
test.end = null
255-
this.testReporter.onTestStart(test)
256-
this.#currentTest = test
243+
this.suiteManager.markSuiteAsRunning(featureSuite)
244+
245+
// Parse step keywords from the feature file
246+
const steps: Array<{ text: string }> = pickle.steps ?? []
247+
const stepKeywords = parseStepKeywords(featureContent, scenarioName, steps.length)
248+
249+
// Create a scenario sub-suite (child of feature suite)
250+
const scenarioUid = generateStableUid(featureUri, `scenario:${scenarioName}`)
251+
252+
const scenarioSuite: any = {
253+
uid: scenarioUid,
254+
cid: DEFAULTS.CID,
255+
title: scenarioName,
256+
fullTitle: `${featureName} ${scenarioName}`,
257+
parent: featureSuite.uid,
258+
type: 'suite' as const,
259+
file: featureUri,
260+
start: new Date(),
261+
state: 'running',
262+
end: null,
263+
tests: [],
264+
suites: [],
265+
hooks: [],
266+
_duration: 0
267+
}
268+
269+
// Create a TestStats entry for each step
270+
steps.forEach((step, i) => {
271+
const keyword = stepKeywords[i] || ''
272+
const stepLabel = keyword ? `${keyword} ${step.text}` : step.text
273+
const stepUid = generateStableUid(featureUri, `step:${scenarioName}:${step.text}`)
274+
scenarioSuite.tests.push({
275+
uid: stepUid,
276+
cid: DEFAULTS.CID,
277+
title: stepLabel,
278+
fullTitle: `${scenarioName} ${stepLabel}`,
279+
parent: scenarioUid,
280+
state: 'pending',
281+
start: new Date(),
282+
end: null,
283+
type: 'test' as const,
284+
file: featureUri,
285+
retries: 0,
286+
_duration: 0,
287+
hooks: []
288+
})
289+
})
290+
291+
// Add scenario sub-suite to the feature suite
292+
// (replace if already exists from a previous run)
293+
const existingIdx = featureSuite.suites.findIndex((s: any) => s.uid === scenarioUid)
294+
if (existingIdx !== -1) {
295+
featureSuite.suites[existingIdx] = scenarioSuite
296+
} else {
297+
featureSuite.suites.push(scenarioSuite)
257298
}
258299

300+
this.#currentScenarioSuite = scenarioSuite
301+
this.#currentStep = null
302+
this.#currentTest = null
303+
304+
this.testReporter.updateSuites()
305+
259306
if (!this.isScriptInjected) {
260307
this.browserProxy.wrapUrlMethod(browser)
261308
this.isScriptInjected = true
@@ -270,28 +317,44 @@ class NightwatchDevToolsPlugin {
270317
async #finalizeCucumberScenario(browser: NightwatchBrowser, result: any, pickle: any) {
271318
try {
272319
const status = String(result?.status ?? 'UNKNOWN').toUpperCase()
273-
const testState: TestStats['state'] =
320+
const scenarioState: TestStats['state'] =
274321
status === 'PASSED' ? TEST_STATE.PASSED :
275322
status === 'SKIPPED' ? TEST_STATE.SKIPPED :
276323
TEST_STATE.FAILED
277324

278-
if (this.#currentTest) {
279-
const duration = Date.now() - (this.#currentTest.start?.getTime() ?? Date.now())
280-
this.testManager.updateTestState(this.#currentTest, testState, new Date(), duration)
325+
if (this.#currentScenarioSuite) {
326+
const duration = Date.now() - (this.#currentScenarioSuite.start?.getTime() ?? Date.now())
327+
this.#currentScenarioSuite.state = scenarioState
328+
this.#currentScenarioSuite.end = new Date()
329+
this.#currentScenarioSuite._duration = duration
330+
331+
// Ensure any still-running or pending steps are marked appropriately
332+
for (const step of this.#currentScenarioSuite.tests) {
333+
if (typeof step !== 'string' && (step.state === 'running' || step.state === 'pending')) {
334+
step.state = scenarioState === TEST_STATE.PASSED ? TEST_STATE.PASSED : TEST_STATE.FAILED
335+
step.end = new Date()
336+
}
337+
}
281338

282339
const featureUri: string = pickle?.uri ?? 'unknown.feature'
283-
this.testManager.markTestAsProcessed(featureUri, this.#currentTest.title)
340+
this.testManager.markTestAsProcessed(featureUri, pickle?.name ?? '')
284341

285-
const suite = this.suiteManager.getSuite(featureUri)
286-
if (suite) this.suiteManager.finalizeSuite(suite)
342+
const featureSuite = this.suiteManager.getSuite(featureUri)
343+
if (featureSuite) {
344+
// Finalize is not called until all scenarios are done — just update state
345+
this.suiteManager.finalizeSuiteState(featureSuite)
346+
}
287347

288-
if (testState === TEST_STATE.PASSED) this.#passCount++
289-
else if (testState === TEST_STATE.SKIPPED) this.#skipCount++
348+
if (scenarioState === TEST_STATE.PASSED) this.#passCount++
349+
else if (scenarioState === TEST_STATE.SKIPPED) this.#skipCount++
290350
else this.#failCount++
291-
const icon = testState === TEST_STATE.PASSED ? '✅' : testState === TEST_STATE.SKIPPED ? '⏭' : '❌'
351+
const icon = scenarioState === TEST_STATE.PASSED ? '✅' : scenarioState === TEST_STATE.SKIPPED ? '⏭' : '❌'
292352
const durationSec = (duration / 1000).toFixed(2)
293353
log.info(` ${icon} ${pickle?.name ?? 'Unknown'} (${durationSec}s)`)
294354

355+
this.testReporter.updateSuites()
356+
this.#currentScenarioSuite = null
357+
this.#currentStep = null
295358
this.#currentTest = null
296359
}
297360

@@ -301,6 +364,39 @@ class NightwatchDevToolsPlugin {
301364
}
302365
}
303366

367+
/** Called from Cucumber BeforeStep hook — marks the step as running. */
368+
async cucumberBeforeStep(browser: NightwatchBrowser, pickleStep: any, _pickle: any) {
369+
if (!this.#currentScenarioSuite) return
370+
const stepText: string = pickleStep?.text ?? ''
371+
const step = (this.#currentScenarioSuite.tests as any[]).find(
372+
(t: any) => typeof t !== 'string' && (t.title.endsWith(stepText) || t.title === stepText)
373+
)
374+
if (step) {
375+
step.state = TEST_STATE.RUNNING
376+
step.start = new Date()
377+
step.end = null
378+
this.#currentStep = step
379+
this.testReporter.updateSuites()
380+
}
381+
}
382+
383+
/** Called from Cucumber AfterStep hook — records the step result. */
384+
async cucumberAfterStep(_browser: NightwatchBrowser, result: any, pickleStep: any, _pickle: any) {
385+
const step = this.#currentStep
386+
if (!step) return
387+
const status = String(result?.status ?? 'UNKNOWN').toUpperCase()
388+
const stepState: TestStats['state'] =
389+
status === 'PASSED' ? TEST_STATE.PASSED :
390+
status === 'SKIPPED' ? TEST_STATE.SKIPPED :
391+
TEST_STATE.FAILED
392+
step.state = stepState
393+
step.end = new Date()
394+
step._duration = Date.now() - (step.start?.getTime() ?? Date.now())
395+
this.#currentStep = null
396+
this.testReporter.updateSuites()
397+
void pickleStep // used by BeforeStep to find the step
398+
}
399+
304400
async beforeEach(browser: NightwatchBrowser) {
305401
if (this.#isCucumberRunner) return
306402

@@ -676,6 +772,42 @@ class NightwatchDevToolsPlugin {
676772
}
677773
}
678774

775+
/**
776+
* Extract BDD step keywords (Given/When/Then/And/But) from a feature file
777+
* for the steps belonging to the named scenario. The order of keywords
778+
* in the file matches the order of pickle.steps, so we just walk line-by-line.
779+
*/
780+
function parseStepKeywords(
781+
featureContent: string,
782+
scenarioName: string,
783+
stepCount: number
784+
): string[] {
785+
if (!featureContent || stepCount === 0) return Array(stepCount).fill('')
786+
787+
const lines = featureContent.split('\n')
788+
const stepRe = /^\s*(Given|When|Then|And|But)\s+/i
789+
790+
// Find the Scenario block that contains this scenario name
791+
const scenarioLineIdx = lines.findIndex(
792+
(l) => /^\s*Scenario:/i.test(l) && l.includes(scenarioName)
793+
)
794+
if (scenarioLineIdx === -1) return Array(stepCount).fill('')
795+
796+
const keywords: string[] = []
797+
for (let i = scenarioLineIdx + 1; i < lines.length && keywords.length < stepCount; i++) {
798+
// Stop at next Scenario or Feature header
799+
if (i > scenarioLineIdx && (/^\s*Scenario:/i.test(lines[i]) || /^\s*Feature:/i.test(lines[i]))) {
800+
break
801+
}
802+
const m = stepRe.exec(lines[i])
803+
if (m) keywords.push(m[1])
804+
}
805+
806+
// Pad with empty strings if fewer keywords were found than steps
807+
while (keywords.length < stepCount) keywords.push('')
808+
return keywords
809+
}
810+
679811
/**
680812
* The absolute path to the compiled Cucumber hooks file.
681813
* Kept for backwards compatibility — prefer using `withCucumber()` instead.

packages/nightwatch-devtools/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export interface SuiteStats {
108108
suites: SuiteStats[]
109109
hooks: any[]
110110
_duration: number
111+
parent?: string
111112
}
112113

113114
export interface Metadata {

0 commit comments

Comments
 (0)