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.
@@ -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, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
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
- let _locator = `_react=${reactLocator.react}`;
5
- let props = '';
8
+ const page = typeof matcher.page === 'function' ? matcher.page() : matcher
6
9
 
7
- if (reactLocator.props) {
8
- props += propBuilder(reactLocator.props);
9
- _locator += props;
10
+ if (!resqScript) {
11
+ resqScript = fs.readFileSync(fileURLToPath(import.meta.resolve('resq'))).toString()
10
12
  }
11
- return matcher.locator(_locator).all();
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
- async function findVue(matcher, locator) {
15
- // Handle both Locator objects and raw locator objects
16
- const vueLocator = locator.locator || locator
17
- let _locator = `_vue=${vueLocator.vue}`;
18
- let props = '';
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
- if (vueLocator.props) {
21
- props += propBuilder(vueLocator.props);
22
- _locator += props;
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 matcher.locator(_locator).all();
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, findVue, findByPlaywrightLocator };
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
- const { name, value } = attr
107
- if (name === 'class') {
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
- export { scanForErrorMessages, removeNonInteractiveElements, splitByChunks, minifyHtml, simplifyHtmlElement }
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 => `./self::*[contains(normalize-space(string(.)), ${literal}) or contains(normalize-space(@value), ${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 }
@@ -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 colors from 'chalk'
14
-
15
- const supportedHelpers = Container.STANDARD_ACTING_HELPERS
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 helpers = Container.helpers()
61
- let helper
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
- for (const helperName of supportedHelpers) {
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
- const testTitle = clearString(title).slice(0, 200)
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
- // Save HTML
314
- if (config.captureHTML && helper.grabSource && browserAvailable) {
315
- if (!step.artifacts?.html) {
316
- try {
317
- const html = await helper.grabSource()
318
- const htmlFile = `${stepPrefix}_page.html`
319
- fs.writeFileSync(path.join(dir, htmlFile), html)
320
- stepData.artifacts.html = htmlFile
321
- } catch (err) {
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
- // Save ARIA snapshot
330
- if (config.captureARIA && helper.grabAriaSnapshot && browserAvailable) {
331
- try {
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
- // Save browser logs
342
- if (config.captureBrowserLogs && helper.grabBrowserLogs && browserAvailable) {
343
- try {
344
- const logs = await helper.grabBrowserLogs()
345
- const logsFile = `${stepPrefix}_console.json`
346
- fs.writeFileSync(path.join(dir, logsFile), JSON.stringify(logs || [], null, 2))
347
- stepData.artifacts.console = logsFile
348
- stepData.meta.consoleCount = logs ? logs.length : 0
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
- if (stepData.artifacts.html) {
409
- markdown += ` > [HTML](./${stepData.artifacts.html})\n`
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
+ }