codeceptjs 4.0.0-rc.17 → 4.0.0-rc.19

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 (240) hide show
  1. package/bin/codecept.js +15 -2
  2. package/bin/codeceptq.js +49 -0
  3. package/bin/mcp-server.js +733 -196
  4. package/docs/advanced.md +201 -0
  5. package/docs/agents.md +159 -0
  6. package/docs/ai.md +537 -0
  7. package/docs/aitrace.md +266 -0
  8. package/docs/api.md +332 -0
  9. package/docs/assertions.md +415 -0
  10. package/docs/auth.md +318 -0
  11. package/docs/basics.md +424 -0
  12. package/docs/bdd.md +539 -0
  13. package/docs/best.md +240 -0
  14. package/docs/bootstrap.md +132 -0
  15. package/docs/commands.md +352 -0
  16. package/docs/community-helpers.md +63 -0
  17. package/docs/configuration.md +230 -0
  18. package/docs/continuous-integration.md +497 -0
  19. package/docs/custom-helpers.md +297 -0
  20. package/docs/data.md +448 -0
  21. package/docs/debugging.md +332 -0
  22. package/docs/detox.md +235 -0
  23. package/docs/docker.md +136 -0
  24. package/docs/effects.md +179 -0
  25. package/docs/element-based-testing.md +295 -0
  26. package/docs/element-selection.md +125 -0
  27. package/docs/els.md +328 -0
  28. package/docs/examples.md +161 -0
  29. package/docs/heal.md +213 -0
  30. package/docs/helpers/ApiDataFactory.md +267 -0
  31. package/docs/helpers/Appium.md +1405 -0
  32. package/docs/helpers/Detox.md +665 -0
  33. package/docs/helpers/ExpectHelper.md +275 -0
  34. package/docs/helpers/FileSystem.md +152 -0
  35. package/docs/helpers/GraphQL.md +152 -0
  36. package/docs/helpers/GraphQLDataFactory.md +226 -0
  37. package/docs/helpers/JSONResponse.md +255 -0
  38. package/docs/helpers/Mochawesome.md +8 -0
  39. package/docs/helpers/MockRequest.md +377 -0
  40. package/docs/helpers/MockServer.md +212 -0
  41. package/docs/helpers/Playwright.md +2969 -0
  42. package/docs/helpers/Polly.md +44 -0
  43. package/docs/helpers/Protractor.md +1769 -0
  44. package/docs/helpers/Puppeteer-firefox.md +86 -0
  45. package/docs/helpers/Puppeteer.md +2690 -0
  46. package/docs/helpers/REST.md +289 -0
  47. package/docs/helpers/SoftExpectHelper.md +352 -0
  48. package/docs/helpers/WebDriver.md +2682 -0
  49. package/docs/hooks.md +339 -0
  50. package/docs/index.md +111 -0
  51. package/docs/installation.md +83 -0
  52. package/docs/internal-api.md +265 -0
  53. package/docs/internal-test-server.md +89 -0
  54. package/docs/locators.md +355 -0
  55. package/docs/mcp.md +485 -0
  56. package/docs/migration-4.md +556 -0
  57. package/docs/mobile.md +338 -0
  58. package/docs/pageobjects.md +399 -0
  59. package/docs/parallel.md +585 -0
  60. package/docs/playwright.md +714 -0
  61. package/docs/plugins.md +866 -0
  62. package/docs/puppeteer.md +314 -0
  63. package/docs/quickstart.md +120 -0
  64. package/docs/react.md +70 -0
  65. package/docs/reports.md +483 -0
  66. package/docs/retry.md +274 -0
  67. package/docs/secrets.md +150 -0
  68. package/docs/sessions.md +80 -0
  69. package/docs/shadow.md +68 -0
  70. package/docs/test-structure.md +275 -0
  71. package/docs/timeouts.md +183 -0
  72. package/docs/translation.md +247 -0
  73. package/docs/tutorial.md +271 -0
  74. package/docs/typescript.md +374 -0
  75. package/docs/web-element.md +251 -0
  76. package/docs/webdriver.md +708 -0
  77. package/docs/within.md +55 -0
  78. package/lib/aria.js +260 -0
  79. package/lib/command/dryRun.js +23 -3
  80. package/lib/command/init.js +247 -266
  81. package/lib/command/list.js +150 -10
  82. package/lib/command/query.js +218 -0
  83. package/lib/config.js +77 -4
  84. package/lib/container.js +34 -2
  85. package/lib/element/WebElement.js +37 -0
  86. package/lib/globals.js +11 -10
  87. package/lib/helper/Playwright.js +5 -6
  88. package/lib/helper/extras/PlaywrightReactVueLocator.js +45 -36
  89. package/lib/html.js +90 -16
  90. package/lib/index.js +9 -1
  91. package/lib/locator.js +2 -2
  92. package/lib/mocha/factory.js +5 -1
  93. package/lib/mocha/inject.js +1 -1
  94. package/lib/parser.js +2 -2
  95. package/lib/pause.js +38 -4
  96. package/lib/plugin/aiTrace.js +72 -84
  97. package/lib/plugin/browser.js +77 -0
  98. package/lib/plugin/expose.js +159 -0
  99. package/lib/plugin/heal.js +44 -1
  100. package/lib/plugin/pageInfo.js +51 -48
  101. package/lib/plugin/pause.js +131 -0
  102. package/lib/plugin/pauseOnFail.js +10 -34
  103. package/lib/plugin/screencast.js +287 -0
  104. package/lib/plugin/screenshot.js +563 -0
  105. package/lib/plugin/screenshotOnFail.js +8 -170
  106. package/lib/utils/pluginParser.js +151 -0
  107. package/lib/utils/trace.js +297 -0
  108. package/lib/utils.js +25 -0
  109. package/lib/workers.js +1 -15
  110. package/package.json +12 -10
  111. package/typings/index.d.ts +0 -5
  112. package/docs/webapi/amOnPage.mustache +0 -11
  113. package/docs/webapi/appendField.mustache +0 -16
  114. package/docs/webapi/attachFile.mustache +0 -24
  115. package/docs/webapi/blur.mustache +0 -18
  116. package/docs/webapi/checkOption.mustache +0 -13
  117. package/docs/webapi/clearCookie.mustache +0 -9
  118. package/docs/webapi/clearField.mustache +0 -14
  119. package/docs/webapi/click.mustache +0 -29
  120. package/docs/webapi/clickLink.mustache +0 -8
  121. package/docs/webapi/closeCurrentTab.mustache +0 -7
  122. package/docs/webapi/closeOtherTabs.mustache +0 -8
  123. package/docs/webapi/dontSee.mustache +0 -11
  124. package/docs/webapi/dontSeeCheckboxIsChecked.mustache +0 -10
  125. package/docs/webapi/dontSeeCookie.mustache +0 -8
  126. package/docs/webapi/dontSeeCurrentPathEquals.mustache +0 -10
  127. package/docs/webapi/dontSeeCurrentUrlEquals.mustache +0 -10
  128. package/docs/webapi/dontSeeElement.mustache +0 -12
  129. package/docs/webapi/dontSeeElementInDOM.mustache +0 -8
  130. package/docs/webapi/dontSeeInCurrentUrl.mustache +0 -4
  131. package/docs/webapi/dontSeeInField.mustache +0 -16
  132. package/docs/webapi/dontSeeInSource.mustache +0 -8
  133. package/docs/webapi/dontSeeInTitle.mustache +0 -8
  134. package/docs/webapi/dontSeeTraffic.mustache +0 -13
  135. package/docs/webapi/doubleClick.mustache +0 -13
  136. package/docs/webapi/downloadFile.mustache +0 -12
  137. package/docs/webapi/dragAndDrop.mustache +0 -9
  138. package/docs/webapi/dragSlider.mustache +0 -11
  139. package/docs/webapi/executeAsyncScript.mustache +0 -24
  140. package/docs/webapi/executeScript.mustache +0 -26
  141. package/docs/webapi/fillField.mustache +0 -21
  142. package/docs/webapi/flushNetworkTraffics.mustache +0 -5
  143. package/docs/webapi/focus.mustache +0 -13
  144. package/docs/webapi/forceClick.mustache +0 -28
  145. package/docs/webapi/forceRightClick.mustache +0 -18
  146. package/docs/webapi/grabAllWindowHandles.mustache +0 -7
  147. package/docs/webapi/grabAttributeFrom.mustache +0 -10
  148. package/docs/webapi/grabAttributeFromAll.mustache +0 -9
  149. package/docs/webapi/grabBrowserLogs.mustache +0 -9
  150. package/docs/webapi/grabCookie.mustache +0 -11
  151. package/docs/webapi/grabCssPropertyFrom.mustache +0 -11
  152. package/docs/webapi/grabCssPropertyFromAll.mustache +0 -10
  153. package/docs/webapi/grabCurrentUrl.mustache +0 -9
  154. package/docs/webapi/grabCurrentWindowHandle.mustache +0 -6
  155. package/docs/webapi/grabDataFromPerformanceTiming.mustache +0 -20
  156. package/docs/webapi/grabElementBoundingRect.mustache +0 -20
  157. package/docs/webapi/grabGeoLocation.mustache +0 -8
  158. package/docs/webapi/grabHTMLFrom.mustache +0 -10
  159. package/docs/webapi/grabHTMLFromAll.mustache +0 -9
  160. package/docs/webapi/grabNumberOfOpenTabs.mustache +0 -8
  161. package/docs/webapi/grabNumberOfVisibleElements.mustache +0 -9
  162. package/docs/webapi/grabPageScrollPosition.mustache +0 -8
  163. package/docs/webapi/grabPopupText.mustache +0 -5
  164. package/docs/webapi/grabRecordedNetworkTraffics.mustache +0 -10
  165. package/docs/webapi/grabSource.mustache +0 -8
  166. package/docs/webapi/grabTextFrom.mustache +0 -10
  167. package/docs/webapi/grabTextFromAll.mustache +0 -9
  168. package/docs/webapi/grabTitle.mustache +0 -8
  169. package/docs/webapi/grabValueFrom.mustache +0 -9
  170. package/docs/webapi/grabValueFromAll.mustache +0 -8
  171. package/docs/webapi/grabWebElement.mustache +0 -9
  172. package/docs/webapi/grabWebElements.mustache +0 -9
  173. package/docs/webapi/moveCursorTo.mustache +0 -16
  174. package/docs/webapi/openNewTab.mustache +0 -7
  175. package/docs/webapi/pressKey.mustache +0 -12
  176. package/docs/webapi/pressKeyDown.mustache +0 -12
  177. package/docs/webapi/pressKeyUp.mustache +0 -12
  178. package/docs/webapi/pressKeyWithKeyNormalization.mustache +0 -60
  179. package/docs/webapi/refreshPage.mustache +0 -6
  180. package/docs/webapi/resizeWindow.mustache +0 -6
  181. package/docs/webapi/rightClick.mustache +0 -14
  182. package/docs/webapi/saveElementScreenshot.mustache +0 -10
  183. package/docs/webapi/saveScreenshot.mustache +0 -12
  184. package/docs/webapi/say.mustache +0 -10
  185. package/docs/webapi/scrollIntoView.mustache +0 -11
  186. package/docs/webapi/scrollPageToBottom.mustache +0 -6
  187. package/docs/webapi/scrollPageToTop.mustache +0 -6
  188. package/docs/webapi/scrollTo.mustache +0 -12
  189. package/docs/webapi/see.mustache +0 -11
  190. package/docs/webapi/seeAttributesOnElements.mustache +0 -9
  191. package/docs/webapi/seeCheckboxIsChecked.mustache +0 -10
  192. package/docs/webapi/seeCookie.mustache +0 -8
  193. package/docs/webapi/seeCssPropertiesOnElements.mustache +0 -9
  194. package/docs/webapi/seeCurrentPathEquals.mustache +0 -10
  195. package/docs/webapi/seeCurrentUrlEquals.mustache +0 -11
  196. package/docs/webapi/seeElement.mustache +0 -12
  197. package/docs/webapi/seeElementInDOM.mustache +0 -8
  198. package/docs/webapi/seeInCurrentUrl.mustache +0 -8
  199. package/docs/webapi/seeInField.mustache +0 -17
  200. package/docs/webapi/seeInPopup.mustache +0 -8
  201. package/docs/webapi/seeInSource.mustache +0 -7
  202. package/docs/webapi/seeInTitle.mustache +0 -8
  203. package/docs/webapi/seeNumberOfElements.mustache +0 -11
  204. package/docs/webapi/seeNumberOfVisibleElements.mustache +0 -10
  205. package/docs/webapi/seeTextEquals.mustache +0 -9
  206. package/docs/webapi/seeTitleEquals.mustache +0 -8
  207. package/docs/webapi/seeTraffic.mustache +0 -36
  208. package/docs/webapi/selectOption.mustache +0 -26
  209. package/docs/webapi/setCookie.mustache +0 -16
  210. package/docs/webapi/setGeoLocation.mustache +0 -12
  211. package/docs/webapi/startRecordingTraffic.mustache +0 -8
  212. package/docs/webapi/startRecordingWebSocketMessages.mustache +0 -8
  213. package/docs/webapi/stopRecordingTraffic.mustache +0 -5
  214. package/docs/webapi/stopRecordingWebSocketMessages.mustache +0 -7
  215. package/docs/webapi/switchTo.mustache +0 -9
  216. package/docs/webapi/switchToNextTab.mustache +0 -10
  217. package/docs/webapi/switchToPreviousTab.mustache +0 -10
  218. package/docs/webapi/type.mustache +0 -21
  219. package/docs/webapi/uncheckOption.mustache +0 -13
  220. package/docs/webapi/wait.mustache +0 -8
  221. package/docs/webapi/waitForClickable.mustache +0 -11
  222. package/docs/webapi/waitForCookie.mustache +0 -9
  223. package/docs/webapi/waitForDetached.mustache +0 -10
  224. package/docs/webapi/waitForDisabled.mustache +0 -6
  225. package/docs/webapi/waitForElement.mustache +0 -11
  226. package/docs/webapi/waitForEnabled.mustache +0 -6
  227. package/docs/webapi/waitForFunction.mustache +0 -17
  228. package/docs/webapi/waitForInvisible.mustache +0 -10
  229. package/docs/webapi/waitForNumberOfTabs.mustache +0 -9
  230. package/docs/webapi/waitForText.mustache +0 -13
  231. package/docs/webapi/waitForValue.mustache +0 -10
  232. package/docs/webapi/waitForVisible.mustache +0 -10
  233. package/docs/webapi/waitInUrl.mustache +0 -9
  234. package/docs/webapi/waitNumberOfVisibleElements.mustache +0 -10
  235. package/docs/webapi/waitToHide.mustache +0 -10
  236. package/docs/webapi/waitUrlEquals.mustache +0 -10
  237. package/lib/helper/AI.js +0 -214
  238. package/lib/plugin/pauseOn.js +0 -167
  239. package/lib/plugin/stepByStepReport.js +0 -432
  240. package/lib/plugin/subtitles.js +0 -89
