Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion bin/codecept.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,14 @@ program
.option(commandFlags.config.flag, commandFlags.config.description)
.action(commandHandler('../lib/command/interactive.js'))

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

program
.command('def [path]')
Expand Down
21 changes: 20 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,12 +318,31 @@ npx codeceptjs def -o ./tests/typings

## List Commands

Prints all available methods of `I` to console
Prints all available methods of `I` to console.

```sh
npx codeceptjs list
```

Use `-c` to point at a specific config (same as `run`):

```sh
npx codeceptjs list -c ./test/acceptance/codecept.Playwright.js
```

Add `--docs` to print full documentation (description, examples, `@param` annotations) below each action — pulled from helper JSDoc and `docs/webapi/*` snippets:

```sh
npx codeceptjs list --docs
```

Use `--action` to show docs for a single action. The `I.` prefix is optional and `--docs` is implied:

```sh
npx codeceptjs list --action amOnPage
npx codeceptjs list --action I.click -c ./test/acceptance/codecept.Playwright.js
```

## Local Environment Information

Prints debugging information concerning the local environment
Expand Down
160 changes: 150 additions & 10 deletions lib/command/list.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,177 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import * as acorn from 'acorn'
import { getConfig, getTestRoot } from './utils.js'
import Codecept from '../codecept.js'
import container from '../container.js'
import { getParamsToString } from '../parser.js'
import { methodsOfObject } from '../utils.js'
import output from '../output.js'

export default async function (path) {
const testsPath = getTestRoot(path)
const config = await getConfig(testsPath)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const helperDir = path.resolve(__dirname, '..', 'helper')
const webapiDir = path.resolve(__dirname, '..', '..', 'docs', 'webapi')

let partialsCache = null

function loadWebApiPartials() {
if (partialsCache) return partialsCache
const map = new Map()
if (fs.existsSync(webapiDir)) {
for (const file of fs.readdirSync(webapiDir)) {
if (path.extname(file) !== '.mustache') continue
const name = path.basename(file, '.mustache')
map.set(name, fs.readFileSync(path.join(webapiDir, file), 'utf8'))
}
}
partialsCache = map
return map
}

function resolveHelperSource(helper, helperName, config, testsPath) {
const builtin = path.join(helperDir, `${helper.constructor.name}.js`)
if (fs.existsSync(builtin)) return builtin
const requirePath = config?.helpers?.[helperName]?.require
if (requirePath) {
const resolved = path.isAbsolute(requirePath) ? requirePath : path.resolve(testsPath, requirePath)
if (fs.existsSync(resolved)) return resolved
}
return null
}

function findClassNode(ast) {
for (const node of ast.body) {
if (node.type === 'ClassDeclaration') return node
if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'ClassDeclaration') return node.declaration
if (node.type === 'ExportDefaultDeclaration' && node.declaration?.type === 'ClassDeclaration') return node.declaration
}
return null
}

function stripJsDoc(value) {
return value
.split('\n')
.map(line => line.replace(/^\s*\* ?/, ''))
.join('\n')
.trim()
}

function resolvePartials(text, partials) {
return text.replace(/\{\{>\s*([\w-]+)\s*\}\}/g, (match, name) => {
return partials.has(name) ? partials.get(name) : match
})
}

function extractMethodDocs(helper, helperName, config, testsPath, partials) {
const result = new Map()
const sourceFile = resolveHelperSource(helper, helperName, config, testsPath)
if (!sourceFile) return result

let source
try {
source = fs.readFileSync(sourceFile, 'utf8')
} catch {
return result
}

const comments = []
let ast
try {
ast = acorn.parse(source, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
onComment: comments,
})
} catch {
return result
}

const classNode = findClassNode(ast)
if (!classNode) return result

const blockComments = comments
.filter(c => c.type === 'Block' && c.value.startsWith('*'))
.sort((a, b) => a.start - b.start)

let cursor = 0
for (const member of classNode.body.body) {
if (member.type !== 'MethodDefinition') continue
if (member.kind === 'constructor' || member.static) continue
const name = member.key?.name
if (!name || name.startsWith('_')) continue

let attached = null
let attachedIdx = -1
for (let i = cursor; i < blockComments.length; i++) {
const c = blockComments[i]
if (c.end > member.start) break
attached = c
attachedIdx = i
}
if (attached) {
cursor = attachedIdx + 1
const stripped = stripJsDoc(attached.value)
const resolved = resolvePartials(stripped, partials)
result.set(name, resolved)
}
}

return result
}

function printDocBlock(doc) {
if (!doc) return
for (const line of doc.split('\n')) {
output.print(` ${line}`)
}
output.print('')
}

