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
@@ -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'
@@ -2674,8 +2674,11 @@ class Playwright extends Helper {
2674
2674
  * @returns {Promise<any>}
2675
2675
  */
2676
2676
  async executeScript(fn, arg) {
2677
+ if (arg && typeof arg.getNativeElement === 'function') arg = arg.getNativeElement()
2678
+ if (arg && typeof arg.evaluate === 'function' && typeof arg.locator === 'function') {
2679
+ return arg.evaluate(fn)
2680
+ }
2677
2681
  if (this.context && this.context.constructor.name === 'FrameLocator') {
2678
- // switching to iframe context
2679
2682
  return this.context.locator(':root').evaluate(fn, arg)
2680
2683
  }
2681
2684
  return this.page.evaluate.apply(this.page, [fn, arg])
@@ -4223,13 +4226,10 @@ async function findByRole(context, locator) {
4223
4226
  }
4224
4227
 
4225
4228
  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
4229
  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
4230
  const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
4230
4231
 
4231
4232
  if (isReactLocator) return findReact(matcher, locator)
4232
- if (isVueLocator) return findVue(matcher, locator)
4233
4233
  if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
4234
4234
 
4235
4235
  // Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
@@ -4245,7 +4245,6 @@ async function findElements(matcher, locator) {
4245
4245
 
4246
4246
  async function findElement(matcher, locator) {
4247
4247
  if (locator.react) return findReact(matcher, locator)
4248
- if (locator.vue) return findVue(matcher, locator)
4249
4248
  if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4250
4249
 
4251
4250
  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,31 @@ 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
+ // Force every element onto its own line so line numbers in trace HTML
327
+ // map 1:1 to elements (consumed by codeceptq for AI/agent debugging).
328
+ inline: [],
329
+ })
330
+ } catch (e) {
331
+ return processed
332
+ }
333
+ }
334
+
335
+ export { scanForErrorMessages, removeNonInteractiveElements, splitByChunks, minifyHtml, simplifyHtmlElement, formatHtml, cleanHtml, isTrashClass }
package/lib/index.js CHANGED
@@ -23,6 +23,10 @@ import heal from './heal.js'
23
23
  import ai from './ai.js'
24
24
  import Workers from './workers.js'
25
25
  import Secret, { secret } from './secret.js'
26
+ import session from './session.js'
27
+
28
+ const inject = (name) => container.support(name)
29
+ const locate = (query) => locator.build(query)
26
30
 
27
31
  export default {
28
32
  /** @type {typeof CodeceptJS.Codecept} */
@@ -67,7 +71,11 @@ export default {
67
71
  Secret,
68
72
  /** @type {typeof CodeceptJS.secret} */
69
73
  secret,
74
+
75
+ session,
76
+ inject,
77
+ locate,
70
78
  }
71
79
 
72
80
  // Named exports for ESM compatibility
73
- export { codecept, output, container, event, recorder, config, actor, helper, pause, within, dataTable, dataTableArgument, store, locator, heal, ai, Workers, Secret, secret }
81
+ export { codecept, output, container, event, recorder, config, actor, helper, pause, within, dataTable, dataTableArgument, store, locator, heal, ai, Workers, Secret, secret, session, inject, locate }
package/lib/locator.js CHANGED
@@ -589,7 +589,7 @@ Locator.clickable = {
589
589
  `.//button[./@name = ${literal}]`,
590
590
  `.//*[@aria-label = ${literal}]`,
591
591
  `.//*[@title = ${literal}]`,
592
- `.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`,
592
+ `.//*[@aria-labelledby][@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id]`,
593
593
  `.//*[@role='button'][normalize-space(.)=${literal}]`,
594
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})]`,
595
595
  ]),
@@ -632,7 +632,7 @@ Locator.field = {
632
632
  `.//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]`,
633
633
  `.//*[@aria-label = ${literal}]`,
634
634
  `.//*[@title = ${literal}]`,
635
- `.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`,
635
+ `.//*[@aria-labelledby][@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id]`,
636
636
  ]),
637
637
 
638
638
  /**
@@ -17,7 +17,11 @@ let mocha
17
17
 
18
18
  class MochaFactory {
19
19
  static create(config, opts) {
20
- mocha = new Mocha(Object.assign(config, opts))
20
+ const merged = Object.assign({}, config, opts)
21
+ mocha = new Mocha(merged)
22
+ if (merged.cleanReferencesAfterRun !== true) {
23
+ mocha.cleanReferencesAfterRun(false)
24
+ }
21
25
  output.process(opts.child)
22
26
  mocha.ui(scenarioUiFunction)
23
27
 
@@ -5,7 +5,7 @@ const getInjectedArguments = async (fn, test, suite) => {
5
5
  const container = containerModule.default || containerModule
6
6
 
7
7
  const testArgs = {}
8
- const params = getParams(fn) || []
8
+ const params = getParams(fn, { warnOnLegacyFormat: true }) || []
9
9
  const objects = container.support()
10
10
 
11
11
  for (const key of params) {
package/lib/parser.js CHANGED
@@ -14,11 +14,11 @@ export const getParamsToString = function (fn) {
14
14
  return getParams(newFn).join(', ')
15
15
  }
16
16
 
17
- function getParams(fn) {
17
+ function getParams(fn, { warnOnLegacyFormat = false } = {}) {
18
18
  if (fn.isSinonProxy) return []
19
19
  try {
20
20
  const reflected = parser.parse(fn)
21
- if (reflected.args.length > 1 || reflected.args[0] === 'I') {
21
+ if (warnOnLegacyFormat && (reflected.args.length > 1 || reflected.args[0] === 'I')) {
22
22
  output.error('Error: old CodeceptJS v2 format detected. Upgrade your project to the new format -> https://bit.ly/codecept3Up')
23
23
  }
24
24
  if (reflected.destructuredArgs.length > 0) reflected.args = [...reflected.destructuredArgs]
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) {