@@ -5,7 +5,7 @@ import url from 'node:url'
55import { createRequire } from 'node:module'
66import kill from 'tree-kill'
77import type { RunnerRequestBody } from './types.js'
8- import { WDIO_CONFIG_FILENAMES } from './types.js'
8+ import { WDIO_CONFIG_FILENAMES , NIGHTWATCH_CONFIG_FILENAMES } from './types.js'
99
1010const require = createRequire ( import . meta. url )
1111const wdioBin = resolveWdioBin ( )
@@ -104,25 +104,70 @@ const FRAMEWORK_FILTERS: Record<
104104const DEFAULT_FILTERS = ( { specArg } : { specArg ?: string } ) =>
105105 specArg ? [ '--spec' , specArg ] : [ ]
106106
107+ // Nightwatch CLI: positional spec file + optional --testcase filter
108+ FRAMEWORK_FILTERS . nightwatch = ( { specArg, payload } : { specArg ?: string ; payload : RunnerRequestBody } ) => {
109+ const filters : string [ ] = [ ]
110+ if ( specArg ) {
111+ // Nightwatch doesn't support file:line — strip any trailing line number
112+ filters . push ( specArg . split ( ':' ) [ 0 ] )
113+ }
114+ if ( payload . entryType === 'test' && payload . fullTitle ) {
115+ filters . push ( '--testcase' , payload . fullTitle )
116+ }
117+ return filters
118+ }
119+
120+ // Nightwatch + Cucumber: feature files are resolved via the config's feature_path.
121+ // Never pass .feature files as positional args — Nightwatch rejects them.
122+ // Nightwatch forwards --name and --tags to the underlying Cucumber runner.
123+ FRAMEWORK_FILTERS [ 'nightwatch-cucumber' ] = ( { payload } : { specArg ?: string ; payload : RunnerRequestBody } ) => {
124+ const filters : string [ ] = [ ]
125+
126+ // Only pass --name for scenario-level reruns. Feature/file-level suites
127+ // (suiteType === 'feature') run all their scenarios, so no --name filter.
128+ const isFeatureLevel = payload . suiteType === 'feature' || payload . runAll
129+ if ( ! isFeatureLevel && payload . fullTitle ) {
130+ // Wrap as an anchored exact regex so "Scenario A" never also matches
131+ // "Scenario A-1" (Cucumber treats --name as a regex).
132+ const escaped = payload . fullTitle . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' )
133+ filters . push ( '--name' , `^${ escaped } $` )
134+ }
135+ return filters
136+ }
137+
107138class TestRunner {
108139 #child?: ChildProcess
109140 #lastPayload?: RunnerRequestBody
110141 #baseDir = process . cwd ( )
111142
112143 async run ( payload : RunnerRequestBody ) {
113144 if ( this . #child) {
114- throw new Error ( 'A test run is already in progress' )
145+ // A run is already in progress — stop it first so the new one can start.
146+ this . stop ( )
147+ // Give the killed process a moment to release resources before spawning.
148+ await new Promise < void > ( ( resolve ) => setTimeout ( resolve , 500 ) )
115149 }
116150
151+ const isNightwatch = ( payload . framework || '' ) . toLowerCase ( ) . startsWith ( 'nightwatch' )
117152 const configPath = this . #resolveConfigPath( payload )
118153 this . #baseDir = process . env . DEVTOOLS_RUNNER_CWD || path . dirname ( configPath )
119154
120- const args = [
121- wdioBin ,
122- 'run' ,
123- configPath ,
124- ...this . #buildFilters( payload )
125- ] . filter ( Boolean )
155+ let args : string [ ]
156+ if ( isNightwatch ) {
157+ const nightwatchBin = resolveNightwatchBin ( this . #baseDir)
158+ args = [
159+ nightwatchBin ,
160+ '--config' , configPath ,
161+ ...this . #buildFilters( payload )
162+ ] . filter ( Boolean )
163+ } else {
164+ args = [
165+ wdioBin ,
166+ 'run' ,
167+ configPath ,
168+ ...this . #buildFilters( payload )
169+ ] . filter ( Boolean )
170+ }
126171
127172 const childEnv = { ...process . env }
128173 if ( payload . devtoolsHost && payload . devtoolsPort ) {
@@ -250,14 +295,16 @@ class TestRunner {
250295 ? path . dirname ( this . #toFsPath( specCandidate ) )
251296 : undefined
252297
298+ const isNightwatch = ( payload ?. framework || '' ) . toLowerCase ( ) . startsWith ( 'nightwatch' )
253299 const candidates = this . #dedupeCandidates( [
254300 payload ?. configFile ,
255301 this . #lastPayload?. configFile ,
256302 process . env . DEVTOOLS_WDIO_CONFIG ,
257- this . #findConfigFromSpec( specCandidate ) ,
258- ...this . #expandDefaultConfigsFor( this . #baseDir) ,
259- ...this . #expandDefaultConfigsFor( path . resolve ( this . #baseDir, 'example' ) ) ,
260- ...this . #expandDefaultConfigsFor( specDir )
303+ process . env . DEVTOOLS_NIGHTWATCH_CONFIG ,
304+ this . #findConfigFromSpec( specCandidate , isNightwatch ) ,
305+ ...this . #expandDefaultConfigsFor( this . #baseDir, isNightwatch ) ,
306+ ...this . #expandDefaultConfigsFor( path . resolve ( this . #baseDir, 'example' ) , isNightwatch ) ,
307+ ...this . #expandDefaultConfigsFor( specDir , isNightwatch )
261308 ] )
262309
263310 for ( const candidate of candidates ) {
@@ -267,24 +314,28 @@ class TestRunner {
267314 }
268315 }
269316
317+ const runner = isNightwatch ? 'Nightwatch' : 'WDIO'
270318 throw new Error (
271- `Cannot locate WDIO config. Tried:\n${ candidates
319+ `Cannot locate ${ runner } config. Tried:\n${ candidates
272320 . map ( ( c ) => ` • ${ this . #toFsPath( c ) } ` )
273321 . join ( '\n' ) } `
274322 )
275323 }
276324
277- #findConfigFromSpec( specFile ?: string ) {
325+ #findConfigFromSpec( specFile ?: string , nightwatch = false ) {
278326 if ( ! specFile ) {
279327 return undefined
280328 }
281329
330+ const filenames = nightwatch
331+ ? [ ...NIGHTWATCH_CONFIG_FILENAMES , ...WDIO_CONFIG_FILENAMES ]
332+ : WDIO_CONFIG_FILENAMES
282333 const fsSpec = this . #toFsPath( specFile )
283334 let dir = path . dirname ( fsSpec )
284335 const root = path . parse ( dir ) . root
285336
286337 while ( dir && dir !== root ) {
287- for ( const file of WDIO_CONFIG_FILENAMES ) {
338+ for ( const file of filenames ) {
288339 const candidate = path . join ( dir , file )
289340 if ( fs . existsSync ( candidate ) ) {
290341 return candidate
@@ -300,11 +351,14 @@ class TestRunner {
300351 return undefined
301352 }
302353
303- #expandDefaultConfigsFor( baseDir ?: string ) {
354+ #expandDefaultConfigsFor( baseDir ?: string , nightwatch = false ) {
304355 if ( ! baseDir ) {
305356 return [ ]
306357 }
307- return WDIO_CONFIG_FILENAMES . map ( ( file ) => path . resolve ( baseDir , file ) )
358+ const filenames = nightwatch
359+ ? [ ...NIGHTWATCH_CONFIG_FILENAMES , ...WDIO_CONFIG_FILENAMES ]
360+ : WDIO_CONFIG_FILENAMES
361+ return filenames . map ( ( file ) => path . resolve ( baseDir , file ) )
308362 }
309363
310364 #dedupeCandidates( values : Array < string | undefined > ) {
@@ -319,6 +373,57 @@ class TestRunner {
319373 }
320374}
321375
376+ function resolveNightwatchBin ( baseDir : string ) : string {
377+ const envOverride = process . env . DEVTOOLS_NIGHTWATCH_BIN
378+ if ( envOverride ) {
379+ const resolved = path . isAbsolute ( envOverride )
380+ ? envOverride
381+ : path . resolve ( process . cwd ( ) , envOverride )
382+ if ( fs . existsSync ( resolved ) ) {
383+ return resolved
384+ }
385+ }
386+
387+ // Walk up from baseDir looking for node_modules/nightwatch/package.json
388+ // and resolve the actual JS entry (avoids running the shell-script wrapper
389+ // at node_modules/.bin/nightwatch directly via node).
390+ let dir = baseDir
391+ const root = path . parse ( dir ) . root
392+ while ( dir !== root ) {
393+ const nightwatchPkgPath = path . join (
394+ dir ,
395+ 'node_modules' ,
396+ 'nightwatch' ,
397+ 'package.json'
398+ )
399+ if ( fs . existsSync ( nightwatchPkgPath ) ) {
400+ try {
401+ const pkg = JSON . parse ( fs . readFileSync ( nightwatchPkgPath , 'utf8' ) )
402+ const nightwatchDir = path . join ( dir , 'node_modules' , 'nightwatch' )
403+ const binEntry =
404+ typeof pkg . bin === 'string'
405+ ? pkg . bin
406+ : ( pkg . bin ?. nightwatch ?? pkg . bin ?. nw )
407+ if ( binEntry ) {
408+ const jsPath = path . resolve ( nightwatchDir , binEntry )
409+ if ( fs . existsSync ( jsPath ) ) {
410+ return jsPath
411+ }
412+ }
413+ } catch {
414+ // malformed package.json — continue walking
415+ }
416+ }
417+ const parent = path . dirname ( dir )
418+ if ( parent === dir ) break
419+ dir = parent
420+ }
421+
422+ throw new Error (
423+ 'Cannot find nightwatch binary. Install nightwatch locally or set DEVTOOLS_NIGHTWATCH_BIN env var.'
424+ )
425+ }
426+
322427function resolveWdioBin ( ) {
323428 const envOverride = process . env . DEVTOOLS_WDIO_BIN
324429 if ( envOverride ) {
0 commit comments