export default async function (path, options = {}) {
const configFile = options.config
const testsPath = getTestRoot(configFile || path)
const config = await getConfig(configFile || testsPath)
const codecept = new Codecept(config, {})
await codecept.init(testsPath)
await container.started()

output.print('List of test actions: -- ')
const filter = options.action ? options.action.replace(/^I\./, '') : null
const showDocs = !!(options.docs || filter)
const partials = showDocs ? loadWebApiPartials() : null

if (!filter) output.print('List of test actions: -- ')
const helpers = container.helpers()
const supportI = container.support('I')
const actions = []
let matched = false
for (const name in helpers) {
const helper = helpers[name]
const docs = showDocs ? extractMethodDocs(helper, name, config, testsPath, partials) : null
methodsOfObject(helper).forEach(action => {
const params = getParamsToString(helper[action])
actions[action] = 1
if (filter && action !== filter) return
const params = getParamsToString(helper[action])
output.print(` ${output.colors.grey(name)} I.${output.colors.bold(action)}(${params})`)
if (docs && docs.has(action)) printDocBlock(docs.get(action))
matched = true
})
}
for (const name in supportI) {
if (actions[name]) {
continue
}
if (actions[name]) continue
if (filter && name !== filter) continue
const actor = supportI[name]
const params = getParamsToString(actor)
output.print(` I.${output.colors.bold(name)}(${params})`)
matched = true
}
if (filter && !matched) {
output.print(`No action named ${output.colors.bold(filter)} found in enabled helpers or support objects.`)
return
}
if (!filter) {
output.print('PS: Actions are retrieved from enabled helpers. ')
output.print('Implement custom actions in your helper classes.')
}
output.print('PS: Actions are retrieved from enabled helpers. ')
output.print('Implement custom actions in your helper classes.')
}
18 changes: 18 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,24 @@ class Config {
return ran
}

/**
* Number of registered config hooks. Useful for snapshotting before a phase
* (e.g. plugin loading) and re-running only the hooks added during it.
* @return {number}
*/
static hooksCount() {
return hooks.length
}

/**
* Run hooks in `[fromIndex, end)` against the given config object, mutating it.
* @param {number} fromIndex
* @param {Object<string, *>} cfg
*/
static runHooksFrom(fromIndex, cfg) {
for (let i = fromIndex; i < hooks.length; i++) hooks[i](cfg)
}

/**
* Appends values to current config
*
Expand Down
5 changes: 2 additions & 3 deletions lib/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,8 @@ class Container {
// Wait for all async helpers to finish loading and populate the actor
await asyncHelperPromise

// Plugins may have registered Config hooks during their boot (e.g. the
// browser plugin pushing `setBrowserConfig` overrides). Run anything that
// hasn't been applied yet and re-feed the mutated helper config to the
// Plugins may have registered Config hooks during their boot. Run anything
// that hasn't been applied yet and re-feed the mutated helper config to the
// already-instantiated helpers.
if (Config.runPendingHooks(config)) {
for (const name of Object.keys(container.helpers)) {
Expand Down
55 changes: 53 additions & 2 deletions test/runner/list_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,66 @@ const __dirname = path.dirname(__filename);

const runner = path.join(__dirname, '/../../bin/codecept.js')
const codecept_dir = path.join(__dirname, '/../data/sandbox')
const codecept_config = path.join(codecept_dir, 'codecept.js')

describe('list commands', () => {
it('list should print actions', done => {
exec(`${runner} list ${codecept_dir}`, (err, stdout) => {
stdout.should.include('FileSystem') // helper name
stdout.should.include('FileSystem I.amInPath(openPath)') // action name
stdout.should.include('FileSystem')
stdout.should.include('FileSystem I.amInPath(openPath)')
stdout.should.include('FileSystem I.seeFile(name)')
assert(!err)
done()
})
})

it('list should accept -c with a config file path', done => {
exec(`${runner} list -c ${codecept_config}`, (err, stdout) => {
stdout.should.include('FileSystem I.amInPath(openPath)')
stdout.should.include('FileSystem I.seeFile(name)')
assert(!err)
done()
})
})

it('list --docs should print JSDoc descriptions for actions', done => {
exec(`${runner} list --docs -c ${codecept_config}`, (err, stdout) => {
stdout.should.include('FileSystem I.amInPath(openPath)')
stdout.should.include('Enters a directory In local filesystem.')
stdout.should.include('FileSystem I.seeFile(name)')
stdout.should.include('Checks that file exists')
assert(!err)
done()
})
})

it('list --action filters to a single action and implies --docs', done => {
exec(`${runner} list --action seeFile -c ${codecept_config}`, (err, stdout) => {
stdout.should.include('FileSystem I.seeFile(name)')
stdout.should.include('Checks that file exists')
stdout.should.not.include('I.amInPath(')
stdout.should.not.include('List of test actions:')
assert(!err)
done()
})
})

it('list --action accepts the I. prefix', done => {
exec(`${runner} list --action I.seeFile -c ${codecept_config}`, (err, stdout) => {
stdout.should.include('FileSystem I.seeFile(name)')
stdout.should.include('Checks that file exists')
stdout.should.not.include('I.amInPath(')
assert(!err)
done()
})
})

it('list --action prints a not-found message for an unknown action', done => {
exec(`${runner} list --action doesNotExist -c ${codecept_config}`, (err, stdout) => {
stdout.should.include('No action named')
stdout.should.include('doesNotExist')
assert(!err)
done()
})
})
})