codeceptjs 4.0.0-rc.1 → 4.0.0-rc.11

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.
Files changed (49) hide show
  1. package/README.md +39 -27
  2. package/bin/mcp-server.js +637 -0
  3. package/docs/webapi/appendField.mustache +5 -0
  4. package/docs/webapi/attachFile.mustache +12 -0
  5. package/docs/webapi/checkOption.mustache +1 -1
  6. package/docs/webapi/clearField.mustache +5 -0
  7. package/docs/webapi/dontSeeCurrentPathEquals.mustache +10 -0
  8. package/docs/webapi/dontSeeElement.mustache +4 -0
  9. package/docs/webapi/dontSeeInField.mustache +5 -0
  10. package/docs/webapi/fillField.mustache +5 -0
  11. package/docs/webapi/moveCursorTo.mustache +5 -1
  12. package/docs/webapi/seeCurrentPathEquals.mustache +10 -0
  13. package/docs/webapi/seeElement.mustache +4 -0
  14. package/docs/webapi/seeInField.mustache +5 -0
  15. package/docs/webapi/selectOption.mustache +5 -0
  16. package/docs/webapi/uncheckOption.mustache +1 -1
  17. package/lib/codecept.js +20 -17
  18. package/lib/command/init.js +0 -3
  19. package/lib/command/run-workers.js +1 -0
  20. package/lib/container.js +19 -4
  21. package/lib/element/WebElement.js +81 -2
  22. package/lib/els.js +12 -6
  23. package/lib/helper/Appium.js +8 -8
  24. package/lib/helper/Playwright.js +224 -138
  25. package/lib/helper/Puppeteer.js +211 -69
  26. package/lib/helper/WebDriver.js +183 -64
  27. package/lib/helper/errors/MultipleElementsFound.js +27 -110
  28. package/lib/helper/errors/NonFocusedType.js +8 -0
  29. package/lib/helper/extras/elementSelection.js +58 -0
  30. package/lib/helper/extras/focusCheck.js +43 -0
  31. package/lib/helper/scripts/dropFile.js +11 -0
  32. package/lib/html.js +14 -1
  33. package/lib/listener/globalRetry.js +32 -6
  34. package/lib/mocha/cli.js +10 -0
  35. package/lib/plugin/aiTrace.js +464 -0
  36. package/lib/plugin/retryFailedStep.js +28 -19
  37. package/lib/plugin/stepByStepReport.js +5 -1
  38. package/lib/step/config.js +15 -2
  39. package/lib/step/record.js +1 -1
  40. package/lib/utils.js +48 -0
  41. package/lib/workers.js +49 -7
  42. package/package.json +5 -3
  43. package/typings/index.d.ts +19 -0
  44. package/lib/listener/enhancedGlobalRetry.js +0 -110
  45. package/lib/plugin/enhancedRetryFailedStep.js +0 -99
  46. package/lib/plugin/htmlReporter.js +0 -3648
  47. package/lib/retryCoordinator.js +0 -207
  48. package/typings/promiseBasedTypes.d.ts +0 -9469
  49. package/typings/types.d.ts +0 -11402