@@ -1,177 +1,15 @@
1
- import fs from 'fs'
2
- import path from 'path'
3
-
4
- import Container from '../container.js'
5
-
6
- import recorder from '../recorder.js'
7
-
8
- import event from '../event.js'
9
-
10
1
  import output from '../output.js'
11
- import store from '../store.js'
12
-
13
- import { fileExists } from '../utils.js'
14
- import Codeceptjs from '../index.js'
15
- import { testToFileName } from '../mocha/test.js'
16
-
17
- const defaultConfig = {
18
- uniqueScreenshotNames: false,
19
- disableScreenshots: false,
20
- fullPageScreenshots: false,
21
- }
2
+ import screenshot from './screenshot.js'
22
3
 
23
- const supportedHelpers = Container.STANDARD_ACTING_HELPERS
4
+ let warned = false
24
5
 
25
6
  /**
26
- * Creates screenshot on failure. Screenshot is saved into `output` directory.
27
- *
28
- * Initially this functionality was part of corresponding helper but has been moved into plugin since 1.4
29
- *
30
- * This plugin is **enabled by default**.
31
- *
32
- * #### Configuration
33
- *
34
- * Configuration can either be taken from a corresponding helper (deprecated) or a from plugin config (recommended).
35
- *
36
- * ```js
37
- * plugins: {
38
- * screenshotOnFail: {
39
- * enabled: true
40
- * }
41
- * }
42
- * ```
43
- *
44
- * Possible config options:
45
- *
46
- * * `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
47
- * * `fullPageScreenshots`: make full page screenshots. Default: false.
48
- *
49
- *
7
+ * @deprecated Use the `screenshot` plugin with `on: 'fail'` (the default).
50
8
  */
