Skip to content

Commit 1d3b23a

Browse files
committed
Test lens changes for test suit for all frameworks (Mocha, Jasmine, and Cucumber)
1 parent b334fd7 commit 1d3b23a

File tree

5 files changed

+277
-269
lines changed

5 files changed

+277
-269
lines changed

packages/service/src/constants.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ export const DEFAULT_LAUNCH_CAPS: WebdriverIO.Capabilities = {
88
browserName: 'chrome',
99
'goog:chromeOptions': {
1010
// production:
11-
// args: ['--window-size=1200,800']
11+
args: ['--window-size=1200,800']
1212
// development:
13-
args: ['--window-size=1600,1200']
13+
// args: ['--window-size=1600,1200', '--auto-open-devtools-for-tabs']
1414
}
1515
}
1616

@@ -25,4 +25,52 @@ export const CONTEXT_CHANGE_COMMANDS = [
2525
'url', 'back', 'forward', 'refresh', 'switchFrame', 'newWindow', 'createWindow', 'closeWindow'
2626
]
2727

28+
/**
29+
* Existing pattern (kept for any external consumers)
30+
*/
2831
export const SPEC_FILE_PATTERN = /(test|spec|features)[\\/].*\.(js|ts)$/i
32+
33+
/**
34+
* Parser options
35+
*/
36+
export const PARSE_PLUGINS = [
37+
'typescript',
38+
'jsx',
39+
'decorators-legacy',
40+
'classProperties',
41+
'dynamicImport'
42+
] as const
43+
44+
/**
45+
* Test framework identifiers
46+
*/
47+
export const TEST_FN_NAMES = ['it', 'test', 'specify', 'fit', 'xit'] as const
48+
export const SUITE_FN_NAMES = ['describe', 'context', 'suite'] as const
49+
export const STEP_FN_NAMES = ['Given', 'When', 'Then', 'And', 'But', 'defineStep'] as const
50+
51+
/**
52+
* File/type recognizers
53+
*/
54+
export const STEP_FILE_RE = /\.(?:steps?)\.[cm]?[jt]sx?$/i
55+
export const STEP_DIR_RE = /(?:^|\/)(?:step[-_]?definitions|steps)\/.+\.[cm]?[jt]sx?$/i
56+
export const SPEC_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/i
57+
export const FEATURE_FILE_RE = /\.feature$/i
58+
export const SOURCE_FILE_EXT_RE = /\.(?:[cm]?js|[cm]?ts)x?$/
59+
60+
/**
61+
* Gherkin Feature/Scenario line
62+
*/
63+
export const FEATURE_OR_SCENARIO_LINE_RE = /^\s*(Feature|Scenario(?: Outline)?):\s*(.+)\s*$/i
64+
65+
/**
66+
* Step definition textual scan regexes
67+
*/
68+
export const STEP_DEF_REGEX_LITERAL_RE = /\b(Given|When|Then|And|But)\s*\(\s*(\/(?:\\.|[^/\\])+\/[gimsuy]*)/
69+
export const STEP_DEF_STRING_RE = /\b(Given|When|Then|And|But)\s*\(\s*(['`])([^'`\\]*(?:\\.[^'`\\]*)*)\2/
70+
71+
/**
72+
* Step directories discovery
73+
*/
74+
export const STEPS_DIR_CANDIDATES = ['step-definitions', 'step_definitions', 'steps'] as const
75+
export const STEPS_DIR_ASCENT_MAX = 6
76+
export const STEPS_GLOBAL_SEARCH_MAX_DEPTH = 5

packages/service/src/reporter.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import WebdriverIOReporter, { type SuiteStats, type TestStats } from '@wdio/reporter'
2-
import { enrichTestStats, setCurrentSpecFile } from './utils.js'
2+
import { enrichTestStats, setCurrentSpecFile, enrichSuiteStats } from './utils.js'
33

44
export class TestReporter extends WebdriverIOReporter {
55
#report: (data: any) => void
66
#currentSpecFile?: string
7+
#suitePath: string[] = []
78

89
constructor (options: any, report: (data: any) => void) {
910
super(options)
@@ -14,11 +15,21 @@ export class TestReporter extends WebdriverIOReporter {
1415
super.onSuiteStart(suiteStats)
1516
this.#currentSpecFile = suiteStats.file
1617
setCurrentSpecFile(suiteStats.file)
18+
19+
// Push title if non-empty
20+
if (suiteStats.title) this.#suitePath.push(suiteStats.title)
21+
22+
// Enrich and set callSource for suites
23+
enrichSuiteStats(suiteStats as any, this.#currentSpecFile, this.#suitePath)
24+
if ((suiteStats as any).file && (suiteStats as any).line != null) {
25+
;(suiteStats as any).callSource = `${(suiteStats as any).file}:${(suiteStats as any).line}`
26+
}
27+
1728
this.#sendUpstream()
1829
}
1930

2031
onTestStart(testStats: TestStats): void {
21-
//Enrich testStats with file + line info
32+
// Enrich testStats with callSource info
2233
enrichTestStats(testStats, this.#currentSpecFile)
2334
if ((testStats as any).file && (testStats as any).line != null) {
2435
;(testStats as any).callSource = `${(testStats as any).file}:${(testStats as any).line}`
@@ -34,8 +45,15 @@ export class TestReporter extends WebdriverIOReporter {
3445

3546
onSuiteEnd(suiteStats: SuiteStats): void {
3647
super.onSuiteEnd(suiteStats)
37-
this.#currentSpecFile = undefined
38-
setCurrentSpecFile(undefined)
48+
// Pop the suite we pushed on start
49+
if (suiteStats.title && this.#suitePath[this.#suitePath.length - 1] === suiteStats.title) {
50+
this.#suitePath.pop()
51+
}
52+
// Only clear when the last suite ends
53+
if (this.#suitePath.length === 0) {
54+
this.#currentSpecFile = undefined
55+
setCurrentSpecFile(undefined)
56+
}
3957
this.#sendUpstream()
4058
}
4159

packages/service/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,8 @@ declare module '@wdio/reporter' {
7373
line?: number
7474
column?: number
7575
}
76+
77+
interface SuiteStats {
78+
line?: string
79+
}
7680
}

packages/service/src/utils.ts

Lines changed: 112 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ import * as babelTraverse from '@babel/traverse'
66
import type { NodePath } from '@babel/traverse'
77
import 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+
927
const require = createRequire(import.meta.url)
1028
const stackTrace = require('stack-trace') as typeof import('stack-trace')
1129
const _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-
/\.(?:steps?)\.[cm]?[jt]sx?$/i.test(fn) ||
181-
/(?:^|\/)(?:step[-_]?definitions|steps)\/.+\.[cm]?[jt]sx?$/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) => /\.(?:test|spec)\.[cm]?[jt]sx?$/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) => /\.feature$/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
209214
function _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
263267
function _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 (/\.(?:[cm]?js|[cm]?ts)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(Given|When|Then|And|But)\s*\(\s*(\/(?:\\.|[^/\\])+\/[gimsuy]*)/)
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(Given|When|Then|And|But)\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) {
427431
function escapeRegExp(s: string) {
428432
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
429433
}
434+
430435
function 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 (/^(Given|When|Then|And|But)\b/i.test(title)) {
476-
const stepLoc = findStepDefinitionLocation(title, /\.feature$/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 && !/\.feature$/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

Comments
 (0)