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
package/lib/helper/Playwright.js
CHANGED
|
@@ -36,7 +36,7 @@ import MultipleElementsFound from './errors/MultipleElementsFound.js'
|
|
|
36
36
|
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
|
|
37
37
|
import Popup from './extras/Popup.js'
|
|
38
38
|
import Console from './extras/Console.js'
|
|
39
|
-
import { findReact,
|
|
39
|
+
import { findReact, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
|
|
40
40
|
import { dropFile } from './scripts/dropFile.js'
|
|
41
41
|
import WebElement from '../element/WebElement.js'
|
|
42
42
|
import { selectElement } from './extras/elementSelection.js'
|
|
@@ -4223,13 +4223,10 @@ async function findByRole(context, locator) {
|
|
|
4223
4223
|
}
|
|
4224
4224
|
|
|
4225
4225
|
async function findElements(matcher, locator) {
|
|
4226
|
-
// Check if locator is a Locator object with react/vue type, or a raw object with react/vue property
|
|
4227
4226
|
const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react
|
|
4228
|
-
const isVueLocator = locator.type === 'vue' || (locator.locator && locator.locator.vue) || locator.vue
|
|
4229
4227
|
const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
|
|
4230
4228
|
|
|
4231
4229
|
if (isReactLocator) return findReact(matcher, locator)
|
|
4232
|
-
if (isVueLocator) return findVue(matcher, locator)
|
|
4233
4230
|
if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
|
|
4234
4231
|
|
|
4235
4232
|
// Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
|
|
@@ -4245,7 +4242,6 @@ async function findElements(matcher, locator) {
|
|
|
4245
4242
|
|
|
4246
4243
|
async function findElement(matcher, locator) {
|
|
4247
4244
|
if (locator.react) return findReact(matcher, locator)
|
|
4248
|
-
if (locator.vue) return findVue(matcher, locator)
|
|
4249
4245
|
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
|
|
4250
4246
|
|
|
4251
4247
|
locator = new Locator(locator, 'css')
|
|
@@ -1,52 +1,61 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { fileURLToPath } from 'url'
|
|
3
|
+
|
|
4
|
+
let resqScript
|
|
5
|
+
|
|
1
6
|
async function findReact(matcher, locator) {
|
|
2
|
-
// Handle both Locator objects and raw locator objects
|
|
3
7
|
const reactLocator = locator.locator || locator
|
|
4
|
-
|
|
5
|
-
let props = '';
|
|
8
|
+
const page = typeof matcher.page === 'function' ? matcher.page() : matcher
|
|
6
9
|
|
|
7
|
-
if (
|
|
8
|
-
|
|
9
|
-
_locator += props;
|
|
10
|
+
if (!resqScript) {
|
|
11
|
+
resqScript = fs.readFileSync(fileURLToPath(import.meta.resolve('resq'))).toString()
|
|
10
12
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
await page.evaluate(resqScript)
|
|
14
|
+
await page.evaluate(() => window.resq.waitToLoadReact())
|
|
15
|
+
|
|
16
|
+
const arrayHandle = await page.evaluateHandle(
|
|
17
|
+
({ selector, props, state }) => {
|
|
18
|
+
let elements = window.resq.resq$$(selector)
|
|
19
|
+
if (Object.keys(props).length) elements = elements.byProps(props)
|
|
20
|
+
if (Object.keys(state).length) elements = elements.byState(state)
|
|
21
|
+
if (!elements.length) return []
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
let nodes = []
|
|
24
|
+
elements.forEach(element => {
|
|
25
|
+
let { node, isFragment } = element
|
|
26
|
+
if (!node) {
|
|
27
|
+
isFragment = true
|
|
28
|
+
node = element.children
|
|
29
|
+
}
|
|
30
|
+
if (isFragment) nodes = nodes.concat(node)
|
|
31
|
+
else nodes.push(node)
|
|
32
|
+
})
|
|
33
|
+
return [...nodes]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
selector: reactLocator.react,
|
|
37
|
+
props: reactLocator.props || {},
|
|
38
|
+
state: reactLocator.state || {},
|
|
39
|
+
},
|
|
40
|
+
)
|
|
19
41
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
42
|
+
const properties = await arrayHandle.getProperties()
|
|
43
|
+
await arrayHandle.dispose()
|
|
44
|
+
const result = []
|
|
45
|
+
for (const property of properties.values()) {
|
|
46
|
+
const elementHandle = property.asElement()
|
|
47
|
+
if (elementHandle) result.push(elementHandle)
|
|
23
48
|
}
|
|
24
|
-
return
|
|
49
|
+
return result
|
|
25
50
|
}
|
|
26
51
|
|
|
27
52
|
async function findByPlaywrightLocator(matcher, locator) {
|
|
28
|
-
// Handle both Locator objects and raw locator objects
|
|
29
53
|
const pwLocator = locator.locator || locator
|
|
30
54
|
if (pwLocator && pwLocator.toString && pwLocator.toString().includes(process.env.testIdAttribute)) {
|
|
31
|
-
return matcher.getByTestId(pwLocator.pw.value.split('=')[1])
|
|
55
|
+
return matcher.getByTestId(pwLocator.pw.value.split('=')[1])
|
|
32
56
|
}
|
|
33
57
|
const pwValue = typeof pwLocator.pw === 'string' ? pwLocator.pw : pwLocator.pw
|
|
34
|
-
return matcher.locator(pwValue).all()
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function propBuilder(props) {
|
|
38
|
-
let _props = '';
|
|
39
|
-
|
|
40
|
-
for (const [key, value] of Object.entries(props)) {
|
|
41
|
-
if (typeof value === 'object') {
|
|
42
|
-
for (const [k, v] of Object.entries(value)) {
|
|
43
|
-
_props += `[${key}.${k} = "${v}"]`;
|
|
44
|
-
}
|
|
45
|
-
} else {
|
|
46
|
-
_props += `[${key} = "${value}"]`;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return _props;
|
|
58
|
+
return matcher.locator(pwValue).all()
|
|
50
59
|
}
|
|
51
60
|
|
|
52
|
-
export { findReact,
|
|
61
|
+
export { findReact, findByPlaywrightLocator }
|
package/lib/html.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { parse, serialize } from 'parse5'
|
|
2
2
|
import { minify } from 'html-minifier-terser'
|
|
3
|
+
import beautify from 'js-beautify'
|
|
4
|
+
|
|
5
|
+
const { html: html_beautify } = beautify
|
|
3
6
|
|
|
4
7
|
async function minifyHtml(html) {
|
|
5
8
|
return minify(html, {
|
|
@@ -14,6 +17,62 @@ async function minifyHtml(html) {
|
|
|
14
17
|
})
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
const TRASH_HTML_CLASSES = /^(text-|color-|flex-|float-|v-|ember-|d-|border-)/
|
|
21
|
+
|
|
22
|
+
function isTrashClass(className) {
|
|
23
|
+
if (!className) return true
|
|
24
|
+
if (/\d/.test(className)) return true
|
|
25
|
+
if (TRASH_HTML_CLASSES.test(className)) return true
|
|
26
|
+
if (/(:|__)/.test(className)) return true
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function filterClassValue(value) {
|
|
31
|
+
return (value || '')
|
|
32
|
+
.split(/\s+/)
|
|
33
|
+
.filter(c => c && !isTrashClass(c))
|
|
34
|
+
.join(' ')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DROP_TAGS = new Set(['style', 'noscript'])
|
|
38
|
+
const DROP_ATTRS = new Set(['style'])
|
|
39
|
+
|
|
40
|
+
function cleanHtml(html) {
|
|
41
|
+
const document = parse(html)
|
|
42
|
+
|
|
43
|
+
function walk(node) {
|
|
44
|
+
if (!node) return false
|
|
45
|
+
|
|
46
|
+
if (DROP_TAGS.has(node.nodeName) || (node.nodeName === 'script' && !(node.attrs || []).some(a => a.name === 'src'))) {
|
|
47
|
+
const parent = node.parentNode
|
|
48
|
+
const idx = parent.childNodes.indexOf(node)
|
|
49
|
+
if (idx >= 0) parent.childNodes.splice(idx, 1)
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (node.attrs) {
|
|
54
|
+
node.attrs = node.attrs.filter(attr => {
|
|
55
|
+
if (DROP_ATTRS.has(attr.name)) return false
|
|
56
|
+
if (attr.name === 'class') {
|
|
57
|
+
attr.value = filterClassValue(attr.value)
|
|
58
|
+
if (!attr.value) return false
|
|
59
|
+
}
|
|
60
|
+
return true
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (node.childNodes) {
|
|
65
|
+
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
|
66
|
+
walk(node.childNodes[i])
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
walk(document)
|
|
73
|
+
return serialize(document)
|
|
74
|
+
}
|
|
75
|
+
|
|
17
76
|
const defaultHtmlOpts = {
|
|
18
77
|
interactiveElements: ['a', 'input', 'button', 'select', 'textarea', 'option'],
|
|
19
78
|
textElements: ['label', 'h1', 'h2'],
|
|
@@ -28,7 +87,6 @@ function removeNonInteractiveElements(html, opts = {}) {
|
|
|
28
87
|
// Parse the HTML into a document tree
|
|
29
88
|
const document = parse(html)
|
|
30
89
|
|
|
31
|
-
const trashHtmlClasses = /^(text-|color-|flex-|float-|v-|ember-|d-|border-)/
|
|
32
90
|
// Array to store interactive elements
|
|
33
91
|
const removeElements = ['path', 'script']
|
|
34
92
|
|
|
@@ -103,21 +161,10 @@ function removeNonInteractiveElements(html, opts = {}) {
|
|
|
103
161
|
if (node.attrs) {
|
|
104
162
|
// Filter and keep allowed attributes, accessibility attributes
|
|
105
163
|
node.attrs = node.attrs.filter(attr => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// Remove classes containing digits
|
|
109
|
-
attr.value = value
|
|
110
|
-
.split(' ')
|
|
111
|
-
// remove classes containing digits/
|
|
112
|
-
.filter(className => !/\d/.test(className))
|
|
113
|
-
// remove popular trash classes
|
|
114
|
-
.filter(className => !className.match(trashHtmlClasses))
|
|
115
|
-
// remove classes with : and __ in them
|
|
116
|
-
.filter(className => !className.match(/(:|__)/))
|
|
117
|
-
.join(' ')
|
|
164
|
+
if (attr.name === 'class') {
|
|
165
|
+
attr.value = filterClassValue(attr.value)
|
|
118
166
|
}
|
|
119
|
-
|
|
120
|
-
return allowedAttrs.includes(name)
|
|
167
|
+
return allowedAttrs.includes(attr.name)
|
|
121
168
|
})
|
|
122
169
|
}
|
|
123
170
|
|
|
@@ -258,4 +305,28 @@ function simplifyHtmlElement(html, maxLength = 300) {
|
|
|
258
305
|
return html
|
|
259
306
|
}
|
|
260
307
|
|
|
261
|
-
|
|
308
|
+
async function formatHtml(html) {
|
|
309
|
+
let processed = html
|
|
310
|
+
try {
|
|
311
|
+
processed = await minifyHtml(processed)
|
|
312
|
+
} catch (e) {
|
|
313
|
+
// keep raw html if minification fails
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
processed = cleanHtml(processed)
|
|
317
|
+
} catch (e) {
|
|
318
|
+
// keep minified html if cleaning fails
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
return html_beautify(processed, {
|
|
322
|
+
indent_size: 2,
|
|
323
|
+
wrap_line_length: 0,
|
|
324
|
+
preserve_newlines: false,
|
|
325
|
+
end_with_newline: false,
|
|
326
|
+
})
|
|
327
|
+
} catch (e) {
|
|
328
|
+
return processed
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export { scanForErrorMessages, removeNonInteractiveElements, splitByChunks, minifyHtml, simplifyHtmlElement, formatHtml, cleanHtml, isTrashClass }
|
package/lib/locator.js
CHANGED
|
@@ -591,13 +591,24 @@ Locator.clickable = {
|
|
|
591
591
|
`.//*[@title = ${literal}]`,
|
|
592
592
|
`.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`,
|
|
593
593
|
`.//*[@role='button'][normalize-space(.)=${literal}]`,
|
|
594
|
+
`.//*[@role='tab' or @role='link' or @role='menuitem' or @role='menuitemcheckbox' or @role='menuitemradio' or @role='option' or @role='treeitem'][contains(normalize-space(string(.)), ${literal})]`,
|
|
594
595
|
]),
|
|
595
596
|
|
|
596
597
|
/**
|
|
597
598
|
* @param {string} literal
|
|
598
599
|
* @returns {string}
|
|
599
600
|
*/
|
|
600
|
-
self: literal =>
|
|
601
|
+
self: literal => {
|
|
602
|
+
// Narrowest-match: prefer the deepest descendant whose string-value contains the literal.
|
|
603
|
+
// Falling back to `self` without the `not(descendant...)` guard would match a container
|
|
604
|
+
// whose concatenated text happens to include the literal (e.g. a <ul role="tablist"> whose
|
|
605
|
+
// tab labels all sit in its string-value) and click the container itself.
|
|
606
|
+
const narrowest = `contains(normalize-space(string(.)), ${literal}) and not(.//*[contains(normalize-space(string(.)), ${literal})])`
|
|
607
|
+
return xpathLocator.combine([
|
|
608
|
+
`.//*[${narrowest}]`,
|
|
609
|
+
`./self::*[${narrowest} or contains(normalize-space(@value), ${literal})]`,
|
|
610
|
+
])
|
|
611
|
+
},
|
|
601
612
|
}
|
|
602
613
|
|
|
603
614
|
Locator.field = {
|
package/lib/pause.js
CHANGED
|
@@ -18,6 +18,8 @@ let nextStep
|
|
|
18
18
|
let finish
|
|
19
19
|
let next
|
|
20
20
|
let registeredVariables = {}
|
|
21
|
+
let externalHandler = null
|
|
22
|
+
|
|
21
23
|
/**
|
|
22
24
|
* Pauses test execution and starts interactive shell
|
|
23
25
|
* @param {Object<string, *>} [passedObject]
|
|
@@ -37,10 +39,10 @@ const pause = function (passedObject = {}) {
|
|
|
37
39
|
})
|
|
38
40
|
|
|
39
41
|
event.dispatcher.on(event.test.finished, () => {
|
|
40
|
-
finish()
|
|
42
|
+
if (typeof finish === 'function') finish()
|
|
41
43
|
recorder.session.restore('pause')
|
|
42
|
-
rl.close()
|
|
43
|
-
history.save()
|
|
44
|
+
if (rl) rl.close()
|
|
45
|
+
if (!externalHandler) history.save()
|
|
44
46
|
})
|
|
45
47
|
|
|
46
48
|
recorder.add('Start new session', () => pauseSession(passedObject))
|
|
@@ -49,6 +51,15 @@ const pause = function (passedObject = {}) {
|
|
|
49
51
|
function pauseSession(passedObject = {}) {
|
|
50
52
|
registeredVariables = passedObject
|
|
51
53
|
recorder.session.start('pause')
|
|
54
|
+
|
|
55
|
+
if (externalHandler) {
|
|
56
|
+
store.onPause = true
|
|
57
|
+
return externalHandler({ registeredVariables }).then(() => {
|
|
58
|
+
store.onPause = false
|
|
59
|
+
recorder.session.restore('pause')
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
if (!next) {
|
|
53
64
|
let vars = Object.keys(registeredVariables).join(', ')
|
|
54
65
|
if (vars) vars = `(vars: ${vars})`
|
|
@@ -234,5 +245,28 @@ function registerVariable(name, value) {
|
|
|
234
245
|
registeredVariables[name] = value
|
|
235
246
|
}
|
|
236
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Hook for external pause drivers (e.g. the MCP server). When set, pauseSession
|
|
250
|
+
* delegates to the handler instead of opening a readline REPL. The handler
|
|
251
|
+
* receives `{ registeredVariables }` and returns a Promise that resolves when
|
|
252
|
+
* the driver decides to continue (resume) or step.
|
|
253
|
+
*
|
|
254
|
+
* The driver controls step-vs-resume by mutating `next` via setNextStep before
|
|
255
|
+
* resolving its Promise.
|
|
256
|
+
*/
|
|
257
|
+
function setPauseHandler(handler) {
|
|
258
|
+
externalHandler = handler
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Trigger a one-shot pause from outside the test (e.g. the MCP server,
|
|
263
|
+
* pausing the test at a specific step index without modifying the test).
|
|
264
|
+
* Schedules pauseSession through the recorder so it slots between steps.
|
|
265
|
+
*/
|
|
266
|
+
function pauseNow(passedObject = {}) {
|
|
267
|
+
if (store.dryRun) return
|
|
268
|
+
recorder.add('Triggered pause', () => pauseSession(passedObject))
|
|
269
|
+
}
|
|
270
|
+
|
|
237
271
|
export default pause
|
|
238
|
-
export { registerVariable }
|
|
272
|
+
export { registerVariable, setPauseHandler, pauseNow }
|
package/lib/plugin/aiTrace.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import crypto from 'crypto'
|
|
2
1
|
import fs from 'fs'
|
|
3
2
|
import { mkdirp } from 'mkdirp'
|
|
4
3
|
import path from 'path'
|
|
5
|
-
import { fileURLToPath } from 'url'
|
|
6
4
|
|
|
7
5
|
import store from '../store.js'
|
|
8
6
|
import Container from '../container.js'
|
|
@@ -10,11 +8,16 @@ import recorder from '../recorder.js'
|
|
|
10
8
|
import event from '../event.js'
|
|
11
9
|
import output from '../output.js'
|
|
12
10
|
import { deleteDir, clearString } from '../utils.js'
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import { captureSnapshot, pickActingHelper, traceDirFor, artifactLinks } from '../utils/trace.js'
|
|
12
|
+
import {
|
|
13
|
+
parsePluginArgs,
|
|
14
|
+
resolveTrigger,
|
|
15
|
+
matchStepFile,
|
|
16
|
+
matchUrl,
|
|
17
|
+
} from '../utils/pluginParser.js'
|
|
16
18
|
|
|
17
19
|
const defaultConfig = {
|
|
20
|
+
on: 'step',
|
|
18
21
|
deleteSuccessful: false,
|
|
19
22
|
fullPageScreenshots: false,
|
|
20
23
|
output: store.outputDir,
|
|
@@ -53,20 +56,26 @@ const defaultConfig = {
|
|
|
53
56
|
* * `captureHTTP`: capture HTTP requests (requires `trace` or `recordHar` enabled in helper config). Default: true.
|
|
54
57
|
* * `captureDebugOutput`: capture CodeceptJS debug output. Default: true.
|
|
55
58
|
* * `ignoreSteps`: steps to ignore in trace. Array of RegExps is expected.
|
|
59
|
+
* * `on`: trigger mode — `step` (default), `fail`, `test`, `file`, `url`.
|
|
60
|
+
*
|
|
61
|
+
* #### `on=` modes
|
|
62
|
+
*
|
|
63
|
+
* * **step** — persist every step (default)
|
|
64
|
+
* * **fail** — persist only the failed step
|
|
65
|
+
* * **test** — persist only the last step of each test
|
|
66
|
+
* * **file** — persist steps from `path=...[;line=...]`
|
|
67
|
+
* * **url** — persist when the current URL matches `pattern=...`
|
|
56
68
|
*
|
|
57
69
|
* @param {*} config
|
|
58
70
|
*/
|
|
59
|
-
export default function (config) {
|
|
60
|
-
const
|
|
61
|
-
|
|
71
|
+
export default function (config = {}) {
|
|
72
|
+
const cliArgs = parsePluginArgs(config._args)
|
|
73
|
+
const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, { name: 'aiTrace' })
|
|
74
|
+
if (!trigger) return
|
|
62
75
|
|
|
63
76
|
config = Object.assign(defaultConfig, config)
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
if (Object.keys(helpers).indexOf(helperName) > -1) {
|
|
67
|
-
helper = helpers[helperName]
|
|
68
|
-
}
|
|
69
|
-
}
|
|
78
|
+
const helper = pickActingHelper(Container.helpers())
|
|
70
79
|
|
|
71
80
|
if (!helper) {
|
|
72
81
|
output.warn('aiTrace plugin: No supported helper found (Playwright, Puppeteer, WebDriver). Plugin disabled.')
|
|
@@ -106,13 +115,7 @@ export default function (config) {
|
|
|
106
115
|
} catch (err) {
|
|
107
116
|
title = test.title
|
|
108
117
|
}
|
|
109
|
-
|
|
110
|
-
const uniqueHash = crypto
|
|
111
|
-
.createHash('sha256')
|
|
112
|
-
.update(test.file + test.title)
|
|
113
|
-
.digest('hex')
|
|
114
|
-
.slice(0, 8)
|
|
115
|
-
dir = path.join(reportDir, `trace_${testTitle}_${uniqueHash}`)
|
|
118
|
+
dir = traceDirFor(test.file, title, reportDir)
|
|
116
119
|
mkdirp.sync(dir)
|
|
117
120
|
deleteDir(dir)
|
|
118
121
|
mkdirp.sync(dir)
|
|
@@ -141,6 +144,24 @@ export default function (config) {
|
|
|
141
144
|
output.debug(`aiTrace: Skipping failed step "${step.toString()}" - already handled by step.failed event`)
|
|
142
145
|
return
|
|
143
146
|
}
|
|
147
|
+
|
|
148
|
+
// on= filtering
|
|
149
|
+
if (trigger.on === 'fail') return // failed steps handled by step.failed
|
|
150
|
+
if (trigger.on === 'file' && !matchStepFile(step, trigger.path, trigger.line)) return
|
|
151
|
+
if (trigger.on === 'url') {
|
|
152
|
+
recorder.add('aiTrace:url check', async () => {
|
|
153
|
+
try {
|
|
154
|
+
if (!helper.grabCurrentUrl) return
|
|
155
|
+
const url = await helper.grabCurrentUrl()
|
|
156
|
+
if (!matchUrl(url, trigger.pattern)) return
|
|
157
|
+
await persistStep(step)
|
|
158
|
+
} catch (err) {
|
|
159
|
+
output.debug(`aiTrace: Error in url-mode step persistence: ${err.message}`)
|
|
160
|
+
}
|
|
161
|
+
}, true)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
const stepPersistPromise = persistStep(step).catch(err => {
|
|
145
166
|
output.debug(`aiTrace: Error saving step: ${err.message}`)
|
|
146
167
|
})
|
|
@@ -282,6 +303,7 @@ export default function (config) {
|
|
|
282
303
|
output.debug(`aiTrace: Browser unavailable, partial artifact capture: ${err.message}`)
|
|
283
304
|
}
|
|
284
305
|
|
|
306
|
+
let preExistingScreenshot = false
|
|
285
307
|
if (step.artifacts?.screenshot) {
|
|
286
308
|
const screenshotPath = path.isAbsolute(step.artifacts.screenshot)
|
|
287
309
|
? step.artifacts.screenshot
|
|
@@ -289,6 +311,7 @@ export default function (config) {
|
|
|
289
311
|
const screenshotFile = path.basename(screenshotPath)
|
|
290
312
|
stepData.artifacts.screenshot = screenshotFile
|
|
291
313
|
step.artifacts.screenshot = screenshotPath
|
|
314
|
+
preExistingScreenshot = true
|
|
292
315
|
|
|
293
316
|
if (!fs.existsSync(screenshotPath)) {
|
|
294
317
|
try {
|
|
@@ -297,58 +320,31 @@ export default function (config) {
|
|
|
297
320
|
output.debug(`aiTrace: Could not save screenshot: ${err.message}`)
|
|
298
321
|
}
|
|
299
322
|
}
|
|
300
|
-
} else {
|
|
301
|
-
try {
|
|
302
|
-
const screenshotFile = `${stepPrefix}_screenshot.png`
|
|
303
|
-
const screenshotPath = path.join(dir, screenshotFile)
|
|
304
|
-
await helper.saveScreenshot(screenshotPath, config.fullPageScreenshots)
|
|
305
|
-
|
|
306
|
-
stepData.artifacts.screenshot = screenshotFile
|
|
307
|
-
step.artifacts.screenshot = screenshotPath
|
|
308
|
-
} catch (err) {
|
|
309
|
-
output.debug(`aiTrace: Could not save screenshot: ${err.message}`)
|
|
310
|
-
}
|
|
311
323
|
}
|
|
312
324
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
output.debug(`aiTrace: Could not capture HTML: ${err.message}`)
|
|
323
|
-
}
|
|
324
|
-
} else {
|
|
325
|
-
stepData.artifacts.html = step.artifacts.html
|
|
326
|
-
}
|
|
327
|
-
}
|
|
325
|
+
const captured = await captureSnapshot(helper, {
|
|
326
|
+
dir,
|
|
327
|
+
prefix: stepPrefix,
|
|
328
|
+
fullPage: config.fullPageScreenshots,
|
|
329
|
+
captureHTML: config.captureHTML && browserAvailable,
|
|
330
|
+
captureARIA: config.captureARIA && browserAvailable,
|
|
331
|
+
captureBrowserLogs: config.captureBrowserLogs && browserAvailable,
|
|
332
|
+
captureStorage: false,
|
|
333
|
+
})
|
|
328
334
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const aria = await helper.grabAriaSnapshot()
|
|
333
|
-
const ariaFile = `${stepPrefix}_aria.txt`
|
|
334
|
-
fs.writeFileSync(path.join(dir, ariaFile), aria)
|
|
335
|
-
stepData.artifacts.aria = ariaFile
|
|
336
|
-
} catch (err) {
|
|
337
|
-
output.debug(`aiTrace: Could not capture ARIA snapshot: ${err.message}`)
|
|
338
|
-
}
|
|
335
|
+
if (!preExistingScreenshot && captured.screenshot) {
|
|
336
|
+
stepData.artifacts.screenshot = captured.screenshot
|
|
337
|
+
step.artifacts.screenshot = path.join(dir, captured.screenshot)
|
|
339
338
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
} catch (err) {
|
|
350
|
-
output.debug(`aiTrace: Could not capture browser logs: ${err.message}`)
|
|
351
|
-
}
|
|
339
|
+
if (step.artifacts?.html) {
|
|
340
|
+
stepData.artifacts.html = step.artifacts.html
|
|
341
|
+
} else if (captured.html) {
|
|
342
|
+
stepData.artifacts.html = captured.html
|
|
343
|
+
}
|
|
344
|
+
if (captured.aria) stepData.artifacts.aria = captured.aria
|
|
345
|
+
if (captured.console) {
|
|
346
|
+
stepData.artifacts.console = captured.console
|
|
347
|
+
stepData.meta.consoleCount = captured.consoleCount
|
|
352
348
|
}
|
|
353
349
|
} catch (err) {
|
|
354
350
|
output.plugin(`aiTrace: Can't save step artifacts: ${err}`)
|
|
@@ -361,6 +357,12 @@ export default function (config) {
|
|
|
361
357
|
return
|
|
362
358
|
}
|
|
363
359
|
|
|
360
|
+
// on=test: only render the last step in markdown; artifacts of earlier steps
|
|
361
|
+
// remain on disk unreferenced.
|
|
362
|
+
if (trigger.on === 'test') {
|
|
363
|
+
steps = steps.slice(-1)
|
|
364
|
+
}
|
|
365
|
+
|
|
364
366
|
const testDuration = ((Date.now() - testStartTime) / 1000).toFixed(2)
|
|
365
367
|
|
|
366
368
|
let markdown = `file: ${test.file || 'unknown'}\n`
|
|
@@ -405,22 +407,8 @@ export default function (config) {
|
|
|
405
407
|
})
|
|
406
408
|
}
|
|
407
409
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (stepData.artifacts.aria) {
|
|
413
|
-
markdown += ` > [ARIA Snapshot](./${stepData.artifacts.aria})\n`
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (stepData.artifacts.screenshot) {
|
|
417
|
-
markdown += ` > [Screenshot](./${stepData.artifacts.screenshot})\n`
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (stepData.artifacts.console) {
|
|
421
|
-
const count = stepData.meta.consoleCount || 0
|
|
422
|
-
markdown += ` > [Browser Logs](./${stepData.artifacts.console}) (${count} entries)\n`
|
|
423
|
-
}
|
|
410
|
+
const links = artifactLinks(stepData.artifacts, { consoleCount: stepData.meta.consoleCount })
|
|
411
|
+
if (links) markdown += links + '\n'
|
|
424
412
|
|
|
425
413
|
if (config.captureHTTP) {
|
|
426
414
|
if (test.artifacts && test.artifacts.har) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import output from '../output.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Overrides browser helper config from the command line. Works for all browser helpers
|
|
5
|
+
* (Playwright, Puppeteer, WebDriver, Appium) without touching `codecept.conf`.
|
|
6
|
+
*
|
|
7
|
+
* Enable it via `-p` option with one or more colon-chained args:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* npx codeceptjs run -p browser:show
|
|
11
|
+
* npx codeceptjs run -p browser:hide
|
|
12
|
+
* npx codeceptjs run -p browser:browser=firefox
|
|
13
|
+
* npx codeceptjs run -p browser:windowSize=1024x768:video=false
|
|
14
|
+
* npx codeceptjs run -p browser:hide:browser=webkit:windowSize=800x600
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* #### Args
|
|
18
|
+
*
|
|
19
|
+
* * **show** — force visible browser
|
|
20
|
+
* * **hide** — force headless (also injects `--headless` into WebDriver chrome/firefox capability args)
|
|
21
|
+
* * **`<key>=<value>`** — set `helpers.<eachBrowserHelper>.<key> = <value>`. Three keys
|
|
22
|
+
* get per-helper translation via `setBrowserConfig`:
|
|
23
|
+
* * `browser=<name>` — Puppeteer receives `product`, Playwright receives `browser`
|
|
24
|
+
* * `windowSize=WxH` — also adds `--window-size=W,H` chromium/chrome args
|
|
25
|
+
* * `show=true|false` — toggles `show` on Playwright/Puppeteer; injects/strips
|
|
26
|
+
* `--headless` in WebDriver chrome/firefox capability args
|
|
27
|
+
*
|
|
28
|
+
* Values stay as strings. `true` / `false` are coerced to booleans.
|
|
29
|
+
*
|
|
30
|
+
* Requires `@codeceptjs/configure` to be installed; if missing, the plugin
|
|
31
|
+
* logs a hint and skips the override.
|
|
32
|
+
*/
|
|
33
|
+
export default async function (config = {}) {
|
|
34
|
+
const opts = parseArgs(config._args || [])
|
|
35
|
+
if (Object.keys(opts).length === 0) return
|
|
36
|
+
|
|
37
|
+
const configure = await tryImportConfigure()
|
|
38
|
+
if (!configure) return
|
|
39
|
+
|
|
40
|
+
configure.setBrowserConfig(opts)
|
|
41
|
+
output.debug(`browser plugin: applied ${formatOpts(opts)}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function tryImportConfigure() {
|
|
45
|
+
try {
|
|
46
|
+
return await import('@codeceptjs/configure')
|
|
47
|
+
} catch (err) {
|
|
48
|
+
output.error("browser plugin: '@codeceptjs/configure' is not installed; CLI overrides are skipped. Run `npm i @codeceptjs/configure` to enable.")
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseArgs(args) {
|
|
54
|
+
return args.filter(Boolean).reduce((acc, arg) => Object.assign(acc, parseArg(arg)), {})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseArg(arg) {
|
|
58
|
+
if (arg === 'show') return { show: true }
|
|
59
|
+
if (arg === 'hide') return { show: false }
|
|
60
|
+
if (arg.includes('=')) {
|
|
61
|
+
const [key, ...rest] = arg.split('=')
|
|
62
|
+
return { [key]: parseValue(rest.join('=')) }
|
|
63
|
+
}
|
|
64
|
+
output.error(`browser plugin: unknown arg "${arg}"`)
|
|
65
|
+
return {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseValue(v) {
|
|
69
|
+
if (v === 'true') return true
|
|
70
|
+
if (v === 'false') return false
|
|
71
|
+
return v
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatOpts(opts) {
|
|
75
|
+
return Object.entries(opts).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
76
|
+
}
|