|
| 1 | +import fs from 'fs' |
| 2 | +import path from 'path' |
| 3 | +import { fileURLToPath } from 'url' |
| 4 | +import * as acorn from 'acorn' |
1 | 5 | import { getConfig, getTestRoot } from './utils.js' |
2 | 6 | import Codecept from '../codecept.js' |
3 | 7 | import container from '../container.js' |
4 | 8 | import { getParamsToString } from '../parser.js' |
5 | 9 | import { methodsOfObject } from '../utils.js' |
6 | 10 | import output from '../output.js' |
7 | 11 |
|
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) |
11 | 136 | const codecept = new Codecept(config, {}) |
12 | 137 | await codecept.init(testsPath) |
13 | 138 | await container.started() |
14 | 139 |
|
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: -- ') |
16 | 145 | const helpers = container.helpers() |
17 | 146 | const supportI = container.support('I') |
18 | 147 | const actions = [] |
| 148 | + let matched = false |
19 | 149 | for (const name in helpers) { |
20 | 150 | const helper = helpers[name] |
| 151 | + const docs = showDocs ? extractMethodDocs(helper, name, config, testsPath, partials) : null |
21 | 152 | methodsOfObject(helper).forEach(action => { |
22 | | - const params = getParamsToString(helper[action]) |
23 | 153 | actions[action] = 1 |
| 154 | + if (filter && action !== filter) return |
| 155 | + const params = getParamsToString(helper[action]) |
24 | 156 | 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 |
25 | 159 | }) |
26 | 160 | } |
27 | 161 | for (const name in supportI) { |
28 | | - if (actions[name]) { |
29 | | - continue |
30 | | - } |
| 162 | + if (actions[name]) continue |
| 163 | + if (filter && name !== filter) continue |
31 | 164 | const actor = supportI[name] |
32 | 165 | const params = getParamsToString(actor) |
33 | 166 | 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.') |
34 | 176 | } |
35 | | - output.print('PS: Actions are retrieved from enabled helpers. ') |
36 | | - output.print('Implement custom actions in your helper classes.') |
37 | 177 | } |
0 commit comments