@@ -0,0 +1,58 @@
1
+ import store from '../../store.js'
2
+ import output from '../../output.js'
3
+ import WebElement from '../../element/WebElement.js'
4
+ import MultipleElementsFound from '../errors/MultipleElementsFound.js'
5
+
6
+ function resolveElementIndex(value) {
7
+ if (value === 'first') return 1
8
+ if (value === 'last') return -1
9
+ return value
10
+ }
11
+
12
+ function isStrictStep(opts, helper) {
13
+ if (opts?.exact === true || opts?.strictMode === true) return true
14
+ if (opts?.exact === false || opts?.strictMode === false) return false
15
+ return helper.options.strict
16
+ }
17
+
18
+ function selectElement(els, locator, helper) {
19
+ const opts = store.currentStep?.opts
20
+ const rawIndex = opts?.elementIndex
21
+ const elementIndex = resolveElementIndex(rawIndex)
22
+
23
+ if (elementIndex != null) {
24
+ if (els.length === 1) return els[0]
25
+
26
+ if (!Number.isInteger(elementIndex) || elementIndex === 0) {
27
+ throw new Error(`elementIndex must be a non-zero integer or 'first'/'last', got: ${rawIndex}`)
28
+ }
29
+
30
+ let idx
31
+ if (elementIndex > 0) {
32
+ idx = elementIndex - 1
33
+ if (idx >= els.length) {
34
+ throw new Error(`elementIndex ${elementIndex} exceeds the number of elements found (${els.length}) for "${locator}"`)
35
+ }
36
+ } else {
37
+ idx = els.length + elementIndex
38
+ if (idx < 0) {
39
+ throw new Error(`elementIndex ${elementIndex} exceeds the number of elements found (${els.length}) for "${locator}"`)
40
+ }
41
+ }
42
+
43
+ output.debug(`[Elements] Using element #${elementIndex} out of ${els.length}`)
44
+ return els[idx]
45
+ }
46
+
47
+ if (isStrictStep(opts, helper)) {
48
+ if (els.length > 1) {
49
+ const webElements = Array.from(els).map(el => new WebElement(el, helper))
50
+ throw new MultipleElementsFound(locator, webElements)
51
+ }
52
+ }
53
+
54
+ if (els.length > 1) output.debug(`[Elements] Using first element out of ${els.length}`)
55
+ return els[0]
56
+ }
57
+
58
+ export { selectElement }
@@ -0,0 +1,43 @@
1
+ import store from '../../store.js'
2
+ import NonFocusedType from '../errors/NonFocusedType.js'
3
+
4
+ const MODIFIER_PATTERN = /^(control|ctrl|meta|cmd|command|commandorcontrol|ctrlorcommand)/i
5
+ const EDITING_KEYS = new Set(['a', 'c', 'x', 'v', 'z', 'y'])
6
+
7
+ async function isNoElementFocused(helper) {
8
+ return helper.executeScript(() => {
9
+ const ae = document.activeElement
10
+ return !ae || ae === document.documentElement || (ae === document.body && !ae.isContentEditable)
11
+ })
12
+ }
13
+
14
+ export async function checkFocusBeforeType(helper) {
15
+ if (!helper.options.strict && !store.debugMode) return
16
+ if (!await isNoElementFocused(helper)) return
17
+
18
+ const message = 'No element is in focus. Use I.click() or I.focus() to activate an element before typing.'
19
+ if (helper.options.strict) throw new NonFocusedType(message)
20
+ helper.debugSection('Warning', message)
21
+ }
22
+
23
+ export async function checkFocusBeforePressKey(helper, originalKey) {
24
+ if (!helper.options.strict && !store.debugMode) return
25
+ if (!Array.isArray(originalKey)) return
26
+
27
+ let hasCtrlOrMeta = false
28
+ let actionKey = null
29
+ for (const k of originalKey) {
30
+ if (MODIFIER_PATTERN.test(k)) {
31
+ hasCtrlOrMeta = true
32
+ } else {
33
+ actionKey = k
34
+ }
35
+ }
36
+ if (!hasCtrlOrMeta || !actionKey || !EDITING_KEYS.has(actionKey.toLowerCase())) return
37
+
38
+ if (!await isNoElementFocused(helper)) return
39
+
40
+ const message = `No element is in focus. Key combination with "${originalKey.join('+')}" may not work as expected. Use I.click() or I.focus() first.`
41
+ if (helper.options.strict) throw new NonFocusedType(message)
42
+ helper.debugSection('Warning', message)
43
+ }
@@ -0,0 +1,11 @@
1
+ export const dropFile = (el, { base64Content, fileName, mimeType }) => {
2
+ const binaryStr = atob(base64Content)
3
+ const bytes = new Uint8Array(binaryStr.length)
4
+ for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
5
+ const fileObj = new File([bytes], fileName, { type: mimeType })
6
+ const dataTransfer = new DataTransfer()
7
+ dataTransfer.items.add(fileObj)
8
+ el.dispatchEvent(new DragEvent('dragenter', { dataTransfer, bubbles: true }))
9
+ el.dispatchEvent(new DragEvent('dragover', { dataTransfer, bubbles: true }))
10
+ el.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true }))
11
+ }
package/lib/html.js CHANGED
@@ -245,4 +245,17 @@ function splitByChunks(text, chunkSize) {
245
245
  return chunks.map(chunk => chunk.trim())
246
246
  }
