Skip to content

Commit 3f135b9

Browse files
DavertMikclaude
andcommitted
feat: single-file suite discovery (scanFile + forSuite auto-title)
Two additions that close the "I have a file, give me its suites" gap without requiring a config or a runtime Mocha object: - Reflection.scanFile(filePath) returns { suites, tests } for a file. Reuses the walker ProjectReflection uses internally. Tests are tagged with their parent suite by source order. Works on JS, TS, and CJS; throws UnsupportedSourceError on Gherkin. - Reflection.forSuite now auto-detects the suite title when only a file is provided. Single Feature → used automatically. Multiple Features → AmbiguousLocateError with the candidate titles. None → NotFoundError. Also accepts a bare path string as shorthand (Reflection.forSuite('./auth.js')). SuiteReflection grows one small helper (_autoDetectTitle) that only runs when the suite-like object has no title. Existing callers that pass { title, file } are unaffected. 11 new unit tests covering scanFile across single/multi-Feature files and both engines, plus auto-title detection, ambiguity, not-found, and the bare-path shorthand. Totals: 224 tests across 24 files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b9a9fc2 commit 3f135b9

7 files changed

Lines changed: 256 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.6.0 — Unreleased
4+
5+
Added single-file discovery without a config or a runtime Mocha suite object.
6+
7+
- `Reflection.scanFile(filePath)` returns `{ suites, tests }` for a file — every `Feature(...)` and `Scenario(...)` with its parent suite, byte range, and line. Reuses the same walker `ProjectReflection` uses internally.
8+
- `Reflection.forSuite({ file })` now auto-detects the suite title when the file has exactly one `Feature(...)`. Throws `AmbiguousLocateError` if multiple exist, `NotFoundError` if none. `forSuite` also accepts a bare path string (`Reflection.forSuite('./auth.js')`) as a shorthand.
9+
310
## 0.5.0 — Unreleased
411

512
Added hook reflection to `SuiteReflection` for `Before`, `After`, `BeforeSuite`, and `AfterSuite`.

docs/api/reflection.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,32 @@ Returns a [`TestReflection`](./test-reflection.md).
2020
|---|---|---|
2121
| `test` | `TestLike` | Object with `title`, `file`, and optional `opts.data`, `meta`, `tags`. |
2222

23-
## `Reflection.forSuite(suite)`
23+
## `Reflection.forSuite(suiteOrPath)`
2424

25-
Returns a [`SuiteReflection`](./suite-reflection.md).
25+
Returns a [`SuiteReflection`](./suite-reflection.md). Accepts any of:
26+
27+
```js
28+
Reflection.forSuite(suite) // live runtime suite object { title, file, ... }
29+
Reflection.forSuite({ title, file }) // suite-like object
30+
Reflection.forSuite({ file: './test/auth.js' }) // no title — auto-detected
31+
Reflection.forSuite('./test/auth.js') // bare path — shorthand for { file: ... }
32+
```
33+
34+
When the `title` is omitted, the suite is auto-detected from the file: if there's exactly one `Feature(...)` call, it's used. If there are multiple, `AmbiguousLocateError` is thrown with the list of candidate titles. If there are none, `NotFoundError`.
35+
36+
## `Reflection.scanFile(filePath)`
37+
38+
Parses a file and returns every `Feature(...)` and `Scenario(...)` it contains, without constructing any reflection objects. Useful for lightweight discovery when you don't need a full `ProjectReflection`.
39+
40+
```js
41+
Reflection.scanFile('./test/auth.js')
42+
// {
43+
// suites: [{ title: 'Auth', file: '...', line: 1, range: {...} }, ...],
44+
// tests: [{ title: 'login works', suite: 'Auth', file: '...', line: 3, range: {...} }, ...],
45+
// }
46+
```
47+
48+
Each test entry is tagged with the `suite` whose `Feature(...)` precedes it in source order. Throws `UnsupportedSourceError` on Gherkin `.feature` files.
2649

