diff --git a/bin/codecept.js b/bin/codecept.js index f8cd0a6cb..221546148 100755 --- a/bin/codecept.js +++ b/bin/codecept.js @@ -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 ', 'show docs for a single action (e.g. amOnPage or I.amOnPage)') + .action(commandHandler('../lib/command/list.js')) program .command('def [path]') diff --git a/docs/commands.md b/docs/commands.md index 8eaa66b61..8e087ae6c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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 diff --git a/lib/command/list.js b/lib/command/list.js index 7ef966cbf..bebe93a2a 100644 --- a/lib/command/list.js +++ b/lib/command/list.js @@ -1,3 +1,7 @@ +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' @@ -5,33 +9,169 @@ 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.') } diff --git a/lib/config.js b/lib/config.js index e209794e5..0a33d24fd 100644 --- a/lib/config.js +++ b/lib/config.js @@ -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} cfg + */ + static runHooksFrom(fromIndex, cfg) { + for (let i = fromIndex; i < hooks.length; i++) hooks[i](cfg) + } + /** * Appends values to current config * diff --git a/lib/container.js b/lib/container.js index 2fad4aca6..581674c28 100644 --- a/lib/container.js +++ b/lib/container.js @@ -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)) { diff --git a/test/runner/list_test.js b/test/runner/list_test.js index 88a7de5c1..968fd8f36 100644 --- a/test/runner/list_test.js +++ b/test/runner/list_test.js @@ -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() + }) + }) })