Skip to content

Commit b9a9fc2

Browse files
DavertMikclaude
andcommitted
feat: add hook reflection to SuiteReflection
Before / After / BeforeSuite / AfterSuite are top-level sibling statements of Feature in CodeceptJS, so the existing suite-range walker already visits them — it just used to ignore them. New surface on SuiteReflection: - hooks: list of { kind, line, range }, in source order, scoped to this Feature (never bleeds into the next Feature in the same file) - findHook(kind): filter helper - addHook(kind, code, { position }): append after existing hooks or right after Feature(); rejects unknown hook kinds - removeHook(kind, { index }): delete by kind, throws AmbiguousLocateError when multiple match and no index is given - replaceHook(kind, code, { index }): replace with the same disambiguation semantics Implementation extends collectSuiteStatements to return hooks[] alongside scenarios[]; the JS and TS engines both classify ExpressionStatements whose callee is one of the four hook names. 15 new unit tests covering listing, scoping, insertion into empty and populated suites, cross-suite isolation, ambiguity detection, index-based disambiguation, and replacement. Removed hook reflection from the v2 "deferred" list in limitations.md. Totals: 213 tests across 23 files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6182460 commit b9a9fc2

8 files changed

Lines changed: 509 additions & 10 deletions

File tree

CHANGELOG.md

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

3+
## 0.5.0 — Unreleased
4+
5+
Added hook reflection to `SuiteReflection` for `Before`, `After`, `BeforeSuite`, and `AfterSuite`.
6+
7+
- `sur.hooks` — list of `{ kind, line, range }` entries scoped to the current suite.
8+
- `sur.findHook(kind)` — filter by kind.
9+
- `sur.addHook(kind, code, { position })` — append after existing hooks (or right after `Feature(...)` if none); scoped to the current suite.
10+
- `sur.removeHook(kind, { index })` — delete a hook; throws `AmbiguousLocateError` when multiple match and no index is given.
11+
- `sur.replaceHook(kind, code, { index })` — replace a hook body with the same disambiguation semantics.
12+
313
## 0.4.0 — Unreleased
414

515
Added `ProjectReflection` — a project-level discovery layer that reads a CodeceptJS config and enumerates suites, tests, steps, and page objects without running anything.

docs/api/suite-reflection.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Reflection.forSuite(suite)
2121
| `tags` | `string[]` | |
2222
| `meta` | `object` | |
2323
| `tests` | `Array<{ title, range }>` | Scenarios that belong to this suite, in source order. `title` is the first string argument of each `Scenario(...)`. `range` is the byte range of the full statement. |
24+
| `hooks` | `Array<{ kind, line, range }>` | `Before` / `After` / `BeforeSuite` / `AfterSuite` calls that belong to this suite, in source order. `kind` is the hook function name. |
2425
| `dependencies` | `string[]` | Unique destructured parameter names across all scenarios in this suite (e.g. `['I', 'loginPage']`). Non-destructured params appear as `*name` so you can tell them apart. |
2526

