@@ -6,6 +6,24 @@ import * as babelTraverse from '@babel/traverse'
66import type { NodePath } from '@babel/traverse'
77import type { CallExpression } from '@babel/types'
88
9+ import {
10+ PARSE_PLUGINS ,
11+ TEST_FN_NAMES ,
12+ SUITE_FN_NAMES ,
13+ STEP_FN_NAMES ,
14+ STEP_FILE_RE ,
15+ STEP_DIR_RE ,
16+ SPEC_FILE_RE ,
17+ FEATURE_FILE_RE ,
18+ FEATURE_OR_SCENARIO_LINE_RE ,
19+ STEP_DEF_REGEX_LITERAL_RE ,
20+ STEP_DEF_STRING_RE ,
21+ SOURCE_FILE_EXT_RE ,
22+ STEPS_DIR_CANDIDATES ,
23+ STEPS_DIR_ASCENT_MAX ,
24+ STEPS_GLOBAL_SEARCH_MAX_DEPTH
25+ } from './constants.js'
26+
927const require = createRequire ( import . meta. url )
1028const stackTrace = require ( 'stack-trace' ) as typeof import ( 'stack-trace' )
1129const _astCache = new Map < string , any [ ] > ( )
@@ -51,17 +69,6 @@ function rootCalleeName(callee: any): string | undefined {
5169 return
5270}
5371
54- /**
55- * Babel parse options (be permissive)
56- */
57- const PARSE_PLUGINS = [
58- 'typescript' ,
59- 'jsx' ,
60- 'decorators-legacy' ,
61- 'classProperties' ,
62- 'dynamicImport'
63- ] as const
64-
6572/**
6673 * Parse a JS/TS test/spec file to collect suite/test calls (Mocha/Jasmine) with full title path
6774 */
@@ -87,8 +94,8 @@ export function findTestLocations(filePath: string) {
8794 const out : Loc [ ] = [ ]
8895 const suiteStack : string [ ] = [ ]
8996
90- const isSuite = ( n ?: string ) => ! ! n && [ 'describe' , 'context' , 'suite' , 'Feature' ] . includes ( n )
91- const isTest = ( n ?: string ) => ! ! n && [ 'it' , 'test' , 'specify' , 'fit' , 'xit' ] . includes ( n )
97+ const isSuite = ( n ?: string ) => ! ! n && ( SUITE_FN_NAMES as readonly string [ ] ) . includes ( n ) || n === 'Feature'
98+ const isTest = ( n ?: string ) => ! ! n && ( TEST_FN_NAMES as readonly string [ ] ) . includes ( n )
9299
93100 const staticTitle = ( node : any ) : string | undefined => {
94101 if ( ! node ) return
@@ -176,17 +183,14 @@ export function getCurrentTestLocation() {
176183
177184 const step = pick ( ( fr ) => {
178185 const fn = fr . getFileName ( ) as string
179- return (
180- / \. (?: s t e p s ? ) \. [ c m ] ? [ j t ] s x ? $ / i. test ( fn ) ||
181- / (?: ^ | \/ ) (?: s t e p [ - _ ] ? d e f i n i t i o n s | s t e p s ) \/ .+ \. [ c m ] ? [ j t ] s x ? $ / i. test ( fn )
182- )
186+ return STEP_FILE_RE . test ( fn ) || STEP_DIR_RE . test ( fn )
183187 } )
184188 if ( step ) return step
185189
186- const spec = pick ( ( fr ) => / \. (?: t e s t | s p e c ) \. [ c m ] ? [ j t ] s x ? $ / i . test ( fr . getFileName ( ) as string ) )
190+ const spec = pick ( ( fr ) => SPEC_FILE_RE . test ( fr . getFileName ( ) as string ) )
187191 if ( spec ) return spec
188192
189- const feature = pick ( ( fr ) => / \. f e a t u r e $ / i . test ( fr . getFileName ( ) as string ) )
193+ const feature = pick ( ( fr ) => FEATURE_FILE_RE . test ( fr . getFileName ( ) as string ) )
190194 if ( feature ) return feature
191195
192196 return null
@@ -206,11 +210,11 @@ type StepDef = {
206210 column : number
207211}
208212
213+ // Look for step-definitions directory by ascending from a base directory
209214function _findStepsDir ( startDir : string ) : string | undefined {
210- const candidates = [ 'step-definitions' , 'step_definitions' , 'steps' ]
211215 let dir = startDir
212- for ( let i = 0 ; i < 6 ; i ++ ) {
213- for ( const c of candidates ) {
216+ for ( let i = 0 ; i < STEPS_DIR_ASCENT_MAX ; i ++ ) {
217+ for ( const c of STEPS_DIR_CANDIDATES ) {
214218 const p = path . join ( dir , c )
215219 if ( fs . existsSync ( p ) && fs . statSync ( p ) . isDirectory ( ) ) return p
216220 }
@@ -228,16 +232,15 @@ function _findStepsDirGlobal(): string | undefined {
228232
229233 const root = process . cwd ( )
230234 const queue : { dir : string ; depth : number } [ ] = [ { dir : root , depth : 0 } ]
231- const maxDepth = 5
235+ const maxDepth = STEPS_GLOBAL_SEARCH_MAX_DEPTH
232236 while ( queue . length ) {
233237 const { dir, depth } = queue . shift ( ) !
234238 if ( depth > maxDepth ) continue
235239
236240 // Look for a features folder here
237241 const featuresDir = path . join ( dir , 'features' )
238242 if ( fs . existsSync ( featuresDir ) && fs . statSync ( featuresDir ) . isDirectory ( ) ) {
239- const cands = [ 'step-definitions' , 'step_definitions' , 'steps' ]
240- for ( const c of cands ) {
243+ for ( const c of STEPS_DIR_CANDIDATES ) {
241244 const p = path . join ( featuresDir , c )
242245 if ( fs . existsSync ( p ) && fs . statSync ( p ) . isDirectory ( ) ) {
243246 _globalStepsDir = p
@@ -260,13 +263,14 @@ function _findStepsDirGlobal(): string | undefined {
260263 return undefined
261264}
262265
266+ // Recursively list all source files in a directory
263267function _listFiles ( dir : string ) : string [ ] {
264268 const out : string [ ] = [ ]
265269 for ( const entry of fs . readdirSync ( dir ) ) {
266270 const full = path . join ( dir , entry )
267271 const st = fs . statSync ( full )
268272 if ( st . isDirectory ( ) ) out . push ( ..._listFiles ( full ) )
269- else if ( / \. (?: [ c m ] ? j s | [ c m ] ? t s ) x ? $ / . test ( entry ) ) out . push ( full )
273+ else if ( SOURCE_FILE_EXT_RE . test ( entry ) ) out . push ( full )
270274 }
271275 return out
272276}
@@ -279,7 +283,7 @@ function _collectStepDefsFromText(file: string): StepDef[] {
279283 for ( let i = 0 ; i < lines . length ; i ++ ) {
280284 const line = lines [ i ]
281285 // Regex step: Given(/^...$/i, ...)
282- const mRe = line . match ( / \b ( G i v e n | W h e n | T h e n | A n d | B u t ) \s * \( \s * ( \/ (?: \\ . | [ ^ / \\ ] ) + \/ [ g i m s u y ] * ) / )
286+ const mRe = line . match ( STEP_DEF_REGEX_LITERAL_RE )
283287 if ( mRe ) {
284288 const lit = mRe [ 2 ] // like /pattern/flags
285289 const lastSlash = lit . lastIndexOf ( '/' )
@@ -299,7 +303,7 @@ function _collectStepDefsFromText(file: string): StepDef[] {
299303 }
300304 }
301305 // String step: Given('I do X', ...)
302- const mStr = line . match ( / \b ( G i v e n | W h e n | T h e n | A n d | B u t ) \s * \( \s * ( [ ' ` ] ) ( [ ^ ' ` \\ ] * (?: \\ . [ ^ ' ` \\ ] * ) * ) \2 / )
306+ const mStr = line . match ( STEP_DEF_STRING_RE )
303307 if ( mStr ) {
304308 const keyword = mStr [ 1 ]
305309 const text = mStr [ 3 ]
@@ -341,7 +345,7 @@ function _collectStepDefs(stepsDir: string): StepDef[] {
341345 const prop = ( callee as any ) . property
342346 if ( prop ?. type === 'Identifier' ) name = prop . name
343347 }
344- if ( ! name || ! [ 'Given' , 'When' , 'Then' , 'And' , 'But' , 'defineStep' ] . includes ( name ) ) return
348+ if ( ! name || ! ( STEP_FN_NAMES as readonly string [ ] ) . includes ( name ) ) return
345349
346350 const arg = p . node . arguments ?. [ 0 ] as any
347351 const loc = { file, line : p . node . loc ?. start . line ?? 1 , column : p . node . loc ?. start . column ?? 0 }
@@ -427,6 +431,7 @@ function normalizeFullTitle(full?: string) {
427431function escapeRegExp ( s : string ) {
428432 return s . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' )
429433}
434+
430435function offsetToLineCol ( src : string , offset : number ) {
431436 let line = 1 , col = 1
432437 for ( let i = 0 ; i < offset && i < src . length ; i ++ ) {
@@ -443,7 +448,23 @@ function findTestLocationByText(file: string, title: string) {
443448 try {
444449 const src = fs . readFileSync ( file , 'utf-8' )
445450 const q = `(['"\`])${ escapeRegExp ( title ) } \\1`
446- const call = String . raw `\b(?:it|test|specify|fit|xit)\s*\(\s*${ q } `
451+ const call = String . raw `\b(?:${ ( TEST_FN_NAMES as readonly string [ ] ) . join ( '|' ) } )\s*\(\s*${ q } `
452+ const re = new RegExp ( call )
453+ const m = re . exec ( src )
454+ if ( m && typeof m . index === 'number' ) {
455+ const { line, column } = offsetToLineCol ( src , m . index )
456+ return { file, line, column }
457+ }
458+ } catch { }
459+ return undefined
460+ }
461+
462+ // Find describe/context/suite("<title>", ...) by text as a fallback
463+ function findSuiteLocationByText ( file : string , title : string ) {
464+ try {
465+ const src = fs . readFileSync ( file , 'utf-8' )
466+ const q = `(['"\`])${ escapeRegExp ( title ) } \\1`
467+ const call = String . raw `\b(?:${ ( SUITE_FN_NAMES as readonly string [ ] ) . join ( '|' ) } )\s*\(\s*${ q } `
447468 const re = new RegExp ( call )
448469 const m = re . exec ( src )
449470 if ( m && typeof m . index === 'number' ) {
@@ -473,7 +494,7 @@ export function enrichTestStats(testStats: any, hintFile?: string) {
473494
474495 // Cucumber-like step: resolve step-definition location
475496 if ( / ^ ( G i v e n | W h e n | T h e n | A n d | B u t ) \b / i. test ( title ) ) {
476- const stepLoc = findStepDefinitionLocation ( title , / \. f e a t u r e $ / i . test ( String ( hint ) ) ? hint : undefined )
497+ const stepLoc = findStepDefinitionLocation ( title , FEATURE_FILE_RE . test ( String ( hint ) ) ? hint : undefined )
477498 if ( stepLoc ) {
478499 Object . assign ( testStats , stepLoc )
479500 return
@@ -488,7 +509,7 @@ export function enrichTestStats(testStats: any, hintFile?: string) {
488509 hintFile ||
489510 CURRENT_SPEC_FILE
490511
491- if ( file && ! / \. f e a t u r e $ / i . test ( file ) ) {
512+ if ( file && ! FEATURE_FILE_RE . test ( file ) ) {
492513 if ( ! _astCache . has ( file ) ) {
493514 try {
494515 _astCache . set ( file , findTestLocations ( file ) )
@@ -522,3 +543,62 @@ export function enrichTestStats(testStats: any, hintFile?: string) {
522543 Object . assign ( testStats , runtimeLoc )
523544 }
524545}
546+
547+ /**
548+ * Enrich a suite with file + line
549+ * - Mocha/Jasmine: map "describe/context" by title path using AST
550+ * - Cucumber: find Feature/Scenario line in .feature file
551+ */
552+ export function enrichSuiteStats (
553+ suiteStats : any ,
554+ hintFile ?: string ,
555+ suitePath : string [ ] = [ ]
556+ ) {
557+ const title = String ( suiteStats ?. title ?? '' ) . trim ( )
558+ const file = ( suiteStats as any ) . file || hintFile || CURRENT_SPEC_FILE
559+ if ( ! title || ! file ) return
560+
561+ // Cucumber: feature/scenario line
562+ if ( FEATURE_FILE_RE . test ( file ) ) {
563+ try {
564+ const src = fs . readFileSync ( file , 'utf-8' ) . split ( / \r ? \n / )
565+ const norm = ( s : string ) => s . trim ( ) . replace ( / \s + / g, ' ' )
566+ const want = norm ( title )
567+ for ( let i = 0 ; i < src . length ; i ++ ) {
568+ const m = src [ i ] . match ( FEATURE_OR_SCENARIO_LINE_RE )
569+ if ( m && norm ( m [ 2 ] ) === want ) {
570+ Object . assign ( suiteStats , { file, line : i + 1 , column : 1 } )
571+ return
572+ }
573+ }
574+ } catch { }
575+ return
576+ }
577+
578+ // Mocha/Jasmine: AST first
579+ try {
580+ if ( ! _astCache . has ( file ) ) _astCache . set ( file , findTestLocations ( file ) )
581+ const locs = _astCache . get ( file ) as any [ ] | undefined
582+ if ( locs ?. length ) {
583+ const match =
584+ locs . find ( l => l . type === 'suite'
585+ && Array . isArray ( l . titlePath )
586+ && l . titlePath . length === suitePath . length
587+ && l . titlePath . every ( ( t : string , i : number ) => t === suitePath [ i ] ) ) ||
588+ locs . find ( l => l . type === 'suite' && l . titlePath . at ( - 1 ) === title )
589+
590+ if ( match ?. line ) {
591+ Object . assign ( suiteStats , { file, line : match . line , column : match . column } )
592+ return
593+ }
594+ }
595+ } catch {
596+ // ignore
597+ }
598+
599+ // Fallback: text search
600+ const textLoc = findSuiteLocationByText ( file , title )
601+ if ( textLoc ) {
602+ Object . assign ( suiteStats , textLoc )
603+ }
604+ }
0 commit comments