51
- export default function (config) {
52
- const helpers = Container.helpers()
53
- let helper
54
-
55
- for (const helperName of supportedHelpers) {
56
- if (Object.keys(helpers).indexOf(helperName) > -1) {
57
- helper = helpers[helperName]
58
- }
59
- }
60
-
61
- if (!helper) return // no helpers for screenshot
62
-
63
- const options = Object.assign(defaultConfig, helper.options, config)
64
-
65
- if (helpers.Mochawesome) {
66
- if (helpers.Mochawesome.config) {
67
- options.uniqueScreenshotNames = helpers.Mochawesome.config.uniqueScreenshotNames
68
- }
69
- }
70
-
71
- if (Codeceptjs.container.mocha()) {
72
- options.reportDir = Codeceptjs.container.mocha()?.options?.reporterOptions && Codeceptjs.container.mocha()?.options?.reporterOptions?.reportDir
73
- }
74
-
75
- if (options.disableScreenshots) {
76
- // old version of disabling screenshots
77
- return
9
+ export default function (config = {}) {
10
+ if (!warned) {
11
+ output.error('screenshotOnFail is deprecated; use the `screenshot` plugin (default on=fail).')
12
+ warned = true
78
13
  }
79
-
80
- event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
81
- if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') {
82
- // no browser here
83
- return
84
- }
85
-
86
- recorder.add(
87
- 'screenshot of failed test',
88
- async () => {
89
- const dataType = 'image/png'
90
- // This prevents data driven to be included in the failed screenshot file name
91
- let fileName
92
-
93
- if (options.uniqueScreenshotNames && test) {
94
- fileName = `${testToFileName(test, { suffix: '', unique: true })}.failed.png`
95
- } else {
96
- fileName = `${testToFileName(test, { suffix: '', unique: false })}.failed.png`
97
- }
98
- const quietMode = !store.outputDir
99
- if (!quietMode) {
100
- output.plugin('screenshotOnFail', 'Test failed, try to save a screenshot')
101
- }
102
-
103
- // Re-check helpers at runtime in case they weren't ready during plugin init
104
- const runtimeHelpers = Container.helpers()
105
- let runtimeHelper = null
106
- for (const helperName of supportedHelpers) {
107
- if (Object.keys(runtimeHelpers).indexOf(helperName) > -1) {
108
- runtimeHelper = runtimeHelpers[helperName]
109
- break
110
- }
111
- }
112
-
113
- if (runtimeHelper && typeof runtimeHelper.saveScreenshot === 'function') {
114
- helper = runtimeHelper
115
- }
116
-
117
- try {
118
- if (options.reportDir) {
119
- fileName = path.join(options.reportDir, fileName)
120
- const mochaReportDir = path.resolve(process.cwd(), options.reportDir)
121
- if (!fileExists(mochaReportDir)) {
122
- fs.mkdirSync(mochaReportDir)
123
- }
124
- }
125
-
126
- // Check if browser/page is still available before attempting screenshot
127
- if (helper.page && helper.page.isClosed && helper.page.isClosed()) {
128
- throw new Error('Browser page has been closed')
129
- }
130
- if (helper.browser && helper.browser.isConnected && !helper.browser.isConnected()) {
131
- throw new Error('Browser has been disconnected')
132
- }
133
-
134
- // Add timeout wrapper to prevent hanging with shorter timeout for ESM
135
- const screenshotPromise = helper.saveScreenshot(fileName, options.fullPageScreenshots)
136
- const timeoutPromise = new Promise((_, reject) => {
137
- setTimeout(() => reject(new Error('Screenshot timeout after 5 seconds')), 5000)
138
- })
139
-
140
- await Promise.race([screenshotPromise, timeoutPromise])
141
-
142
- if (!test.artifacts) test.artifacts = {}
143
- const baseOutputDir = store.outputDir || null
144
- if (baseOutputDir) {
145
- test.artifacts.screenshot = path.join(baseOutputDir, fileName)
146
- if (Container.mocha().options.reporterOptions['mocha-junit-reporter'] && Container.mocha().options.reporterOptions['mocha-junit-reporter'].options.attachments) {
147
- test.attachments = [path.join(baseOutputDir, fileName)]
148
- }
149
- } else {
150
- // Fallback: just store the file name to keep tests stable without triggering path errors
151
- test.artifacts.screenshot = fileName
152
- }
153
- } catch (err) {
154
- if (!quietMode) {
155
- output.plugin('screenshotOnFail', `Failed to save screenshot: ${err.message}`)
156
- }
157
- // Enhanced error handling for browser closed scenarios
158
- if (
159
- err &&
160
- ((err.message &&
161
- (err.message.includes('Target page, context or browser has been closed') ||
162
- err.message.includes('Browser page has been closed') ||
163
- err.message.includes('Browser has been disconnected') ||
164
- err.message.includes('was terminated due to') ||
165
- err.message.includes('no such window: target window already closed') ||
166
- err.message.includes('Screenshot timeout after'))) ||
167
- (err.type && err.type === 'RuntimeError'))
168
- ) {
169
- output.log(`Can't make screenshot, ${err.message}`)
170
- helper.isRunning = false
171
- }
172
- }
173
- },
174
- true,
175
- )
176
- })
14
+ return screenshot({ ...config, on: 'fail' })
177
15
  }