2627
## Methods
@@ -61,6 +62,57 @@ The lookup is scoped: if another suite in the same file has a scenario with the
6162
sur.removeTest('flaky login').apply()
6263
```
6364

65+
## Hook editing
66+
67+
CodeceptJS suite hooks — `BeforeSuite`, `Before`, `After`, `AfterSuite` — are top-level sibling statements of `Feature` / `Scenario`. `SuiteReflection` locates them by walking the same suite range used for `tests`, so each hook operation is scoped to the current Feature.
68+
69+
### `findHook(kind)`
70+
Returns all hooks of a given kind in this suite (in source order). Useful before calling `removeHook` / `replaceHook` to check for ambiguity.
71+
72+
```js
73+
sur.findHook('Before') // [{ kind: 'Before', line: 5, range: {...} }]
74+
```
75+
76+
### `addHook(kind, code, opts?)`
77+
Inserts a new hook and returns an [`Edit`](./edit.md).
78+
79+
| Option | Type | Default | Description |
80+
|---|---|---|---|
81+
| `position` | `'afterHooks' \| 'afterFeature'` | `'afterHooks'` | `'afterHooks'` appends after the last existing hook in this suite, or after `Feature(...)` if none exist. `'afterFeature'` always inserts right after `Feature(...)`. |
82+
83+
```js
84+
sur.addHook(
85+
'BeforeSuite',
86+
`BeforeSuite(async ({ I }) => { I.amOnPage('/seed') })`,
87+
).apply()
88+
```
89+
90+
Insertion is scoped — the hook will never land past the next `Feature(...)` in the same file. Throws `ReflectionError` if `kind` is not one of the four supported hook names.
91+
92+
### `removeHook(kind, opts?)`
93+
Deletes a hook of a given kind, scoped to this suite.
94+
95+
| Option | Type | Description |
96+
|---|---|---|
97+
| `index` | `number?` | Disambiguate when this suite has multiple hooks of the same kind. 0-based index into `findHook(kind)`. |
98+
99+
```js
100+
sur.removeHook('Before').apply()
101+
sur.removeHook('Before', { index: 1 }).apply() // remove the second of two Before hooks
102+
```
103+
104+
Throws `NotFoundError` if no hook of that kind exists (or `index` is out of range). Throws `AmbiguousLocateError` when there are multiple matching hooks and no `index` was given.
105+
106+
### `replaceHook(kind, code, opts?)`
107+
Replaces the body of a hook. Accepts the same `{ index }` option as `removeHook` for disambiguation.
108+
109+
```js
110+
sur.replaceHook(
111+
'Before',
112+
`Before(async ({ I }) => { I.amOnPage('/login') })`,
113+
).apply()
114+
```
115+
64116
## Gherkin
65117

66118
Gherkin `.feature` files are not supported: `read()` throws `UnsupportedSourceError`. For BDD-style CodeceptJS projects, reflect the step-definition JS/TS file instead.

docs/getting-started.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ sur.removeTest('flaky login').apply()
104104

105105
Insertion is scoped to the current suite, so the new Scenario will not land after a later `Feature(...)` in the same file.
106106

107+
### Edit suite hooks
108+
109+
`BeforeSuite`, `Before`, `After`, and `AfterSuite` are editable too:
110+
111+
```js
112+
sur.hooks // [{ kind: 'BeforeSuite', line: 3, range: {...} }, ...]
113+
sur.findHook('Before') // just the Before hooks
114+
115+
sur.addHook('BeforeSuite', `BeforeSuite(async ({ I }) => { I.amOnPage('/seed') })`).apply()
116+
sur.replaceHook('Before', `Before(async ({ I }) => { I.clearCookie() })`).apply()
117+
sur.removeHook('After').apply()
118+
119+
// Multiple hooks of the same kind? Disambiguate with { index }
120+
sur.removeHook('Before', { index: 1 }).apply()
121+
```
122+
107123
## List dependencies
108124

109125
Both `TestReflection` and `SuiteReflection` expose a `dependencies` accessor that reads the destructured parameter list from the scenario callback:

docs/limitations.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,3 @@
4141
- Worker-safe concurrent edits (file locking)
4242
- `.replace()` validating the new code parses
4343
- `.replace()` accepting a function callback `(node) => newCode`
44-
- Hook reflection (`Before`, `After`, `BeforeSuite`, `AfterSuite`)

src/locate/suite.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ function isScenarioCallee(name) {
3535
return false
3636
}
3737

38+
export const HOOK_KINDS = ['BeforeSuite', 'Before', 'After', 'AfterSuite']
39+
const HOOK_SET = new Set(HOOK_KINDS)
40+
41+
function isHookCallee(name) {
42+
return !!name && HOOK_SET.has(name)
43+
}
44+
3845
export function locateSuiteByTitle(parsed, { title, lineHint }) {
3946
if (parsed.engine === 'acorn') return locateSuiteJS(parsed, title, lineHint)
4047
return locateSuiteTS(parsed, title, lineHint)
@@ -88,8 +95,9 @@ function collectSuiteStatementsJS(parsed, featureNode) {
8895
)
8996
const featureStmt = idx !== -1 ? body[idx] : null
9097
const scenarios = []
98+
const hooks = []
9199
let suiteEnd = parsed.source.length
92-
if (idx === -1) return { featureStmt, scenarios, suiteEnd }
100+
if (idx === -1) return { featureStmt, scenarios, hooks, suiteEnd }
93101

94102
for (let i = idx + 1; i < body.length; i++) {
95103
const stmt = body[i]
@@ -109,9 +117,17 @@ function collectSuiteStatementsJS(parsed, featureNode) {
109117
title: firstStringArgJS(expr),
110118
range: { start: stmt.start, end: stmt.end },
111119
})
120+
} else if (isHookCallee(name)) {
121+
hooks.push({
122+
stmt,
123+
call: expr,
124+
kind: name,
125+
line: stmt.loc.start.line,
126+
range: { start: stmt.start, end: stmt.end },
127+
})
112128
}
113129
}
114-
return { featureStmt, scenarios, suiteEnd }
130+
return { featureStmt, scenarios, hooks, suiteEnd }
115131
}
116132

117133
function collectSuiteStatementsTS(parsed, featureNode) {
@@ -123,8 +139,9 @@ function collectSuiteStatementsTS(parsed, featureNode) {
123139
)
124140
const featureStmt = idx !== -1 ? stmts[idx] : null
125141
const scenarios = []
142+
const hooks = []
126143
let suiteEnd = parsed.source.length
127-
if (idx === -1) return { featureStmt, scenarios, suiteEnd }
144+
if (idx === -1) return { featureStmt, scenarios, hooks, suiteEnd }
128145

129146
for (let i = idx + 1; i < stmts.length; i++) {
130147
const stmt = stmts[i]
@@ -144,9 +161,18 @@ function collectSuiteStatementsTS(parsed, featureNode) {
144161
title: firstStringArgTS(ts, expr),
145162
range: { start: stmt.getStart(sourceFile), end: stmt.getEnd() },
146163
})
164+
} else if (isHookCallee(name)) {
165+
const start = stmt.getStart(sourceFile)
166+
hooks.push({
167+
stmt,
168+
call: expr,
169+
kind: name,
170+
line: sourceFile.getLineAndCharacterOfPosition(start).line + 1,
171+
range: { start, end: stmt.getEnd() },
172+
})
147173
}
148174
}
149-
return { featureStmt, scenarios, suiteEnd }
175+
return { featureStmt, scenarios, hooks, suiteEnd }
150176
}
151177

152178
function locateSuiteTS(parsed, title, lineHint) {

src/suite.js

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { parseFile } from './parser.js'
22
import { resolveSourceFile } from './source-path.js'
3-
import { locateSuiteByTitle, collectSuiteStatements } from './locate/suite.js'
3+
import { locateSuiteByTitle, collectSuiteStatements, HOOK_KINDS } from './locate/suite.js'
44
import { extractScenarioDepsJS, extractScenarioDepsTS } from './locate/deps.js'
55
import { Edit } from './edit.js'
6-
import { ReflectionError, NotFoundError } from './errors.js'
6+
import { ReflectionError, NotFoundError, AmbiguousLocateError } from './errors.js'
7+
8+
export { HOOK_KINDS }
79

810
export class SuiteReflection {
911
constructor(suite) {
@@ -61,6 +63,19 @@ export class SuiteReflection {
6163
}))
6264
}
6365

66+
get hooks() {
67+
const { hooks } = this._statements()
68+
return hooks.map(h => ({
69+
kind: h.kind,
70+
line: h.line,
71+
range: h.range,
72+
}))
73+
}
74+
75+
findHook(kind) {
76+
return this.hooks.filter(h => h.kind === kind)
77+
}
78+
6479
get dependencies() {
6580
const parsed = this._parsed()
6681
const { scenarios } = this._statements()
@@ -132,19 +147,108 @@ export class SuiteReflection {
132147
{ filePath: parsed.filePath },
133148
)
134149
}
150+
return this._buildRemoveEdit(parsed, match)
151+
}
152+
153+
addHook(kind, code, { position = 'afterHooks' } = {}) {
154+
if (!HOOK_KINDS.includes(kind)) {
155+
throw new ReflectionError(
156+
`addHook: unknown hook kind "${kind}". Expected one of: ${HOOK_KINDS.join(', ')}`,
157+
)
158+
}
159+
const parsed = this._parsed()
160+
const { featureStmt, hooks, suiteEnd } = this._statements()
161+
const eol = parsed.eol
162+
163+
let insertPos
164+
if (position === 'afterFeature' || hooks.length === 0) {
165+
insertPos = featureStmt ? featureStmt.end : this._locate().range.end
166+
} else {
167+
insertPos = hooks[hooks.length - 1].range.end
168+
}
169+
if (insertPos > suiteEnd) insertPos = suiteEnd
170+
171+
const trimmed = code.replace(/[\r\n]+$/, '')
172+
const replacement = eol + eol + trimmed + eol
173+
174+
return new Edit({
175+
filePath: parsed.filePath,
176+
source: parsed.source,
177+
parsedAtHash: parsed.hash,
178+
start: insertPos,
179+
end: insertPos,
180+
replacement,
181+
eol,
182+
})
183+
}
184+
185+
removeHook(kind, { index } = {}) {
186+
const parsed = this._parsed()
187+
const match = this._pickHook(kind, index, parsed.filePath)
188+
return this._buildRemoveEdit(parsed, match)
189+
}
190+
191+
replaceHook(kind, code, { index } = {}) {
192+
const parsed = this._parsed()
193+
const match = this._pickHook(kind, index, parsed.filePath)
194+
return new Edit({
195+
filePath: parsed.filePath,
196+
source: parsed.source,
197+
parsedAtHash: parsed.hash,
198+
start: match.range.start,
199+
end: match.range.end,
200+
replacement: code.replace(/[\r\n]+$/, ''),
201+
eol: parsed.eol,
202+
})
203+
}
204+
205+
_pickHook(kind, index, filePath) {
206+
if (!HOOK_KINDS.includes(kind)) {
207+
throw new ReflectionError(
208+
`Unknown hook kind "${kind}". Expected one of: ${HOOK_KINDS.join(', ')}`,
209+
{ filePath },
210+
)
211+
}
212+
const { hooks } = this._statements()
213+
const matches = hooks.filter(h => h.kind === kind)
214+
if (matches.length === 0) {
215+
throw new NotFoundError(
216+
`No ${kind} hook found in suite "${this.title}" in ${filePath}`,
217+
{ filePath },
218+
)
219+
}
220+
if (index != null) {
221+
if (index < 0 || index >= matches.length) {
222+
throw new NotFoundError(
223+
`${kind} hook index ${index} out of range (0..${matches.length - 1})`,
224+
{ filePath },
225+
)
226+
}
227+
return matches[index]
228+
}
229+
if (matches.length > 1) {
230+
throw new AmbiguousLocateError(
231+
`Multiple ${kind} hooks in suite "${this.title}". Pass { index } to disambiguate.`,
232+
{
233+
filePath,
234+
candidates: matches.map(m => ({ start: m.range.start, end: m.range.end, line: m.line })),
235+
},
236+
)
237+
}
238+
return matches[0]
239+
}
240+
241+
_buildRemoveEdit(parsed, match) {
135242
const eol = parsed.eol
136243
let start = match.range.start
137244
let end = match.range.end
138-
// Eat one trailing newline so the surrounding blank line becomes the new separator
139245
if (parsed.source.slice(end, end + eol.length) === eol) end += eol.length
140-
// If there was a preceding blank line, eat that too
141246
if (
142247
parsed.source.slice(start - eol.length, start) === eol &&
143248
parsed.source.slice(start - 2 * eol.length, start - eol.length) === eol
144249
) {
145250
start -= eol.length
146251
}
147-
148252
return new Edit({
149253
filePath: parsed.filePath,
150254
source: parsed.source,

src/types.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,22 +88,43 @@ export interface SuiteTestEntry {
8888
range: Range
8989
}
9090

91+
export type HookKind = 'BeforeSuite' | 'Before' | 'After' | 'AfterSuite'
92+
93+
export interface SuiteHookEntry {
94+
kind: HookKind
95+
line: number
96+
range: Range
97+
}
98+
9199
export interface AddTestOptions {
92100
position?: 'start' | 'end'
93101
}
94102

103+
export interface AddHookOptions {
104+
position?: 'afterFeature' | 'afterHooks'
105+
}
106+
107+
export interface HookIndexOption {
108+
index?: number
109+
}
110+
95111
export declare class SuiteReflection {
96112
constructor(suite: SuiteLike)
97113
readonly fileName: string
98114
readonly title: string
99115
readonly tags: string[]
100116
readonly meta: Record<string, unknown>
101117
readonly tests: SuiteTestEntry[]
118+
readonly hooks: SuiteHookEntry[]
102119
readonly dependencies: string[]
103120
read(): string
104121
replace(newCode: string): Edit
105122
addTest(code: string, opts?: AddTestOptions): Edit
106123
removeTest(title: string): Edit
124+
findHook(kind: HookKind): SuiteHookEntry[]
125+
addHook(kind: HookKind, code: string, opts?: AddHookOptions): Edit
126+
removeHook(kind: HookKind, opts?: HookIndexOption): Edit
127+
replaceHook(kind: HookKind, code: string, opts?: HookIndexOption): Edit
107128
}
108129

109130
export interface ReflectionConfigureOptions {

0 commit comments

Comments
 (0)