@@ -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'
2727import { 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 * F e a t u r e : \s * ( .+ ) / m)
218+ featureContent = fs . readFileSync ( featureAbsPath , 'utf-8' )
219+ const match = featureContent . match ( / ^ \s * F e a t u r e : \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 * ( G i v e n | W h e n | T h e n | A n d | B u t ) \s + / i
789+
790+ // Find the Scenario block that contains this scenario name
791+ const scenarioLineIdx = lines . findIndex (
792+ ( l ) => / ^ \s * S c e n a r i o : / 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 * S c e n a r i o : / i. test ( lines [ i ] ) || / ^ \s * F e a t u r e : / 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.
0 commit comments