@@ -0,0 +1,151 @@
1
+ import Container from '../container.js'
2
+ import output from '../output.js'
3
+
4
+ const supportedHelpers = Container.STANDARD_ACTING_HELPERS
5
+
6
+ const RESERVED_KEYS = new Set(['on', 'path', 'line', 'pattern'])
7
+ const ALL_MODES = ['fail', 'test', 'step', 'file', 'url']
8
+
9
+ /**
10
+ * Parse a plugin's _args (from CLI `-p plugin:key=value:key=value`) into a flat dict.
11
+ * Each entry is split on `;` then on the first `=`. Bare segments become `{ key: true }`.
12
+ *
13
+ * Examples:
14
+ * parsePluginArgs(['on=fail'])
15
+ * → { on: 'fail' }
16
+ * parsePluginArgs(['on=file', 'path=tests/foo.js;line=43'])
17
+ * → { on: 'file', path: 'tests/foo.js', line: '43' }
18
+ * parsePluginArgs(['on=file', 'path=tests/foo.js', 'line=43'])
19
+ * → { on: 'file', path: 'tests/foo.js', line: '43' }
20
+ * parsePluginArgs(['show'])
21
+ * → { show: true }
22
+ */
23
+ export function parsePluginArgs(args = []) {
24
+ const opts = {}
25
+ for (const arg of args) {
26
+ if (!arg) continue
27
+ for (const segment of arg.split(';')) {
28
+ if (!segment) continue
29
+ if (segment.includes('=')) {
30
+ const eq = segment.indexOf('=')
31
+ const key = segment.slice(0, eq)
32
+ const value = segment.slice(eq + 1)
33
+ opts[key] = coerce(value)
34
+ } else {
35
+ opts[segment] = true
36
+ }
37
+ }
38
+ }
39
+ return opts
40
+ }
41
+
42
+ function coerce(v) {
43
+ if (v === 'true') return true
44
+ if (v === 'false') return false
45
+ return v
46
+ }
47
+
48
+ /**
49
+ * Compose CLI args > config > defaults into a normalized trigger spec, then
50
+ * validate it. Returns `{ on, path, line, pattern, ...rest }` with `line`
51
+ * coerced to a number, or `null` if validation failed (an error is printed).
52
+ *
53
+ * @param {object} cliArgs — output of parsePluginArgs(config._args)
54
+ * @param {object} config — full plugin config object
55
+ * @param {object} defaults — fallback values, e.g. `{ on: 'fail' }`
56
+ * @param {object} options
57
+ * @param {string} options.name — plugin name, used in error messages
58
+ * @param {string[]} [options.validModes] — accepted values for `on`
59
+ * (default: fail, test, step, file, url)
60
+ */
61
+ export function resolveTrigger(cliArgs = {}, config = {}, defaults = {}, options = {}) {
62
+ const { name = 'plugin', validModes = ALL_MODES } = options
63
+ const merged = { ...defaults, ...pickKnown(config), ...cliArgs }
64
+ if (merged.line != null) merged.line = parseInt(merged.line, 10)
65
+
66
+ const valid = new Set(validModes)
67
+ if (!valid.has(merged.on)) {
68
+ output.error(`${name}: unknown on="${merged.on}". Valid: ${validModes.join(', ')}`)
69
+ return null
70
+ }
71
+ if (merged.on === 'file' && !merged.path) {
72
+ output.error(`${name}:on=file requires path=. Example: -p ${name}:on=file:path=tests/foo.js`)
73
+ return null
74
+ }
75
+ if (merged.on === 'url' && !merged.pattern) {
76
+ output.error(`${name}:on=url requires pattern=. Example: -p ${name}:on=url:pattern=/users/*`)
77
+ return null
78
+ }
79
+
80
+ return merged
81
+ }
82
+
83
+ function pickKnown(config) {
84
+ const out = {}
85
+ for (const key of Object.keys(config || {})) {
86
+ if (RESERVED_KEYS.has(key)) out[key] = config[key]
87
+ }
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * Match a step's source location against a `path` (substring/suffix) and optional `line`.
93
+ * Reads the step's stack via `step.line()` to get `file:row:col`.
94
+ */
95
+ export function matchStepFile(step, targetPath, targetLine) {
96
+ if (!targetPath) return false
97
+ const stepLine = step.line && step.line()
98
+ if (!stepLine) return false
99
+
100
+ const parsed = parseStepLine(stepLine)
101
+ if (!parsed) return false
102
+
103
+ const fileMatches = parsed.file.includes(targetPath) || parsed.file.endsWith(targetPath)
104
+ if (!fileMatches) return false
105
+
106
+ if (targetLine != null && !Number.isNaN(targetLine) && parsed.line !== targetLine) return false
107
+ return true
108
+ }
109
+
110
+ function parseStepLine(stepLine) {
111
+ let line = stepLine.trim()
112
+ if (line.startsWith('at ')) line = line.substring(3).trim()
113
+
114
+ const lastColon = line.lastIndexOf(':')
115
+ if (lastColon < 0) return null
116
+ const secondLastColon = line.lastIndexOf(':', lastColon - 1)
117
+ if (secondLastColon < 0) return null
118
+
119
+ const file = line.substring(0, secondLastColon)
120
+ const lineNum = parseInt(line.substring(secondLastColon + 1, lastColon), 10)
121
+
122
+ if (Number.isNaN(lineNum)) return null
123
+ return { file, line: lineNum }
124
+ }
125
+
126
+ /**
127
+ * Match a URL string against a glob-style pattern (supports `*` wildcards).
128
+ */
129
+ export function matchUrl(currentUrl, pattern) {
130
+ if (!pattern || !currentUrl) return false
131
+ return patternToRegex(pattern).test(currentUrl)
132
+ }
133
+
134
+ function patternToRegex(pattern) {
135
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
136
+ const regexStr = escaped.replace(/\*/g, '.*')
137
+ return new RegExp(regexStr)
138
+ }
139
+
140
+ /**
141
+ * Return the first available standard browser helper, or null.
142
+ */
143
+ export function getBrowserHelper() {
144
+ const helpers = Container.helpers()
145
+ for (const name of supportedHelpers) {
146
+ if (Object.keys(helpers).indexOf(name) > -1) {
147
+ return helpers[name]
148
+ }
149
+ }
150
+ return null
151
+ }
@@ -0,0 +1,297 @@
1
+ import crypto from 'crypto'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { pathToFileURL } from 'url'
5
+ import Container from '../container.js'
6
+ import { clearString } from '../utils.js'
7
+ import { formatHtml } from '../html.js'
8
+ import { diffAriaSnapshots } from '../aria.js'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helper / directory naming
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function pickActingHelper(helpers) {
15
+ for (const name of Container.STANDARD_ACTING_HELPERS) {
16
+ if (helpers[name]) return helpers[name]
17
+ }
18
+ return null
19
+ }
20
+
21
+ export function traceDirFor(testFile, testTitle, baseDir) {
22
+ const hash = crypto.createHash('sha256').update((testFile || '') + (testTitle || '')).digest('hex').slice(0, 8)
23
+ const cleanTitle = clearString(testTitle || '').slice(0, 200)
24
+ return path.resolve(baseDir, `trace_${cleanTitle}_${hash}`)
25
+ }
26
+
27
+ export function snapshotDirFor(baseDir) {
28
+ const hash = crypto.randomBytes(4).toString('hex')
29
+ return path.resolve(baseDir, `snapshot_${Date.now()}_${hash}`)
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Artifact link rendering (trace.md)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const ARTIFACT_LABELS = {
37
+ html: 'HTML',
38
+ aria: 'ARIA',
39
+ screenshot: 'Screenshot',
40
+ console: 'Browser Logs',
41
+ storage: 'Storage',
42
+ }
43
+
44
+ export function artifactLinks(artifacts, { indent = ' ', consoleCount } = {}) {
45
+ const lines = []
46
+ const order = ['html', 'aria', 'screenshot', 'console', 'storage']
47
+
48
+ for (const key of order) {
49
+ const file = artifacts[key]
50
+ if (!file) continue
51
+ const label = ARTIFACT_LABELS[key]
52
+ let line = `${indent}> [${label}](./${file})`
53
+ if (key === 'console') {
54
+ const count = consoleCount ?? artifacts.consoleCount ?? 0
55
+ line += ` (${count} entries)`
56
+ } else if (key === 'storage') {
57
+ const cookies = artifacts.cookieCount ?? 0
58
+ const ls = artifacts.localStorageCount ?? 0
59
+ line += ` (${cookies} cookies, ${ls} localStorage)`
60
+ }
61
+ lines.push(line)
62
+ }
63
+
64
+ return lines.join('\n')
65
+ }
66
+
67
+ export function fileToUrl(dir, basename) {
68
+ return pathToFileURL(path.join(dir, basename)).href
69
+ }
70
+
71
+ export function writeTraceMarkdown({ dir, title, file, durationMs, commands, captured, error }) {
72
+ let md = `file: ${file || 'mcp'}\n`
73
+ md += `name: ${title}\n`
74
+ md += `time: ${(durationMs / 1000).toFixed(2)}s\n`
75
+ md += `---\n\n`
76
+
77
+ if (error) md += `Error: ${error}\n\n---\n\n`
78
+
79
+ if (commands && commands.length) {
80
+ md += `### Commands\n`
81
+ for (const c of commands) md += `- ${c}\n`
82
+ md += `\n`
83
+ }
84
+
85
+ md += `### Final State\n`
86
+ if (captured.url) md += ` > URL: ${captured.url}\n`
87
+ const links = artifactLinks(captured)
88
+ if (links) md += links + '\n'
89
+
90
+ const traceFile = path.join(dir, 'trace.md')
91
+ fs.writeFileSync(traceFile, md)
92
+ return traceFile
93
+ }
94
+
95
+ export function artifactsToFileUrls(captured, dir) {
96
+ const out = {}
97
+ if (captured.url) out.url = captured.url
98
+ if (captured.screenshot) out.screenshot = fileToUrl(dir, captured.screenshot)
99
+ if (captured.html) out.html = fileToUrl(dir, captured.html)
100
+ if (captured.aria) out.aria = fileToUrl(dir, captured.aria)
101
+ if (captured.console) out.console = fileToUrl(dir, captured.console)
102
+ if (captured.storage) out.storage = fileToUrl(dir, captured.storage)
103
+ if (typeof captured.consoleCount === 'number') out.consoleCount = captured.consoleCount
104
+ if (typeof captured.cookieCount === 'number') out.cookieCount = captured.cookieCount
105
+ if (typeof captured.localStorageCount === 'number') out.localStorageCount = captured.localStorageCount
106
+ return out
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Snapshot capture (HTML / ARIA / screenshot / console / storage)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function normalizeBrowserLogs(logs) {
114
+ return (logs || []).map(l => {
115
+ if (typeof l === 'string') return l
116
+ if (l && typeof l.type === 'function' && typeof l.text === 'function') {
117
+ return { type: l.type(), text: l.text() }
118
+ }
119
+ return l
120
+ })
121
+ }
122
+
123
+ async function captureStorageState(helper) {
124
+ if (typeof helper.grabStorageState === 'function') {
125
+ try {
126
+ const state = await helper.grabStorageState()
127
+ if (state) return state
128
+ } catch {}
129
+ }
130
+
131
+ const state = { cookies: [], origins: [] }
132
+
133
+ if (typeof helper.grabCookie === 'function') {
134
+ try {
135
+ const cookies = await helper.grabCookie()
136
+ if (Array.isArray(cookies)) state.cookies = cookies
137
+ } catch {}
138
+ }
139
+
140
+ if (typeof helper.executeScript === 'function') {
141
+ try {
142
+ const result = await helper.executeScript(() => {
143
+ const out = { origin: location.origin, items: [] }
144
+ for (let i = 0; i < localStorage.length; i++) {
145
+ const name = localStorage.key(i)
146
+ out.items.push({ name, value: localStorage.getItem(name) })
147
+ }
148
+ return out
149
+ })
150
+ if (result?.items?.length) {
151
+ state.origins.push({ origin: result.origin, localStorage: result.items })
152
+ }
153
+ } catch {}
154
+ }
155
+
156
+ return state
157
+ }
158
+
159
+ export async function captureSnapshot(helper, {
160
+ dir,
161
+ prefix = 'snapshot',
162
+ fullPage = false,
163
+ captureURL = true,
164
+ captureScreenshot = true,
165
+ captureHTML = true,
166
+ captureARIA = true,
167
+ captureBrowserLogs = true,
168
+ captureStorage = true,
169
+ } = {}) {
170
+ if (!helper) return {}
171
+ const out = {}
172
+
173
+ if (captureURL) {
174
+ try {
175
+ if (helper.grabCurrentUrl) out.url = await helper.grabCurrentUrl()
176
+ } catch {}
177
+ }
178
+
179
+ if (captureScreenshot && helper.saveScreenshot) {
180
+ try {
181
+ const file = `${prefix}_screenshot.png`
182
+ await helper.saveScreenshot(path.join(dir, file), fullPage)
183
+ out.screenshot = file
184
+ } catch {}
185
+ }
186
+
187
+ if (captureHTML && helper.grabSource) {
188
+ try {
189
+ const html = await helper.grabSource()
190
+ // Universal funnel: every captured HTML snapshot flows through formatHtml
191
+ // (minify -> cleanHtml -> beautify). Don't add direct grabSource->writeFile
192
+ // paths elsewhere; route through this util so trash-class cleanup stays
193
+ // consistent across aiTrace, pageInfo, and MCP tools.
194
+ const formatted = await formatHtml(html)
195
+ const file = `${prefix}_page.html`
196
+ fs.writeFileSync(path.join(dir, file), formatted)
197
+ out.html = file
198
+ // Expose pre-cleanup HTML for consumers that need to inspect classes
199
+ // stripped by cleanHtml (e.g. pageInfo's error-class scan).
200
+ out.htmlRaw = html
201
+ } catch {}
202
+ }
203
+
204
+ if (captureARIA && helper.grabAriaSnapshot) {
205
+ try {
206
+ const aria = await helper.grabAriaSnapshot()
207
+ const file = `${prefix}_aria.txt`
208
+ fs.writeFileSync(path.join(dir, file), aria)
209
+ out.aria = file
210
+ } catch {}
211
+ }
212
+
213
+ if (captureBrowserLogs && helper.grabBrowserLogs) {
214
+ try {
215
+ const logs = await helper.grabBrowserLogs()
216
+ const normalized = normalizeBrowserLogs(logs)
217
+ const file = `${prefix}_console.json`
218
+ fs.writeFileSync(path.join(dir, file), JSON.stringify(normalized, null, 2))
219
+ out.console = file
220
+ out.consoleCount = normalized.length
221
+ } catch {}
222
+ }
223
+
224
+ if (captureStorage) {
225
+ try {
226
+ const state = await captureStorageState(helper)
227
+ const cookieCount = state.cookies?.length || 0
228
+ const localStorageCount = (state.origins || [])
229
+ .reduce((sum, o) => sum + (o.localStorage?.length || 0), 0)
230
+ if (cookieCount || localStorageCount) {
231
+ const file = `${prefix}_storage.json`
232
+ fs.writeFileSync(path.join(dir, file), JSON.stringify(state, null, 2))
233
+ out.storage = file
234
+ out.cookieCount = cookieCount
235
+ out.localStorageCount = localStorageCount
236
+ }
237
+ } catch {}
238
+ }
239
+
240
+ return out
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // TraceReader — read artifacts already on disk (written by aiTrace, MCP, etc.)
245
+ // ---------------------------------------------------------------------------
246
+
247
+ const KIND_SUFFIX = {
248
+ aria: '_aria.txt',
249
+ html: '_page.html',
250
+ screenshot: '_screenshot.png',
251
+ console: '_console.json',
252
+ storage: '_storage.json',
253
+ }
254
+
255
+ export class TraceReader {
256
+ constructor(dir) {
257
+ this.dir = dir
258
+ }
259
+
260
+ // Filenames of a given kind, sorted in capture order. aiTrace prefixes with
261
+ // a zero-padded step index (`0000_`, `0001_`...), so a lexical sort is
262
+ // chronological.
263
+ list(kind) {
264
+ const suffix = KIND_SUFFIX[kind]
265
+ if (!suffix || !this.dir || !fs.existsSync(this.dir)) return []
266
+ let entries
267
+ try { entries = fs.readdirSync(this.dir) } catch { return [] }
268
+ return entries.filter(f => f.endsWith(suffix)).sort()
269
+ }
270
+
271
+ // Path of the n-th file of `kind`, or null. Python-style indexing:
272
+ // 0..N-1 from the start, -1..-N from the end.
273
+ pathAt(n, kind) {
274
+ const files = this.list(kind)
275
+ if (!files.length) return null
276
+ const i = n < 0 ? files.length + n : n
277
+ if (i < 0 || i >= files.length) return null
278
+ return path.join(this.dir, files[i])
279
+ }
280
+
281
+ // Read content of the n-th file of `kind`. Binary kinds (screenshot) are
282
+ // returned as Buffer; text kinds as utf8 string.
283
+ nth(n, kind) {
284
+ const p = this.pathAt(n, kind)
285
+ if (!p) return null
286
+ try {
287
+ if (kind === 'screenshot') return fs.readFileSync(p)
288
+ return fs.readFileSync(p, 'utf8')
289
+ } catch { return null }
290
+ }
291
+
292
+ first(kind) { return this.nth(0, kind) }
293
+ last(kind) { return this.nth(-1, kind) }
294
+ count(kind) { return this.list(kind).length }
295
+ }
296
+
297
+ export const ariaDiff = diffAriaSnapshots
package/lib/utils.js CHANGED
@@ -617,6 +617,12 @@ function createCircularSafeReplacer(keysToSkip = []) {
617
617
  return undefined
618
618
  }
619
619
 
620
+ // Coerce types that JSON.stringify can't handle natively
621
+ if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`
622
+ if (typeof value === 'bigint') return `${value.toString()}n`
623
+ if (typeof value === 'symbol') return value.toString()
624
+ if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack }
625
+
620
626
  if (value === null || typeof value !== 'object') {
621
627
  return value
622
628
  }
@@ -647,6 +653,25 @@ export const safeStringify = function (obj, keysToSkip = [], space = 0) {
647
653
  }
648
654
  }
649
655
 
656
+ /**
657
+ * Truncate a string at a byte cap, returning structured info.
658
+ * @param {string} str
659
+ * @param {number} maxBytes
660
+ * @returns {{ value: string, truncated: boolean, fullLength: number }}
661
+ */
662
+ export const truncateString = function (str, maxBytes) {
663
+ if (typeof str !== 'string') str = String(str)
664
+ if (str.length <= maxBytes) {
665
+ return { value: str, truncated: false, fullLength: str.length }
666
+ }
667
+ const dropped = str.length - maxBytes
668
+ return {
669
+ value: `${str.slice(0, maxBytes)}\n...[truncated ${dropped} more chars]`,
670
+ truncated: true,
671
+ fullLength: str.length,
672
+ }
673
+ }
674
+
650
675
  export const serializeError = function (error) {
651
676
  if (error) {
652
677
  const { stack, uncaught, message, actual, expected } = error
package/lib/workers.js CHANGED
@@ -521,22 +521,8 @@ class Workers extends EventEmitter {
521
521
  // Workers are already running, this is just a placeholder step
522
522
  })
523
523
 
524
- // Add overall timeout to prevent infinite hanging
525
- const overallTimeout = setTimeout(() => {
526
- console.error('[Main] Overall timeout reached (10 minutes). Force terminating remaining workers...')
527
- workerThreads.forEach(w => {
528
- try {
529
- w.terminate()
530
- } catch (e) {
531
- // ignore
532
- }
533
- })
534
- this._finishRun()
535
- }, 600000) // 10 minutes
536
-
537
524
  return new Promise(resolve => {
538
525
  this.on('end', () => {
539
- clearTimeout(overallTimeout)
540
526
  resolve()
541
527
  })
542
528
  })
@@ -565,7 +551,7 @@ class Workers extends EventEmitter {
565
551
  // Track last activity time to detect hanging workers
566
552
  let lastActivity = Date.now()
567
553
  let currentTest = null
568
- const workerTimeout = 300000 // 5 minutes
554
+ const workerTimeout = process.env.CODECEPT_WORKER_TIMEOUT ? ms(process.env.CODECEPT_WORKER_TIMEOUT) : ms('5m')
569
555
 
570
556
  const timeoutChecker = setInterval(() => {
571
557
  const elapsed = Date.now() - lastActivity