247
247
 
248
- export { scanForErrorMessages, removeNonInteractiveElements, splitByChunks, minifyHtml }
248
+ function simplifyHtmlElement(html, maxLength = 300) {
249
+ try {
250
+ html = removeNonInteractiveElements(html)
251
+ html = html.replace(/<html>(?:<head>.*?<\/head>)?<body>(.*)<\/body><\/html>/s, '$1').trim()
252
+ } catch (e) {
253
+ // keep raw html if minification fails
254
+ }
255
+ if (html.length > maxLength) {
256
+ html = html.slice(0, maxLength) + '...'
257
+ }
258
+ return html
259
+ }
260
+
261
+ export { scanForErrorMessages, removeNonInteractiveElements, splitByChunks, minifyHtml, simplifyHtmlElement }
@@ -5,16 +5,27 @@ import { isNotSet } from '../utils.js'
5
5
 
6
6
  const hooks = ['Before', 'After', 'BeforeSuite', 'AfterSuite']
7
7
 
8
+ const RETRY_PRIORITIES = {
9
+ MANUAL_STEP: 100,
10
+ STEP_PLUGIN: 50,
11
+ SCENARIO_CONFIG: 30,
12
+ FEATURE_CONFIG: 20,
13
+ HOOK_CONFIG: 10,
14
+ }
15
+
8
16
  export default function () {
9
17
  event.dispatcher.on(event.suite.before, suite => {
10
18
  let retryConfig = Config.get('retry')
11
19
  if (!retryConfig) return
12
20
 
13
21
  if (Number.isInteger(+retryConfig)) {
14
- // is number
15
22
  const retryNum = +retryConfig
16
23
  output.log(`Retries: ${retryNum}`)
17
- suite.retries(retryNum)
24
+
25
+ if (suite.retries() === -1 || (suite.opts.retryPriority || 0) <= RETRY_PRIORITIES.FEATURE_CONFIG) {
26
+ suite.retries(retryNum)
27
+ suite.opts.retryPriority = RETRY_PRIORITIES.FEATURE_CONFIG
28
+ }
18
29
  return
19
30
  }
20
31
 
@@ -30,11 +41,18 @@ export default function () {
30
41
  hooks
31
42
  .filter(hook => !!config[hook])
32
43
  .forEach(hook => {
33
- if (isNotSet(suite.opts[`retry${hook}`])) suite.opts[`retry${hook}`] = config[hook]
44
+ const retryKey = `retry${hook}`
45
+ if (isNotSet(suite.opts[retryKey])) {
46
+ suite.opts[retryKey] = config[hook]
47
+ suite.opts[`${retryKey}Priority`] = RETRY_PRIORITIES.HOOK_CONFIG
48
+ }
34
49
  })
35
50
 
36
51
  if (config.Feature) {
37
- if (isNotSet(suite.retries())) suite.retries(config.Feature)
52
+ if (suite.retries() === -1 || (suite.opts.retryPriority || 0) <= RETRY_PRIORITIES.FEATURE_CONFIG) {
53
+ suite.retries(config.Feature)
54
+ suite.opts.retryPriority = RETRY_PRIORITIES.FEATURE_CONFIG
55
+ }
38
56
  }
39
57
 
40
58
  output.log(`Retries: ${JSON.stringify(config)}`)
@@ -46,7 +64,10 @@ export default function () {
46
64
  if (!retryConfig) return
47
65
 
48
66
  if (Number.isInteger(+retryConfig)) {
49
- if (test.retries() === -1) test.retries(retryConfig)
67
+ if (test.retries() === -1) {
68
+ test.retries(retryConfig)
69
+ test.opts.retryPriority = RETRY_PRIORITIES.SCENARIO_CONFIG
70
+ }
50
71
  return
51
72
  }
52
73
 
@@ -62,9 +83,14 @@ export default function () {
62
83
  }
63
84
 
64
85
  if (config.Scenario) {
65
- if (test.retries() === -1) test.retries(config.Scenario)
86
+ if (test.retries() === -1 || (test.opts.retryPriority || 0) <= RETRY_PRIORITIES.SCENARIO_CONFIG) {
87
+ test.retries(config.Scenario)
88
+ test.opts.retryPriority = RETRY_PRIORITIES.SCENARIO_CONFIG
89
+ }
66
90
  output.log(`Retries: ${config.Scenario}`)
67
91
  }
68
92
  }
69
93
  })
70
94
  }
95
+
96
+ export { RETRY_PRIORITIES }
package/lib/mocha/cli.js CHANGED
@@ -202,6 +202,16 @@ class Cli extends Base {
202
202
 
203
203
  // failures
204
204
  if (stats.failures) {
205
+ for (const test of this.failures) {
206
+ if (test.err && typeof test.err.fetchDetails === 'function') {
207
+ try {
208
+ await test.err.fetchDetails()
209
+ } catch (e) {
210
+ // ignore fetch errors
211
+ }
212
+ }
213
+ }
214
+
205
215
  // append step traces
206
216
  this.failures = this.failures.map(test => {
207
217
  // we will change the stack trace, so we need to clone the test
@@ -0,0 +1,464 @@
1
+ import crypto from 'crypto'
2
+ import fs from 'fs'
3
+ import { mkdirp } from 'mkdirp'
4
+ import path from 'path'
5
+ import { fileURLToPath } from 'url'
6
+
7
+ import Container from '../container.js'
8
+ import recorder from '../recorder.js'
9
+ import event from '../event.js'
10
+ import output from '../output.js'
11
+ import { deleteDir, clearString } from '../utils.js'
12
+ import colors from 'chalk'
13
+
14
+ const supportedHelpers = Container.STANDARD_ACTING_HELPERS
15
+
16
+ const defaultConfig = {
17
+ deleteSuccessful: false,
18
+ fullPageScreenshots: false,
19
+ output: global.output_dir,
20
+ captureHTML: true,
21
+ captureARIA: true,
22
+ captureBrowserLogs: true,
23
+ captureHTTP: true,
24
+ captureDebugOutput: true,
25
+ ignoreSteps: [],
26
+ }
27
+
28
+ /**
29
+ *
30
+ * Generates AI-friendly trace files for debugging with AI agents.
31
+ * This plugin creates a markdown file with test execution logs and links to all artifacts
32
+ * (screenshots, HTML, ARIA snapshots, browser logs, HTTP requests) for each step.
33
+ *
34
+ * #### Configuration
35
+ *
36
+ * ```js
37
+ * "plugins": {
38
+ * "aiTrace": {
39
+ * "enabled": true
40
+ * }
41
+ * }
42
+ * ```
43
+ *
44
+ * Possible config options:
45
+ *
46
+ * * `deleteSuccessful`: delete traces for successfully executed tests. Default: false.
47
+ * * `fullPageScreenshots`: should full page screenshots be used. Default: false.
48
+ * * `output`: a directory where traces should be stored. Default: `output`.
49
+ * * `captureHTML`: capture HTML for each step. Default: true.
50
+ * * `captureARIA`: capture ARIA snapshot for each step. Default: true.
51
+ * * `captureBrowserLogs`: capture browser console logs. Default: true.
52
+ * * `captureHTTP`: capture HTTP requests (requires `trace` or `recordHar` enabled in helper config). Default: true.
53
+ * * `captureDebugOutput`: capture CodeceptJS debug output. Default: true.
54
+ * * `ignoreSteps`: steps to ignore in trace. Array of RegExps is expected.
55
+ *
56
+ * @param {*} config
57
+ */
58
+ export default function (config) {
59
+ const helpers = Container.helpers()
60
+ let helper
61
+
62
+ config = Object.assign(defaultConfig, config)
63
+
64
+ for (const helperName of supportedHelpers) {
65
+ if (Object.keys(helpers).indexOf(helperName) > -1) {
66
+ helper = helpers[helperName]
67
+ }
68
+ }
69
+
70
+ if (!helper) {
71
+ output.warn('aiTrace plugin: No supported helper found (Playwright, Puppeteer, WebDriver). Plugin disabled.')
72
+ return
73
+ }
74
+
75
+ let dir
76
+ let stepNum
77
+ let steps = []
78
+ let debugOutput = []
79
+ let error
80
+ let savedSteps = new Set()
81
+ let currentTest = null
82
+ let testStartTime
83
+ let currentUrl = null
84
+ let testFailed = false
85
+ let firstFailedStepSaved = false
86
+
87
+ const reportDir = config.output ? path.resolve(global.codecept_dir, config.output) : defaultConfig.output
88
+
89
+ if (config.captureDebugOutput) {
90
+ const originalDebug = output.debug
91
+ output.debug = function (...args) {
92
+ debugOutput.push(args.join(' '))
93
+ originalDebug.apply(output, args)
94
+ }
95
+ }
96
+
97
+ event.dispatcher.on(event.suite.before, suite => {
98
+ stepNum = -1
99
+ })
100
+
101
+ event.dispatcher.on(event.test.before, test => {
102
+ let title
103
+ try {
104
+ title = test.fullTitle ? test.fullTitle() : test.title
105
+ } catch (err) {
106
+ title = test.title
107
+ }
108
+ const testTitle = clearString(title).slice(0, 200)
109
+ const uniqueHash = crypto
110
+ .createHash('sha256')
111
+ .update(test.file + test.title)
112
+ .digest('hex')
113
+ .slice(0, 8)
114
+ dir = path.join(reportDir, `trace_${testTitle}_${uniqueHash}`)
115
+ mkdirp.sync(dir)
116
+ deleteDir(dir)
117
+ mkdirp.sync(dir)
118
+ stepNum = 0
119
+ error = null
120
+ steps = []
121
+ debugOutput = []
122
+ savedSteps.clear()
123
+ currentTest = test
124
+ testStartTime = Date.now()
125
+ currentUrl = null
126
+ testFailed = false
127
+ firstFailedStepSaved = false
128
+ })
129
+
130
+ event.dispatcher.on(event.step.after, step => {
131
+ if (!currentTest) return
132
+ if (step.status === 'failed') {
133
+ testFailed = true
134
+ }
135
+ if (step.status === 'queued' && testFailed) {
136
+ output.debug(`aiTrace: Skipping queued step "${step.toString()}" - testFailed: ${testFailed}`)
137
+ return
138
+ }
139
+ if (step.status === 'failed' && firstFailedStepSaved) {
140
+ output.debug(`aiTrace: Skipping failed step "${step.toString()}" - already handled by step.failed event`)
141
+ return
142
+ }
143
+ const stepPersistPromise = persistStep(step).catch(err => {
144
+ output.debug(`aiTrace: Error saving step: ${err.message}`)
145
+ })
146
+ recorder.add(`wait aiTrace step persistence: ${step.toString()}`, () => stepPersistPromise, true)
147
+ })
148
+
149
+ event.dispatcher.on(event.step.failed, async step => {
150
+ if (!currentTest) return
151
+ if (step.status === 'queued' && testFailed) {
152
+ output.debug(`aiTrace: Skipping queued failed step "${step.toString()}" - testFailed: ${testFailed}`)
153
+ return
154
+ }
155
+ if (firstFailedStepSaved) {
156
+ output.debug(`aiTrace: Skipping subsequent failed step "${step.toString()}" - already saved first failed step`)
157
+ return
158
+ }
159
+
160
+ const stepKey = step.toString()
161
+ if (savedSteps.has(stepKey)) {
162
+ const existingStep = steps.find(s => s.step === stepKey)
163
+ if (!existingStep) {
164
+ output.debug(`aiTrace: Step "${stepKey}" marked as saved but not found in steps array`)
165
+ return
166
+ }
167
+ existingStep.status = 'failed'
168
+
169
+ try {
170
+ await captureArtifactsForStep(step, existingStep, existingStep.prefix)
171
+ } catch (err) {
172
+ output.debug(`aiTrace: Error updating failed step: ${err.message}`)
173
+ }
174
+ } else {
175
+ if (stepNum === -1) return
176
+ if (isStepIgnored(step)) return
177
+ if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
178
+
179
+ const stepPrefix = generateStepPrefix(step, stepNum)
180
+ stepNum++
181
+
182
+ const stepData = {
183
+ step: stepKey,
184
+ status: 'failed',
185
+ prefix: stepPrefix,
186
+ artifacts: {},
187
+ meta: {},
188
+ debugOutput: [],
189
+ }
190
+
191
+ if (step.startTime && step.endTime) {
192
+ stepData.meta.duration = ((step.endTime - step.startTime) / 1000).toFixed(2) + 's'
193
+ }
194
+
195
+ savedSteps.add(stepKey)
196
+ steps.push(stepData)
197
+ firstFailedStepSaved = true
198
+
199
+ try {
200
+ await captureArtifactsForStep(step, stepData, stepPrefix)
201
+ } catch (err) {
202
+ output.debug(`aiTrace: Error capturing failed step artifacts: ${err.message}`)
203
+ }
204
+ }
205
+ })
206
+
207
+ event.dispatcher.on(event.test.passed, test => {
208
+ if (config.deleteSuccessful) {
209
+ deleteDir(dir)
210
+ return
211
+ }
212
+ persist(test, 'passed')
213
+ })
214
+
215
+ event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
216
+ if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') {
217
+ return
218
+ }
219
+ persist(test, 'failed')
220
+ })
221
+
222
+ async function persistStep(step) {
223
+ if (stepNum === -1) return
224
+ if (isStepIgnored(step)) return
225
+ if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
226
+
227
+ const stepKey = step.toString()
228
+
229
+ if (savedSteps.has(stepKey)) {
230
+ const existingStep = steps.find(s => s.step === stepKey)
231
+ if (existingStep && step.status === 'failed') {
232
+ existingStep.status = 'failed'
233
+ step.artifacts = {}
234
+ await captureArtifactsForStep(step, existingStep, existingStep.prefix)
235
+ }
236
+ return
237
+ }
238
+ savedSteps.add(stepKey)
239
+
240
+ const stepPrefix = generateStepPrefix(step, stepNum)
241
+ stepNum++
242
+
243
+ const stepData = {
244
+ step: step.toString(),
245
+ status: step.status,
246
+ prefix: stepPrefix,
247
+ artifacts: {},
248
+ meta: {},
249
+ debugOutput: [],
250
+ }
251
+
252
+ if (step.startTime && step.endTime) {
253
+ stepData.meta.duration = ((step.endTime - step.startTime) / 1000).toFixed(2) + 's'
254
+ }
255
+
256
+ if (config.captureDebugOutput && debugOutput.length > 0) {
257
+ stepData.debugOutput = [...debugOutput]
258
+ debugOutput = []
259
+ }
260
+
261
+ await captureArtifactsForStep(step, stepData, stepPrefix)
262
+ steps.push(stepData)
263
+ }
264
+
265
+ async function captureArtifactsForStep(step, stepData, stepPrefix) {
266
+ if (!step.artifacts) {
267
+ step.artifacts = {}
268
+ }
269
+
270
+ let browserAvailable = true
271
+
272
+ try {
273
+ try {
274
+ if (helper.grabCurrentUrl) {
275
+ const url = await helper.grabCurrentUrl()
276
+ stepData.meta.url = url
277
+ currentUrl = url
278
+ }
279
+ } catch (err) {
280
+ browserAvailable = false
281
+ output.debug(`aiTrace: Browser unavailable, partial artifact capture: ${err.message}`)
282
+ }
283
+
284
+ if (step.artifacts?.screenshot) {
285
+ const screenshotPath = path.isAbsolute(step.artifacts.screenshot)
286
+ ? step.artifacts.screenshot
287
+ : path.resolve(dir, step.artifacts.screenshot)
288
+ const screenshotFile = path.basename(screenshotPath)
289
+ stepData.artifacts.screenshot = screenshotFile
290
+ step.artifacts.screenshot = screenshotPath
291
+
292
+ if (!fs.existsSync(screenshotPath)) {
293
+ try {
294
+ await helper.saveScreenshot(screenshotPath, config.fullPageScreenshots)
295
+ } catch (err) {
296
+ output.debug(`aiTrace: Could not save screenshot: ${err.message}`)
297
+ }
298
+ }
299
+ } else {
300
+ try {
301
+ const screenshotFile = `${stepPrefix}_screenshot.png`
302
+ const screenshotPath = path.join(dir, screenshotFile)
303
+ await helper.saveScreenshot(screenshotPath, config.fullPageScreenshots)
304
+
305
+ stepData.artifacts.screenshot = screenshotFile
306
+ step.artifacts.screenshot = screenshotPath
307
+ } catch (err) {
308
+ output.debug(`aiTrace: Could not save screenshot: ${err.message}`)
309
+ }
310
+ }
311
+
312
+ // Save HTML
313
+ if (config.captureHTML && helper.grabSource && browserAvailable) {
314
+ if (!step.artifacts?.html) {
315
+ try {
316
+ const html = await helper.grabSource()
317
+ const htmlFile = `${stepPrefix}_page.html`
318
+ fs.writeFileSync(path.join(dir, htmlFile), html)
319
+ stepData.artifacts.html = htmlFile
320
+ } catch (err) {
321
+ output.debug(`aiTrace: Could not capture HTML: ${err.message}`)
322
+ }
323
+ } else {
324
+ stepData.artifacts.html = step.artifacts.html
325
+ }
326
+ }
327
+
328
+ // Save ARIA snapshot
329
+ if (config.captureARIA && helper.grabAriaSnapshot && browserAvailable) {
330
+ try {
331
+ const aria = await helper.grabAriaSnapshot()
332
+ const ariaFile = `${stepPrefix}_aria.txt`
333
+ fs.writeFileSync(path.join(dir, ariaFile), aria)
334
+ stepData.artifacts.aria = ariaFile
335
+ } catch (err) {
336
+ output.debug(`aiTrace: Could not capture ARIA snapshot: ${err.message}`)
337
+ }
338
+ }
339
+
340
+ // Save browser logs
341
+ if (config.captureBrowserLogs && helper.grabBrowserLogs && browserAvailable) {
342
+ try {
343
+ const logs = await helper.grabBrowserLogs()
344
+ const logsFile = `${stepPrefix}_console.json`
345
+ fs.writeFileSync(path.join(dir, logsFile), JSON.stringify(logs || [], null, 2))
346
+ stepData.artifacts.console = logsFile
347
+ stepData.meta.consoleCount = logs ? logs.length : 0
348
+ } catch (err) {
349
+ output.debug(`aiTrace: Could not capture browser logs: ${err.message}`)
350
+ }
351
+ }
352
+ } catch (err) {
353
+ output.plugin(`aiTrace: Can't save step artifacts: ${err}`)
354
+ }
355
+ }
356
+
357
+ function persist(test, status) {
358
+ if (!steps.length) {
359
+ output.debug('aiTrace: No steps to save in trace')
360
+ return
361
+ }
362
+
363
+ const testDuration = ((Date.now() - testStartTime) / 1000).toFixed(2)
364
+
365
+ let markdown = `file: ${test.file || 'unknown'}\n`
366
+ markdown += `name: ${test.title}\n`
367
+ markdown += `time: ${testDuration}s\n`
368
+ markdown += `---\n\n`
369
+
370
+ if (status === 'failed') {
371
+ if (test.art && test.art.message) {
372
+ markdown += `Error: ${test.art.message}\n\n`
373
+ }
374
+ if (test.art && test.art.stack) {
375
+ markdown += `${test.art.stack}\n\n`
376
+ }
377
+ markdown += `---\n\n`
378
+ }
379
+
380
+ if (config.captureDebugOutput && debugOutput.length > 0) {
381
+ markdown += `CodeceptJS Debug Output:\n\n`
382
+ debugOutput.forEach(line => {
383
+ markdown += `> ${line}\n`
384
+ })
385
+ markdown += `\n---\n\n`
386
+ }
387
+
388
+ steps.forEach((stepData, index) => {
389
+ const stepAnchor = clearString(stepData.step).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50)
390
+ markdown += `### Step ${index + 1}: ${stepData.step}\n`
391
+ markdown += `<a id="${stepAnchor}"></a>\n`
392
+
393
+ if (stepData.meta.duration) {
394
+ markdown += ` > duration: ${stepData.meta.duration}\n`
395
+ }
396
+
397
+ if (stepData.meta.url) {
398
+ markdown += ` > navigated to ${stepData.meta.url}\n`
399
+ }
400
+
401
+ if (config.captureDebugOutput && stepData.debugOutput && stepData.debugOutput.length > 0) {
402
+ stepData.debugOutput.forEach(line => {
403
+ markdown += ` > ${line}\n`
404
+ })
405
+ }
406
+
407
+ if (stepData.artifacts.html) {
408
+ markdown += ` > [HTML](./${stepData.artifacts.html})\n`
409
+ }
410
+
411
+ if (stepData.artifacts.aria) {
412
+ markdown += ` > [ARIA Snapshot](./${stepData.artifacts.aria})\n`
413
+ }
414
+
415
+ if (stepData.artifacts.screenshot) {
416
+ markdown += ` > [Screenshot](./${stepData.artifacts.screenshot})\n`
417
+ }
418
+
419
+ if (stepData.artifacts.console) {
420
+ const count = stepData.meta.consoleCount || 0
421
+ markdown += ` > [Browser Logs](./${stepData.artifacts.console}) (${count} entries)\n`
422
+ }
423
+
424
+ if (config.captureHTTP) {
425
+ if (test.artifacts && test.artifacts.har) {
426
+ const harPath = path.relative(reportDir, test.artifacts.har)
427
+ markdown += ` > HTTP: see [HAR file](../${harPath}) for network requests\n`
428
+ } else if (test.artifacts && test.artifacts.trace) {
429
+ const tracePath = path.relative(reportDir, test.artifacts.trace)
430
+ markdown += ` > HTTP: see [Playwright trace](../${tracePath}) for network requests\n`
431
+ }
432
+ }
433
+
434
+ markdown += `\n`
435
+ })
436
+
437
+ const traceFile = path.join(dir, 'trace.md')
438
+ fs.writeFileSync(traceFile, markdown)
439
+
440
+ output.print(`🤖 AI Trace: ${colors.white.bold(`file://${traceFile}`)}`)
441
+
442
+ if (!test.artifacts) test.artifacts = {}
443
+ test.artifacts.aiTrace = traceFile
444
+ }
445
+
446
+ function isStepIgnored(step) {
447
+ if (!config.ignoreSteps) return false
448
+ for (const pattern of config.ignoreSteps || []) {
449
+ if (step.name.match(pattern)) return true
450
+ }
451
+ return false
452
+ }
453
+
454
+ function generateStepPrefix(step, index) {
455
+ const stepName = step.toString()
456
+ const cleanedName = clearString(stepName)
457
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
458
+ .replace(/_{2,}/g, '_')
459
+ .slice(0, 80)
460
+ .trim()
461
+
462
+ return `${String(index).padStart(4, '0')}_${cleanedName}`
463
+ }
464
+ }