2750
## `Reflection.batch(filePath)`
2851

docs/api/suite-reflection.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ A suite's "members" are the top-level `Scenario` / `Data().Scenario` statements
77
## Construction
88

99
```js
10-
Reflection.forSuite(suite)
10+
Reflection.forSuite(suite) // live runtime suite object
11+
Reflection.forSuite({ title, file }) // suite-like object
12+
Reflection.forSuite({ file: './test/auth.js' }) // auto-detect title
13+
Reflection.forSuite('./test/auth.js') // bare path shorthand
1114
```
1215

13-
`suite` must have `title` and `file`.
16+
`file` is required. `title` is optional: if omitted and the file has exactly one `Feature(...)`, it's used automatically. If the file has multiple Features, `AmbiguousLocateError` is thrown.
1417

1518
## Properties
1619

src/reflection.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { SuiteReflection } from './suite.js'
44
import { PageObjectReflection } from './pageobject.js'
55
import { ProjectReflection } from './project.js'
66
import { Batch } from './batch.js'
7-
import { configure as configureSourcePath } from './source-path.js'
7+
import { parseFile } from './parser.js'
8+
import { scanFile as scanParsedFile } from './locate/file-scan.js'
9+
import { resolveSourceFile, configure as configureSourcePath } from './source-path.js'
810
import { clearCache } from './parser.js'
911

