codeceptjs 4.0.0-rc.16 → 4.0.0-rc.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/codecept.js +10 -1
- package/bin/mcp-server.js +541 -172
- package/docs/webapi/seeFileDownloaded.mustache +23 -0
- package/lib/aria.js +260 -0
- package/lib/command/dryRun.js +14 -0
- package/lib/command/list.js +150 -10
- package/lib/config.js +68 -4
- package/lib/container.js +34 -2
- package/lib/helper/Playwright.js +1 -5
- package/lib/helper/extras/PlaywrightReactVueLocator.js +45 -36
- package/lib/html.js +87 -16
- package/lib/locator.js +12 -1
- package/lib/pause.js +38 -4
- package/lib/plugin/aiTrace.js +72 -84
- package/lib/plugin/browser.js +76 -0
- package/lib/plugin/heal.js +44 -1
- package/lib/plugin/pageInfo.js +51 -48
- package/lib/plugin/pause.js +131 -0
- package/lib/plugin/pauseOnFail.js +10 -34
- package/lib/plugin/screencast.js +287 -0
- package/lib/plugin/screenshot.js +563 -0
- package/lib/plugin/screenshotOnFail.js +8 -170
- package/lib/utils/pluginParser.js +151 -0
- package/lib/utils/trace.js +297 -0
- package/lib/utils.js +25 -0
- package/package.json +6 -6
- package/typings/index.d.ts +0 -5
- package/lib/helper/AI.js +0 -214
- package/lib/plugin/pauseOn.js +0 -167
- package/lib/plugin/stepByStepReport.js +0 -432
- package/lib/plugin/subtitles.js +0 -89
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Checks that a file was downloaded during the current test.
|
|
2
|
+
Downloads are automatically saved to `output/downloads`.
|
|
3
|
+
|
|
4
|
+
Can be called with different arguments:
|
|
5
|
+
|
|
6
|
+
- **No argument** — asserts that at least one file was downloaded.
|
|
7
|
+
- **Number** — asserts that exactly N files were downloaded.
|
|
8
|
+
- **String** — asserts that a file with the exact name was downloaded.
|
|
9
|
+
- **Glob pattern** (contains `*`, `?`, `[`) — asserts that a file matching the pattern was downloaded.
|
|
10
|
+
- **Regex string** (`/pattern/`) — asserts that a file matching the regex was downloaded.
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
I.click('Download');
|
|
14
|
+
I.seeFileDownloaded();
|
|
15
|
+
|
|
16
|
+
I.seeFileDownloaded('report.pdf');
|
|
17
|
+
I.seeFileDownloaded(2);
|
|
18
|
+
I.seeFileDownloaded('*.pdf');
|
|
19
|
+
I.seeFileDownloaded('/report-.+\\.pdf/');
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
@param {string|number} [arg] filename, number of files, glob pattern, or regex string.
|
|
23
|
+
@returns {void} automatically synchronized promise through #recorder
|
package/lib/aria.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import yaml from 'js-yaml'
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────
|
|
4
|
+
// Roles
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const INTERACTIVE_ROLES = new Set([
|
|
8
|
+
'button',
|
|
9
|
+
'link',
|
|
10
|
+
'textbox',
|
|
11
|
+
'searchbox',
|
|
12
|
+
'checkbox',
|
|
13
|
+
'radio',
|
|
14
|
+
'switch',
|
|
15
|
+
'combobox',
|
|
16
|
+
'listbox',
|
|
17
|
+
'listitem',
|
|
18
|
+
'menu',
|
|
19
|
+
'menuitem',
|
|
20
|
+
'menuitemcheckbox',
|
|
21
|
+
'menuitemradio',
|
|
22
|
+
'option',
|
|
23
|
+
'tab',
|
|
24
|
+
'tabpanel',
|
|
25
|
+
'slider',
|
|
26
|
+
'spinbutton',
|
|
27
|
+
'treeitem',
|
|
28
|
+
'gridcell',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
// Long groups of same-role siblings get summarised as: first N + "...M omitted..." + last N
|
|
32
|
+
const SIBLING_COLLAPSE_THRESHOLD = 50
|
|
33
|
+
const SIBLING_COLLAPSE_KEEP_EACH_SIDE = 5
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────
|
|
36
|
+
// STEP 1 · Parse: YAML text → AriaNode[]
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function unquote(value) {
|
|
40
|
+
const v = value.trim()
|
|
41
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
42
|
+
return v.slice(1, -1)
|
|
43
|
+
}
|
|
44
|
+
return v
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse one YAML node label like: `button "Save"`, `textbox "Email" [focused]`, `heading "Title" [level=2]`
|
|
48
|
+
function parseLabel(label) {
|
|
49
|
+
if (!label) return null
|
|
50
|
+
const trimmed = label.trim()
|
|
51
|
+
const roleMatch = trimmed.match(/^(\w+)/)
|
|
52
|
+
if (!roleMatch) return null
|
|
53
|
+
const role = roleMatch[1].toLowerCase()
|
|
54
|
+
let rest = trimmed.slice(roleMatch[0].length)
|
|
55
|
+
|
|
56
|
+
let name
|
|
57
|
+
const nameMatch = rest.match(/^\s*"((?:[^"\\]|\\.)*)"/) || rest.match(/^\s*'((?:[^'\\]|\\.)*)'/)
|
|
58
|
+
if (nameMatch) {
|
|
59
|
+
name = nameMatch[1]
|
|
60
|
+
rest = rest.slice(nameMatch[0].length)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const attributes = {}
|
|
64
|
+
const attrMatch = rest.match(/\[([^\]]*)\]/)
|
|
65
|
+
if (attrMatch) {
|
|
66
|
+
for (const tok of attrMatch[1].split(/[\s,]+/).filter(Boolean)) {
|
|
67
|
+
const eq = tok.indexOf('=')
|
|
68
|
+
if (eq === -1) {
|
|
69
|
+
attributes[tok.toLowerCase()] = true
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
attributes[tok.slice(0, eq).trim().toLowerCase()] = unquote(tok.slice(eq + 1))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { role, name, attributes }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function yamlItemToNode(item) {
|
|
80
|
+
if (typeof item === 'string') {
|
|
81
|
+
const label = parseLabel(item)
|
|
82
|
+
if (!label) return null
|
|
83
|
+
const node = { role: label.role, name: label.name, attributes: label.attributes, children: [] }
|
|
84
|
+
return node
|
|
85
|
+
}
|
|
86
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
|
|
87
|
+
|
|
88
|
+
const entries = Object.entries(item)
|
|
89
|
+
if (entries.length === 0) return null
|
|
90
|
+
const [key, value] = entries[0]
|
|
91
|
+
const label = parseLabel(key)
|
|
92
|
+
if (!label) return null
|
|
93
|
+
const node = { role: label.role, name: label.name, attributes: label.attributes, children: [] }
|
|
94
|
+
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
node.children = value.map(yamlItemToNode).filter(n => n !== null)
|
|
97
|
+
return node
|
|
98
|
+
}
|
|
99
|
+
if (value !== null && value !== undefined) node.value = String(value)
|
|
100
|
+
return node
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseSnapshot(snapshot) {
|
|
104
|
+
if (!snapshot) return []
|
|
105
|
+
let parsed
|
|
106
|
+
try {
|
|
107
|
+
parsed = yaml.load(snapshot)
|
|
108
|
+
} catch {
|
|
109
|
+
return []
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(parsed)) return []
|
|
112
|
+
return parsed.map(yamlItemToNode).filter(n => n !== null)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────
|
|
116
|
+
// STEP 2 · Transform: drop containers that contribute nothing.
|
|
117
|
+
// ─────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function dropEmpty(nodes) {
|
|
120
|
+
return nodes.flatMap(node => {
|
|
121
|
+
const children = dropEmpty(node.children)
|
|
122
|
+
if (INTERACTIVE_ROLES.has(node.role)) return [{ ...node, children }]
|
|
123
|
+
if (children.length > 0) return [{ ...node, children }]
|
|
124
|
+
return []
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────
|
|
129
|
+
// STEP 3 · Render: AriaNode[] → indented YAML text
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
// One-line representation of a node. Stable attr order so diff comparisons are deterministic.
|
|
133
|
+
function formatNode(node) {
|
|
134
|
+
let line = node.role
|
|
135
|
+
if (node.name && node.name.trim()) line += ` "${node.name.trim()}"`
|
|
136
|
+
const attrParts = []
|
|
137
|
+
for (const k of Object.keys(node.attributes).sort()) {
|
|
138
|
+
const v = node.attributes[k]
|
|
139
|
+
if (v === undefined || v === null || v === '') continue
|
|
140
|
+
if (v === true) attrParts.push(k)
|
|
141
|
+
else attrParts.push(`${k}=${v}`)
|
|
142
|
+
}
|
|
143
|
+
if (attrParts.length > 0) line += ` [${attrParts.join(' ')}]`
|
|
144
|
+
if (node.value !== undefined && node.value !== null) {
|
|
145
|
+
const text = String(node.value).trim()
|
|
146
|
+
if (text) line += `: ${text}`
|
|
147
|
+
}
|
|
148
|
+
return line
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Group consecutive same-role siblings. [a,a,b,a,a,a] → [[a,a],[b],[a,a,a]]
|
|
152
|
+
function groupByConsecutiveRole(nodes) {
|
|
153
|
+
return nodes.reduce((groups, node) => {
|
|
154
|
+
const last = groups[groups.length - 1]
|
|
155
|
+
if (last && last[0].role === node.role) {
|
|
156
|
+
last.push(node)
|
|
157
|
+
return groups
|
|
158
|
+
}
|
|
159
|
+
groups.push([node])
|
|
160
|
+
return groups
|
|
161
|
+
}, [])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Large group of same-role siblings → first N + placeholder line + last N.
|
|
165
|
+
// Returns mix of AriaNode (to render) and pre-rendered placeholder strings.
|
|
166
|
+
function collapseGroup(group, depth) {
|
|
167
|
+
if (group.length <= SIBLING_COLLAPSE_THRESHOLD) return group
|
|
168
|
+
const keep = SIBLING_COLLAPSE_KEEP_EACH_SIDE
|
|
169
|
+
const omitted = group.length - keep * 2
|
|
170
|
+
const placeholder = `${' '.repeat(depth)}- ...${omitted} similar "${group[0].role}" items omitted...`
|
|
171
|
+
return [...group.slice(0, keep), placeholder, ...group.slice(-keep)]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderTree(nodes, depth = 0) {
|
|
175
|
+
const items = groupByConsecutiveRole(nodes).flatMap(group => collapseGroup(group, depth))
|
|
176
|
+
return items
|
|
177
|
+
.map(item => {
|
|
178
|
+
if (typeof item === 'string') return item
|
|
179
|
+
const indent = ' '.repeat(depth)
|
|
180
|
+
const head = `${indent}- ${formatNode(item)}`
|
|
181
|
+
if (item.children.length === 0) return head
|
|
182
|
+
return `${head}:\n${renderTree(item.children, depth + 1)}`
|
|
183
|
+
})
|
|
184
|
+
.join('\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─────────────────────────────────────────────────────────────────
|
|
188
|
+
// STEP 4 · Diff: collect interactive summaries → bag diff → text
|
|
189
|
+
// ─────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
// Walk tree, emit one summary string per meaningful interactive node.
|
|
192
|
+
function collectSummaries(nodes) {
|
|
193
|
+
return nodes.flatMap(node => {
|
|
194
|
+
const fromChildren = collectSummaries(node.children)
|
|
195
|
+
if (!INTERACTIVE_ROLES.has(node.role)) return fromChildren
|
|
196
|
+
const summary = formatNode(node)
|
|
197
|
+
if (summary === node.role) return fromChildren // skip empty unnamed interactive nodes
|
|
198
|
+
return [summary, ...fromChildren]
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function countBy(items) {
|
|
203
|
+
return items.reduce((map, item) => {
|
|
204
|
+
map.set(item, (map.get(item) ?? 0) + 1)
|
|
205
|
+
return map
|
|
206
|
+
}, new Map())
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Bag diff: any summary appearing more in one bag than the other becomes added/removed.
|
|
210
|
+
function diffSummaries(prev, curr) {
|
|
211
|
+
const before = countBy(prev)
|
|
212
|
+
const after = countBy(curr)
|
|
213
|
+
const added = []
|
|
214
|
+
const removed = []
|
|
215
|
+
for (const summary of new Set([...before.keys(), ...after.keys()])) {
|
|
216
|
+
const b = before.get(summary) ?? 0
|
|
217
|
+
const a = after.get(summary) ?? 0
|
|
218
|
+
for (let i = 0; i < a - b; i += 1) added.push(summary)
|
|
219
|
+
for (let i = 0; i < b - a; i += 1) removed.push(summary)
|
|
220
|
+
}
|
|
221
|
+
return { added, removed }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function formatDiff(added, removed) {
|
|
225
|
+
if (added.length === 0 && removed.length === 0) return null
|
|
226
|
+
const lines = ['ariaDiff:']
|
|
227
|
+
if (added.length === 0) {
|
|
228
|
+
lines.push(' added: []')
|
|
229
|
+
} else {
|
|
230
|
+
lines.push(' added:')
|
|
231
|
+
for (const [item, count] of [...countBy(added).entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
232
|
+
const suffix = count > 1 ? ` (x${count})` : ''
|
|
233
|
+
lines.push(` - ${item}${suffix}`)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (removed.length === 0) {
|
|
237
|
+
lines.push(' removed: []')
|
|
238
|
+
} else {
|
|
239
|
+
lines.push(` removed: ${removed.length} interactive elements`)
|
|
240
|
+
}
|
|
241
|
+
return lines.join('\n')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─────────────────────────────────────────────────────────────────
|
|
245
|
+
// Public API — pipelines composed visibly, top-to-bottom
|
|
246
|
+
// ─────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
function compactAriaSnapshot(snapshot) {
|
|
249
|
+
if (!snapshot) return ''
|
|
250
|
+
const tree = dropEmpty(parseSnapshot(snapshot))
|
|
251
|
+
return renderTree(tree)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function diffAriaSnapshots(previous, current) {
|
|
255
|
+
const summariesOf = snap => collectSummaries(dropEmpty(parseSnapshot(snap)))
|
|
256
|
+
const { added, removed } = diffSummaries(summariesOf(previous), summariesOf(current))
|
|
257
|
+
return formatDiff(added, removed)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export { diffAriaSnapshots, compactAriaSnapshot }
|
package/lib/command/dryRun.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
1
2
|
import { getConfig, getTestRoot } from './utils.js'
|
|
2
3
|
import Config from '../config.js'
|
|
3
4
|
import Codecept from '../codecept.js'
|
|
@@ -8,6 +9,7 @@ import Container from '../container.js'
|
|
|
8
9
|
|
|
9
10
|
export default async function (test, options) {
|
|
10
11
|
if (options.grep) process.env.grep = options.grep
|
|
12
|
+
if (options.ansi === false) chalk.level = 0
|
|
11
13
|
const configFile = options.config
|
|
12
14
|
let codecept
|
|
13
15
|
|
|
@@ -37,6 +39,7 @@ export default async function (test, options) {
|
|
|
37
39
|
await printTests(codecept.testFiles)
|
|
38
40
|
return
|
|
39
41
|
}
|
|
42
|
+
if (options.numbers) numberSteps()
|
|
40
43
|
event.dispatcher.on(event.all.result, printFooter)
|
|
41
44
|
await codecept.run(test)
|
|
42
45
|
} catch (err) {
|
|
@@ -45,6 +48,17 @@ export default async function (test, options) {
|
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
function numberSteps() {
|
|
52
|
+
let stepIndex = 0
|
|
53
|
+
event.dispatcher.on(event.test.before, () => {
|
|
54
|
+
stepIndex = 0
|
|
55
|
+
})
|
|
56
|
+
event.dispatcher.prependListener(event.step.before, step => {
|
|
57
|
+
stepIndex++
|
|
58
|
+
step.prefix = `${stepIndex}. ${step.prefix || ''}`
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
async function printTests(files) {
|
|
49
63
|
const { default: figures } = await import('figures')
|
|
50
64
|
const { default: colors } = await import('chalk')
|
package/lib/command/list.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
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'
|
|
@@ -5,33 +9,169 @@ import { getParamsToString } from '../parser.js'
|
|
|
5
9
|
import { methodsOfObject } from '../utils.js'
|
|
6
10
|
import output from '../output.js'
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/config.js
CHANGED
|
@@ -15,8 +15,9 @@ const defaultConfig = {
|
|
|
15
15
|
hooks: [],
|
|
16
16
|
gherkin: {},
|
|
17
17
|
plugins: {
|
|
18
|
-
|
|
19
|
-
enabled: true,
|
|
18
|
+
screenshot: {
|
|
19
|
+
enabled: true,
|
|
20
|
+
on: 'fail',
|
|
20
21
|
},
|
|
21
22
|
},
|
|
22
23
|
stepTimeout: 0,
|
|
@@ -32,9 +33,27 @@ const defaultConfig = {
|
|
|
32
33
|
],
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// Array<{ fn: (cfg) => void, ran: boolean, error?: Error }>
|
|
35
37
|
let hooks = []
|
|
36
38
|
let config = {}
|
|
37
39
|
|
|
40
|
+
// Apply a single hook against `cfg`, swallowing errors so one broken hook
|
|
41
|
+
// can't take down the whole run. The failure is logged through the
|
|
42
|
+
// framework's own output module (when available) so it shows up in test
|
|
43
|
+
// reports; the hook is still marked ran so it doesn't get retried.
|
|
44
|
+
function applyHook(hook, cfg) {
|
|
45
|
+
try {
|
|
46
|
+
hook.fn(cfg)
|
|
47
|
+
} catch (err) {
|
|
48
|
+
hook.error = err
|
|
49
|
+
const out = globalThis.codeceptjs?.output
|
|
50
|
+
if (out && typeof out.error === 'function') out.error(`config hook failed: ${err.message}`)
|
|
51
|
+
else console.error('config hook failed:', err)
|
|
52
|
+
} finally {
|
|
53
|
+
hook.ran = true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
const configFileNames = ['codecept.config.js', 'codecept.conf.js', 'codecept.js', 'codecept.config.cjs', 'codecept.conf.cjs', 'codecept.config.ts', 'codecept.conf.ts']
|
|
39
58
|
|
|
40
59
|
/**
|
|
@@ -49,7 +68,11 @@ class Config {
|
|
|
49
68
|
*/
|
|
50
69
|
static create(newConfig) {
|
|
51
70
|
config = deepMerge(deepClone(defaultConfig), newConfig)
|
|
52
|
-
hooks
|
|
71
|
+
// Re-apply every hook against the freshly built config; hooks added later
|
|
72
|
+
// (e.g. from plugin boot) stay pending until runPendingHooks. Array
|
|
73
|
+
// iterators re-check length on each step, so hooks pushed during a hook
|
|
74
|
+
// execution are visited in this same pass.
|
|
75
|
+
for (const hook of hooks) applyHook(hook, config)
|
|
53
76
|
return config
|
|
54
77
|
}
|
|
55
78
|
|
|
@@ -121,7 +144,48 @@ class Config {
|
|
|
121
144
|
}
|
|
122
145
|
|
|
123
146
|
static addHook(fn) {
|
|
124
|
-
hooks.push(fn)
|
|
147
|
+
hooks.push({ fn, ran: false })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Run every hook that hasn't been applied to the current config yet.
|
|
152
|
+
* Hooks added after `Config.create()` (e.g. from plugin boot code) stay
|
|
153
|
+
* pending until this is called; once it runs, they're marked applied so
|
|
154
|
+
* subsequent calls are no-ops. Hooks added while pending hooks are running
|
|
155
|
+
* are picked up in the same pass (the array iterator re-checks length).
|
|
156
|
+
*
|
|
157
|
+
* Failures are logged through `output.error` and don't abort the loop —
|
|
158
|
+
* a broken hook can't poison the run, but its error is visible.
|
|
159
|
+
*
|
|
160
|
+
* @param {Object<string, *>} [cfg] target config (defaults to the live singleton)
|
|
161
|
+
* @return {boolean} true if any hook ran
|
|
162
|
+
*/
|
|
163
|
+
static runPendingHooks(cfg = config) {
|
|
164
|
+
let ran = false
|
|
165
|
+
for (const hook of hooks) {
|
|
166
|
+
if (hook.ran) continue
|
|
167
|
+
applyHook(hook, cfg)
|
|
168
|
+
ran = true
|
|
169
|
+
}
|
|
170
|
+
return ran
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Number of registered config hooks. Useful for snapshotting before a phase
|
|
175
|
+
* (e.g. plugin loading) and re-running only the hooks added during it.
|
|
176
|
+
* @return {number}
|
|
177
|
+
*/
|
|
178
|
+
static hooksCount() {
|
|
179
|
+
return hooks.length
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run hooks in `[fromIndex, end)` against the given config object, mutating it.
|
|
184
|
+
* @param {number} fromIndex
|
|
185
|
+
* @param {Object<string, *>} cfg
|
|
186
|
+
*/
|
|
187
|
+
static runHooksFrom(fromIndex, cfg) {
|
|
188
|
+
for (let i = fromIndex; i < hooks.length; i++) hooks[i](cfg)
|
|
125
189
|
}
|
|
126
190
|
|
|
127
191
|
/**
|
package/lib/container.js
CHANGED
|
@@ -15,6 +15,7 @@ import store from './store.js'
|
|
|
15
15
|
import Result from './result.js'
|
|
16
16
|
import ai from './ai.js'
|
|
17
17
|
import actorFactory from './actor.js'
|
|
18
|
+
import Config from './config.js'
|
|
18
19
|
|
|
19
20
|
let asyncHelperPromise
|
|
20
21
|
|
|
@@ -121,6 +122,18 @@ class Container {
|
|
|
121
122
|
// Wait for all async helpers to finish loading and populate the actor
|
|
122
123
|
await asyncHelperPromise
|
|
123
124
|
|
|
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
|
|
127
|
+
// already-instantiated helpers.
|
|
128
|
+
if (Config.runPendingHooks(config)) {
|
|
129
|
+
for (const name of Object.keys(container.helpers)) {
|
|
130
|
+
const helper = container.helpers[name]
|
|
131
|
+
if (helper && typeof helper._setConfig === 'function' && config.helpers && config.helpers[name]) {
|
|
132
|
+
helper._setConfig(config.helpers[name])
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
124
137
|
if (opts && opts.ai) ai.enable(config.ai) // enable AI Assistant
|
|
125
138
|
if (config.gherkin) await loadGherkinStepsAsync(config.gherkin.steps || [])
|
|
126
139
|
if (opts && typeof opts.timeouts === 'boolean') store.timeouts = opts.timeouts
|
|
@@ -748,12 +761,24 @@ async function createPlugins(config, options = {}) {
|
|
|
748
761
|
}
|
|
749
762
|
|
|
750
763
|
async function loadGherkinStepsAsync(paths) {
|
|
764
|
+
// Import BDD module to access step file tracking functions and step DSL
|
|
765
|
+
const bddModule = await import('./mocha/bdd.js')
|
|
766
|
+
|
|
751
767
|
global.Before = fn => event.dispatcher.on(event.test.started, fn)
|
|
752
768
|
global.After = fn => event.dispatcher.on(event.test.finished, fn)
|
|
753
769
|
global.Fail = fn => event.dispatcher.on(event.test.failed, fn)
|
|
754
770
|
|
|
755
|
-
//
|
|
756
|
-
|
|
771
|
+
// Scope-inject Given/When/Then/And while loading step files so they work
|
|
772
|
+
// with noGlobals: true. When noGlobals: false, globals.js has already set
|
|
773
|
+
// them as permanent globals — skip to avoid deleting them at the end.
|
|
774
|
+
const injectStepDsl = !!store.noGlobals
|
|
775
|
+
if (injectStepDsl) {
|
|
776
|
+
global.Given = bddModule.Given
|
|
777
|
+
global.When = bddModule.When
|
|
778
|
+
global.Then = bddModule.Then
|
|
779
|
+
global.And = bddModule.And
|
|
780
|
+
global.DefineParameterType = bddModule.defineParameterType
|
|
781
|
+
}
|
|
757
782
|
|
|
758
783
|
// If gherkin.steps is string, then this will iterate through that folder and send all step def js files to loadSupportObject
|
|
759
784
|
// If gherkin.steps is Array, it will go the old way
|
|
@@ -781,6 +806,13 @@ async function loadGherkinStepsAsync(paths) {
|
|
|
781
806
|
delete global.Before
|
|
782
807
|
delete global.After
|
|
783
808
|
delete global.Fail
|
|
809
|
+
if (injectStepDsl) {
|
|
810
|
+
delete global.Given
|
|
811
|
+
delete global.When
|
|
812
|
+
delete global.Then
|
|
813
|
+
delete global.And
|
|
814
|
+
delete global.DefineParameterType
|
|
815
|
+
}
|
|
784
816
|
}
|
|
785
817
|
|
|
786
818
|
function loadGherkinSteps(paths) {
|