Skip to content

Commit 5ce3c9f

Browse files
DavertMikDavertMikclaude
authored
feat(list): add -c, --docs, and --action flags (#5541)
* update docs * updated docs, added browser plugin * feat(list): add -c, --docs, and --action flags `codeceptjs list` now mirrors `run`'s `-c` config flag. `--docs` prints the helper JSDoc + resolved `docs/webapi/*.mustache` snippet beneath each action. `--action <name>` filters to a single action (with optional `I.` prefix) and implies `--docs`. JSDoc extraction uses acorn (already a runtime dep) rather than text patterns: each block comment is paired with the next MethodDefinition, then `{{> name }}` markers are resolved against `docs/webapi/`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fb1a602 commit 5ce3c9f

6 files changed

Lines changed: 251 additions & 17 deletions

File tree

bin/codecept.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,14 @@ program
9393
.option(commandFlags.config.flag, commandFlags.config.description)
9494
.action(commandHandler('../lib/command/interactive.js'))
9595

96-
program.command('list [path]').alias('l').description('List all actions for I.').action(commandHandler('../lib/command/list.js'))
96+
program
97+
.command('list [path]')
98+
.alias('l')
99+
.description('List all actions for I.')
100+
.option(commandFlags.config.flag, commandFlags.config.description)
101+
.option('--docs', 'show documentation for each action')
102+
.option('--action <name>', 'show docs for a single action (e.g. amOnPage or I.amOnPage)')
103+
.action(commandHandler('../lib/command/list.js'))
97104

98105
program
99106
.command('def [path]')

docs/commands.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,12 +318,31 @@ npx codeceptjs def -o ./tests/typings
318318

319319
## List Commands
320320

321-
Prints all available methods of `I` to console
321+
Prints all available methods of `I` to console.
322322

323323
```sh
324324
npx codeceptjs list
325325
```
326326

327+
Use `-c` to point at a specific config (same as `run`):
328+
329+
```sh
330+
npx codeceptjs list -c ./test/acceptance/codecept.Playwright.js
331+
```
332+
333+
Add `--docs` to print full documentation (description, examples, `@param` annotations) below each action — pulled from helper JSDoc and `docs/webapi/*` snippets:
334+
335+
```sh
336+
npx codeceptjs list --docs
337+
```
338+
339+
Use `--action` to show docs for a single action. The `I.` prefix is optional and `--docs` is implied:
340+
341+
```sh
342+
npx codeceptjs list --action amOnPage
343+
npx codeceptjs list --action I.click -c ./test/acceptance/codecept.Playwright.js
344+
```
345+
327346
## Local Environment Information
328347

329348
Prints debugging information concerning the local environment

lib/command/list.js

Lines changed: 150 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,177 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import { fileURLToPath } from 'url'
4+
import * as acorn from 'acorn'
15
import { getConfig, getTestRoot } from './utils.js'
26
import Codecept from '../codecept.js'
37
import container from '../container.js'
48
import { getParamsToString } from '../parser.js'
59
import { methodsOfObject } from '../utils.js'
610
import output from '../output.js'
711

8-
export default async function (path) {
9-
const testsPath = getTestRoot(path)
10-
const config = await getConfig(testsPath)
12+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
13+
const helperDir = path.resolve(__dirname, '..', 'helper')
14+
const webapiDir = path.resolve(__dirname, '..', '..', 'docs', 'webapi')
15+
16+
let partialsCache = null
17+
18+
function loadWebApiPartials() {
19+
if (partialsCache) return partialsCache
20+
const map = new Map()
21+
if (fs.existsSync(webapiDir)) {
22+
for (const file of fs.readdirSync(webapiDir)) {
23+
if (path.extname(file) !== '.mustache') continue
24+
const name = path.basename(file, '.mustache')
25+
map.set(name, fs.readFileSync(path.join(webapiDir, file), 'utf8'))
26+
}
27+
}
28+
partialsCache = map
29+
return map
30+
}
31+
32+
function resolveHelperSource(helper, helperName, config, testsPath) {
33+
const builtin = path.join(helperDir, `${helper.constructor.name}.js`)
34+
if (fs.existsSync(builtin)) return builtin
35+
const requirePath = config?.helpers?.[helperName]?.require
36+
if (requirePath) {
37+
const resolved = path.isAbsolute(requirePath) ? requirePath : path.resolve(testsPath, requirePath)
38+
if (fs.existsSync(resolved)) return resolved
39+
}
40+
return null
41+
}
42+
43+
function findClassNode(ast) {
44+
for (const node of ast.body) {
45+
if (node.type === 'ClassDeclaration') return node
46+
if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'ClassDeclaration') return node.declaration
47+
if (node.type === 'ExportDefaultDeclaration' && node.declaration?.type === 'ClassDeclaration') return node.declaration
48+
}
49+
return null
50+
}
51+
52+
function stripJsDoc(value) {
53+
return value
54+
.split('\n')
55+
.map(line => line.replace(/^\s*\* ?/, ''))
56+
.join('\n')
57+
.trim()
58+
}
59+
60+
function resolvePartials(text, partials) {
61+
return text.replace(/\{\{>\s*([\w-]+)\s*\}\}/g, (match, name) => {
62+
return partials.has(name) ? partials.get(name) : match
63+
})
64+
}
65+
66+
function extractMethodDocs(helper, helperName, config, testsPath, partials) {
67+
const result = new Map()
68+
const sourceFile = resolveHelperSource(helper, helperName, config, testsPath)
69+
if (!sourceFile) return result
70+
71+
let source
72+
try {
73+
source = fs.readFileSync(sourceFile, 'utf8')
74+
} catch {
75+
return result
76+
}
77+
78+
const comments = []
79+
let ast
80+
try {
81+
ast = acorn.parse(source, {
82+
ecmaVersion: 'latest',
83+
sourceType: 'module',
84+
locations: true,
85+
onComment: comments,
86+
})
87+
} catch {
88+
return result
89+
}
90+
91+
const classNode = findClassNode(ast)
92+
if (!classNode) return result
93+
94+
const blockComments = comments
95+
.filter(c => c.type === 'Block' && c.value.startsWith('*'))
96+
.sort((a, b) => a.start - b.start)
97+
98+
let cursor = 0
99+
for (const member of classNode.body.body) {
100+
if (member.type !== 'MethodDefinition') continue
101+
if (member.kind === 'constructor' || member.static) continue
102+
const name = member.key?.name
103+
if (!name || name.startsWith('_')) continue
104+
105+
let attached = null
106+
let attachedIdx = -1
107+
for (let i = cursor; i < blockComments.length; i++) {
108+
const c = blockComments[i]
109+
if (c.end > member.start) break
110+
attached = c
111+
attachedIdx = i
112+
}
113+
if (attached) {
114+
cursor = attachedIdx + 1
115+
const stripped = stripJsDoc(attached.value)
116+
const resolved = resolvePartials(stripped, partials)
117+
result.set(name, resolved)
118+
}
119+
}
120+
121+
return result
122+
}
123+
124+
function printDocBlock(doc) {
125+
if (!doc) return
126+
for (const line of doc.split('\n')) {
127+
output.print(` ${line}`)
128+
}
129+
output.print('')
130+
}
131+
132+
export default async function (path, options = {}) {
133+
const configFile = options.config
134+
const testsPath = getTestRoot(configFile || path)
135+
const config = await getConfig(configFile || testsPath)
11136
const codecept = new Codecept(config, {})
12137
await codecept.init(testsPath)
13138
await container.started()
14139

15-
output.print('List of test actions: -- ')
140+
const filter = options.action ? options.action.replace(/^I\./, '') : null
141+
const showDocs = !!(options.docs || filter)
142+
const partials = showDocs ? loadWebApiPartials() : null
143+
144+
if (!filter) output.print('List of test actions: -- ')
16145
const helpers = container.helpers()
17146
const supportI = container.support('I')
18147
const actions = []
148+
let matched = false
19149
for (const name in helpers) {
20150
const helper = helpers[name]
151+
const docs = showDocs ? extractMethodDocs(helper, name, config, testsPath, partials) : null
21152
methodsOfObject(helper).forEach(action => {
22-
const params = getParamsToString(helper[action])
23153
actions[action] = 1
154+
if (filter && action !== filter) return
155+
const params = getParamsToString(helper[action])
24156
output.print(` ${output.colors.grey(name)} I.${output.colors.bold(action)}(${params})`)
157+
if (docs && docs.has(action)) printDocBlock(docs.get(action))
158+
matched = true
25159
})
26160
}
27161
for (const name in supportI) {
28-
if (actions[name]) {
29-
continue
30-
}
162+
if (actions[name]) continue
163+
if (filter && name !== filter) continue
31164
const actor = supportI[name]
32165
const params = getParamsToString(actor)
33166
output.print(` I.${output.colors.bold(name)}(${params})`)
167+
matched = true
168+
}
169+
if (filter && !matched) {
170+
output.print(`No action named ${output.colors.bold(filter)} found in enabled helpers or support objects.`)
171+
return
172+
}
173+
if (!filter) {
174+
output.print('PS: Actions are retrieved from enabled helpers. ')
175+
output.print('Implement custom actions in your helper classes.')
34176
}
35-
output.print('PS: Actions are retrieved from enabled helpers. ')
36-
output.print('Implement custom actions in your helper classes.')
37177
}

lib/config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,24 @@ class Config {
169169
return ran
170170
}
171171

172+
/**
173+
* Number of registered config hooks. Useful for snapshotting before a phase
174+
* (e.g. plugin loading) and re-running only the hooks added during it.
175+
* @return {number}
176+
*/
177+
static hooksCount() {
178+
return hooks.length
179+
}
180+
181+
/**
182+
* Run hooks in `[fromIndex, end)` against the given config object, mutating it.
183+
* @param {number} fromIndex
184+
* @param {Object<string, *>} cfg
185+
*/
186+
static runHooksFrom(fromIndex, cfg) {
187+
for (let i = fromIndex; i < hooks.length; i++) hooks[i](cfg)
188+
}
189+
172190
/**
173191
* Appends values to current config
174192
*

lib/container.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,8 @@ class Container {
122122
// Wait for all async helpers to finish loading and populate the actor
123123
await asyncHelperPromise
124124

125-
// Plugins may have registered Config hooks during their boot (e.g. the
126-
// browser plugin pushing `setBrowserConfig` overrides). Run anything that
127-
// hasn't been applied yet and re-feed the mutated helper config to the
125+
// Plugins may have registered Config hooks during their boot. Run anything
126+
// that hasn't been applied yet and re-feed the mutated helper config to the
128127
// already-instantiated helpers.
129128
if (Config.runPendingHooks(config)) {
130129
for (const name of Object.keys(container.helpers)) {

test/runner/list_test.js

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,66 @@ const __dirname = path.dirname(__filename);
99

1010
const runner = path.join(__dirname, '/../../bin/codecept.js')
1111
const codecept_dir = path.join(__dirname, '/../data/sandbox')
12+
const codecept_config = path.join(codecept_dir, 'codecept.js')
1213

1314
describe('list commands', () => {
1415
it('list should print actions', done => {
1516
exec(`${runner} list ${codecept_dir}`, (err, stdout) => {
16-
stdout.should.include('FileSystem') // helper name
17-
stdout.should.include('FileSystem I.amInPath(openPath)') // action name
17+
stdout.should.include('FileSystem')
18+
stdout.should.include('FileSystem I.amInPath(openPath)')
1819
stdout.should.include('FileSystem I.seeFile(name)')
1920
assert(!err)
2021
done()
2122
})
2223
})
24+
25+
it('list should accept -c with a config file path', done => {
26+
exec(`${runner} list -c ${codecept_config}`, (err, stdout) => {
27+
stdout.should.include('FileSystem I.amInPath(openPath)')
28+
stdout.should.include('FileSystem I.seeFile(name)')
29+
assert(!err)
30+
done()
31+
})
32+
})
33+
34+
it('list --docs should print JSDoc descriptions for actions', done => {
35+
exec(`${runner} list --docs -c ${codecept_config}`, (err, stdout) => {
36+
stdout.should.include('FileSystem I.amInPath(openPath)')
37+
stdout.should.include('Enters a directory In local filesystem.')
38+
stdout.should.include('FileSystem I.seeFile(name)')
39+
stdout.should.include('Checks that file exists')
40+
assert(!err)
41+
done()
42+
})
43+
})
44+
45+
it('list --action filters to a single action and implies --docs', done => {
46+
exec(`${runner} list --action seeFile -c ${codecept_config}`, (err, stdout) => {
47+
stdout.should.include('FileSystem I.seeFile(name)')
48+
stdout.should.include('Checks that file exists')
49+
stdout.should.not.include('I.amInPath(')
50+
stdout.should.not.include('List of test actions:')
51+
assert(!err)
52+
done()
53+
})
54+
})
55+
56+
it('list --action accepts the I. prefix', done => {
57+
exec(`${runner} list --action I.seeFile -c ${codecept_config}`, (err, stdout) => {
58+
stdout.should.include('FileSystem I.seeFile(name)')
59+
stdout.should.include('Checks that file exists')
60+
stdout.should.not.include('I.amInPath(')
61+
assert(!err)
62+
done()
63+
})
64+
})
65+
66+
it('list --action prints a not-found message for an unknown action', done => {
67+
exec(`${runner} list --action doesNotExist -c ${codecept_config}`, (err, stdout) => {
68+
stdout.should.include('No action named')
69+
stdout.should.include('doesNotExist')
70+
assert(!err)
71+
done()
72+
})
73+
})
2374
})

0 commit comments

Comments
 (0)