1012
export const Reflection = {
@@ -17,9 +19,40 @@ export const Reflection = {
1719
},
1820

1921
forSuite(suite) {
22+
if (typeof suite === 'string') {
23+
return new SuiteReflection({ file: suite })
24+
}
2025
return new SuiteReflection(suite)
2126
},
2227

28+
scanFile(filePath) {
29+
const parsed = parseFile(resolveSourceFile(filePath))
30+
const scanned = scanParsedFile(parsed)
31+
const suites = scanned.features.map(f => ({
32+
title: f.title,
33+
file: parsed.filePath,
34+
line: f.line,
35+
range: f.range,
36+
}))
37+
// Assign each scenario to its parent suite by source order
38+
const tests = []
39+
for (const sc of scanned.scenarios) {
40+
let parent = null
41+
for (const f of scanned.features) {
42+
if (f.range.start < sc.range.start) parent = f
43+
else break
44+
}
45+
tests.push({
46+
title: sc.title,
47+
suite: parent?.title || null,
48+
file: parsed.filePath,
49+
line: sc.line,
50+
range: sc.range,
51+
})
52+
}
53+
return { suites, tests }
54+
},
55+
2356
forPageObject(filePath, opts = {}) {
2457
return new PageObjectReflection(filePath, opts)
2558
},

src/suite.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { parseFile } from './parser.js'
22
import { resolveSourceFile } from './source-path.js'
33
import { locateSuiteByTitle, collectSuiteStatements, HOOK_KINDS } from './locate/suite.js'
4+
import { scanFile as scanParsedFile } from './locate/file-scan.js'
45
import { extractScenarioDepsJS, extractScenarioDepsTS } from './locate/deps.js'
56
import { Edit } from './edit.js'
67
import { ReflectionError, NotFoundError, AmbiguousLocateError } from './errors.js'
@@ -24,7 +25,35 @@ export class SuiteReflection {
2425
}
2526

2627
get title() {
27-
return this._suite.title
28+
if (this._suite.title) return this._suite.title
29+
if (this._resolvedTitle !== undefined) return this._resolvedTitle
30+
this._resolvedTitle = this._autoDetectTitle()
31+
return this._resolvedTitle
32+
}
33+
34+
_autoDetectTitle() {
35+
const parsed = this._parsed()
36+
const scanned = scanParsedFile(parsed)
37+
if (scanned.features.length === 0) {
38+
throw new NotFoundError(`No Feature found in ${parsed.filePath}`, {
39+
filePath: parsed.filePath,
40+
})
41+
}
42+
if (scanned.features.length > 1) {
43+
throw new AmbiguousLocateError(
44+
`Multiple Features in ${parsed.filePath}; pass { title } to disambiguate. Candidates: ${scanned.features.map(f => `"${f.title}" (line ${f.line})`).join(', ')}`,
45+
{
46+
filePath: parsed.filePath,
47+
candidates: scanned.features.map(f => ({
48+
title: f.title,
49+
line: f.line,
50+
start: f.range.start,
51+
end: f.range.end,
52+
})),
53+
},
54+
)
55+
}
56+
return scanned.features[0].title
2857
}
2958

3059
get tags() {

src/types.d.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,33 @@ export declare class ProjectReflection {
217217
getPageObject(name: string): PageObjectReflection
218218
}
219219

220+
export interface ScanFileSuiteEntry {
221+
title: string | null
222+
file: string
223+
line: number
224+
range: Range
225+
}
226+
227+
export interface ScanFileTestEntry {
228+
title: string | null
229+
suite: string | null
230+
file: string
231+
line: number
232+
range: Range
233+
}
234+
235+
export interface ScanFileResult {
236+
suites: ScanFileSuiteEntry[]
237+
tests: ScanFileTestEntry[]
238+
}
239+
220240
export declare const Reflection: {
221241
forStep(step: StepLike, opts?: { test?: TestLike }): StepReflection
222242
forTest(test: TestLike): TestReflection
223-
forSuite(suite: SuiteLike): SuiteReflection
243+
forSuite(suite: SuiteLike | { file: string; title?: string } | string): SuiteReflection
224244
forPageObject(filePath: string, opts?: { name?: string }): PageObjectReflection
225245
project(opts: CodeceptConfigLike | { config?: CodeceptConfigLike; configPath?: string; basePath?: string } | string): ProjectReflection | Promise<ProjectReflection>
246+
scanFile(filePath: string): ScanFileResult
226247
batch(filePath: string): Batch
227248
configure(opts?: ReflectionConfigureOptions): void
228249
clearCache(): void

test/unit/scan-file.test.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import fs from 'node:fs'
3+
import os from 'node:os'
4+
import path from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { Reflection } from '../../src/reflection.js'
7+
import { SuiteReflection } from '../../src/suite.js'
8+
import { clearCache } from '../../src/parser.js'
9+
import { NotFoundError, AmbiguousLocateError, UnsupportedSourceError } from '../../src/errors.js'
10+
11+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
12+
const fix = p => path.resolve(__dirname, '../fixtures', p)
13+
14+
function tmp(contents, ext = '.js') {
15+
const p = path.join(os.tmpdir(), `reflection-scan-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`)
16+
fs.writeFileSync(p, contents)
17+
return p
18+
}
19+
20+
describe('Reflection.scanFile', () => {
21+
beforeEach(() => clearCache())
22+
23+
it('returns suites and tests for a single-Feature file', () => {
24+
const { suites, tests } = Reflection.scanFile(fix('js/simple.scenario.js'))
25+
expect(suites).toHaveLength(1)
26+
expect(suites[0].title).toBe('Auth')
27+
expect(suites[0].line).toBeGreaterThan(0)
28+
expect(suites[0].range.start).toBeLessThan(suites[0].range.end)
29+
30+
expect(tests.map(t => t.title)).toEqual(['login works', 'logout works'])
31+
for (const t of tests) {
32+
expect(t.suite).toBe('Auth')
33+
expect(t.file).toBe(suites[0].file)
34+
}
35+
})
36+
37+
it('assigns each test to its parent suite in multi-Feature files', () => {
38+
const { suites, tests } = Reflection.scanFile(fix('js/multi-suite.scenario.js'))
39+
expect(suites.map(s => s.title)).toEqual(['First', 'Second'])
40+
const bySuite = new Map()
41+
for (const t of tests) {
42+
if (!bySuite.has(t.suite)) bySuite.set(t.suite, [])
43+
bySuite.get(t.suite).push(t.title)
44+
}
45+
expect(bySuite.get('First')).toEqual(['a1', 'a2'])
46+
expect(bySuite.get('Second')).toEqual(['b1'])
47+
})
48+
49+
it('returns empty suites and tests for a file with neither', () => {
50+
const file = tmp('const x = 1\n')
51+
try {
52+
const { suites, tests } = Reflection.scanFile(file)
53+
expect(suites).toEqual([])
54+
expect(tests).toEqual([])
55+
} finally {
56+
fs.unlinkSync(file)
57+
}
58+
})
59+
60+
it('works on .ts scenario files', () => {
61+
const { suites, tests } = Reflection.scanFile(fix('ts/simple.scenario.ts'))
62+
expect(suites.map(s => s.title)).toEqual(['Auth'])
63+
expect(tests.map(t => t.title)).toEqual(['login works', 'typed logout'])
64+
})
65+
66+
it('propagates UnsupportedSourceError for Gherkin .feature files', () => {
67+
expect(() => Reflection.scanFile(fix('gherkin/login.feature'))).toThrow(UnsupportedSourceError)
68+
})
69+
})
70+
71+
describe('Reflection.forSuite auto-title', () => {
72+
beforeEach(() => clearCache())
73+
74+
it('auto-detects the single Feature when only one exists in the file', () => {
75+
const sur = Reflection.forSuite({ file: fix('js/simple.scenario.js') })
76+
expect(sur).toBeInstanceOf(SuiteReflection)
77+
expect(sur.title).toBe('Auth')
78+
const block = sur.read()
79+
expect(block).toBe("Feature('Auth')")
80+
})
81+
82+
it('accepts a bare file path string as shorthand', () => {
83+
const sur = Reflection.forSuite(fix('js/simple.scenario.js'))
84+
expect(sur.title).toBe('Auth')
85+
})
86+
87+
it('lists hooks and tests after auto-detection', () => {
88+
const file = tmp(
89+
`Feature('Auto')
90+
91+
BeforeSuite(async () => {})
92+
93+
Scenario('first', async ({ I }) => { I.amOnPage('/') })
94+
Scenario('second', async ({ I, loginPage }) => { loginPage.open() })
95+
`,
96+
)
97+
try {
98+
const sur = Reflection.forSuite({ file })
99+
expect(sur.title).toBe('Auto')
100+
expect(sur.tests.map(t => t.title)).toEqual(['first', 'second'])
101+
expect(sur.hooks.map(h => h.kind)).toEqual(['BeforeSuite'])
102+
expect(sur.dependencies.sort()).toEqual(['I', 'loginPage'])
103+
} finally {
104+
fs.unlinkSync(file)
105+
}
106+
})
107+
108+
it('throws AmbiguousLocateError when the file has multiple Features', () => {
109+
const file = fix('js/multi-suite.scenario.js')
110+
const sur = Reflection.forSuite({ file })
111+
let thrown
112+
try { sur.title } catch (e) { thrown = e }
113+
expect(thrown).toBeInstanceOf(AmbiguousLocateError)
114+
expect(thrown.candidates).toHaveLength(2)
115+
expect(thrown.candidates.map(c => c.title).sort()).toEqual(['First', 'Second'])
116+
})
117+
118+
it('disambiguates with an explicit title when multiple Features exist', () => {
119+
const sur = Reflection.forSuite({ title: 'Second', file: fix('js/multi-suite.scenario.js') })
120+
expect(sur.title).toBe('Second')
121+
expect(sur.tests.map(t => t.title)).toEqual(['b1'])
122+
})
123+
124+
it('throws NotFoundError for a file with no Feature', () => {
125+
const file = tmp('const x = 1\n')
126+
try {
127+
const sur = Reflection.forSuite({ file })
128+
expect(() => sur.title).toThrow(NotFoundError)
129+
} finally {
130+
fs.unlinkSync(file)
131+
}
132+
})
133+
})

0 commit comments

Comments
 (0)