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,3 +1,7 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import * as acorn from 'acorn'
1
5
  import { getConfig, getTestRoot } from './utils.js'
2
6
  import Codecept from '../codecept.js'
3
7
  import container from '../container.js'
@@ -5,33 +9,169 @@ import { getParamsToString } from '../parser.js'
5
9
  import { methodsOfObject } from '../utils.js'
6
10
  import output from '../output.js'
7
11
 
8
- export default async function (path) {
9
- const testsPath = getTestRoot(path)
10
- const config = await getConfig(testsPath)
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
13
+ const helperDir = path.resolve(__dirname, '..', 'helper')
14
+ const webapiDir = path.resolve(__dirname, '..', '..', 'docs', 'webapi')
15
+
16
+ let partialsCache = null
17
+
18
+ function loadWebApiPartials() {
19
+ if (partialsCache) return partialsCache
20
+ const map = new Map()
21
+ if (fs.existsSync(webapiDir)) {
22
+ for (const file of fs.readdirSync(webapiDir)) {
23
+ if (path.extname(file) !== '.mustache') continue
24
+ const name = path.basename(file, '.mustache')
25
+ map.set(name, fs.readFileSync(path.join(webapiDir, file), 'utf8'))
26
+ }
27
+ }
28
+ partialsCache = map
29
+ return map
30
+ }
31
+
32
+ function resolveHelperSource(helper, helperName, config, testsPath) {
33
+ const builtin = path.join(helperDir, `${helper.constructor.name}.js`)
34
+ if (fs.existsSync(builtin)) return builtin
35
+ const requirePath = config?.helpers?.[helperName]?.require
36
+ if (requirePath) {
37
+ const resolved = path.isAbsolute(requirePath) ? requirePath : path.resolve(testsPath, requirePath)
38
+ if (fs.existsSync(resolved)) return resolved
39
+ }
40
+ return null
41
+ }
42
+
43
+ function findClassNode(ast) {
44
+ for (const node of ast.body) {
45
+ if (node.type === 'ClassDeclaration') return node
46
+ if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'ClassDeclaration') return node.declaration
47
+ if (node.type === 'ExportDefaultDeclaration' && node.declaration?.type === 'ClassDeclaration') return node.declaration
48
+ }
49
+ return null
50
+ }
51
+
52
+ function stripJsDoc(value) {
53
+ return value
54
+ .split('\n')
55
+ .map(line => line.replace(/^\s*\* ?/, ''))
56
+ .join('\n')
57
+ .trim()
58
+ }
59
+
60
+ function resolvePartials(text, partials) {
61
+ return text.replace(/\{\{>\s*([\w-]+)\s*\}\}/g, (match, name) => {
62
+ return partials.has(name) ? partials.get(name) : match
63
+ })
64
+ }
65
+
66
+ function extractMethodDocs(helper, helperName, config, testsPath, partials) {
67
+ const result = new Map()
68
+ const sourceFile = resolveHelperSource(helper, helperName, config, testsPath)
69
+ if (!sourceFile) return result
70
+
71
+ let source
72
+ try {
73
+ source = fs.readFileSync(sourceFile, 'utf8')
74
+ } catch {
75
+ return result
76
+ }
77
+
78
+ const comments = []
79
+ let ast
80
+ try {
81
+ ast = acorn.parse(source, {
82
+ ecmaVersion: 'latest',
83
+ sourceType: 'module',
84
+ locations: true,
85
+ onComment: comments,
86
+ })
87
+ } catch {
88
+ return result
89
+ }
90
+
91
+ const classNode = findClassNode(ast)
92
+ if (!classNode) return result
93
+
94
+ const blockComments = comments
95
+ .filter(c => c.type === 'Block' && c.value.startsWith('*'))
96
+ .sort((a, b) => a.start - b.start)
97
+
98
+ let cursor = 0
99
+ for (const member of classNode.body.body) {
100
+ if (member.type !== 'MethodDefinition') continue
101
+ if (member.kind === 'constructor' || member.static) continue
102
+ const name = member.key?.name
103
+ if (!name || name.startsWith('_')) continue
104
+
105
+ let attached = null
106
+ let attachedIdx = -1
107
+ for (let i = cursor; i < blockComments.length; i++) {
108
+ const c = blockComments[i]
109
+ if (c.end > member.start) break
110
+ attached = c
111
+ attachedIdx = i
112
+ }
113
+ if (attached) {
114
+ cursor = attachedIdx + 1
115
+ const stripped = stripJsDoc(attached.value)
116
+ const resolved = resolvePartials(stripped, partials)
117
+ result.set(name, resolved)
118
+ }
119
+ }
120
+
121
+ return result
122
+ }
123
+
124
+ function printDocBlock(doc) {
125
+ if (!doc) return
126
+ for (const line of doc.split('\n')) {
127
+ output.print(` ${line}`)
128
+ }
129
+ output.print('')
130
+ }
131
+
132
+ export default async function (path, options = {}) {
133
+ const configFile = options.config
134
+ const testsPath = getTestRoot(configFile || path)
135
+ const config = await getConfig(configFile || testsPath)
11
136
  const codecept = new Codecept(config, {})
12
137
  await codecept.init(testsPath)
13
138
  await container.started()
14
139
 
15
- output.print('List of test actions: -- ')
140
+ const filter = options.action ? options.action.replace(/^I\./, '') : null
141
+ const showDocs = !!(options.docs || filter)
142
+ const partials = showDocs ? loadWebApiPartials() : null
143
+
144
+ if (!filter) output.print('List of test actions: -- ')
16
145
  const helpers = container.helpers()
17
146
  const supportI = container.support('I')
18
147
  const actions = []
148
+ let matched = false
19
149
  for (const name in helpers) {
20
150
  const helper = helpers[name]
151
+ const docs = showDocs ? extractMethodDocs(helper, name, config, testsPath, partials) : null
21
152
  methodsOfObject(helper).forEach(action => {
22
- const params = getParamsToString(helper[action])
23
153
  actions[action] = 1
154
+ if (filter && action !== filter) return
155
+ const params = getParamsToString(helper[action])
24
156
  output.print(` ${output.colors.grey(name)} I.${output.colors.bold(action)}(${params})`)
157
+ if (docs && docs.has(action)) printDocBlock(docs.get(action))
158
+ matched = true
25
159
  })
26
160
  }
27
161
  for (const name in supportI) {
28
- if (actions[name]) {
29
- continue
30
- }
162
+ if (actions[name]) continue
163
+ if (filter && name !== filter) continue
31
164
  const actor = supportI[name]
32
165
  const params = getParamsToString(actor)
33
166
  output.print(` I.${output.colors.bold(name)}(${params})`)
167
+ matched = true
168
+ }
169
+ if (filter && !matched) {
170
+ output.print(`No action named ${output.colors.bold(filter)} found in enabled helpers or support objects.`)
171
+ return
172
+ }
173
+ if (!filter) {
174
+ output.print('PS: Actions are retrieved from enabled helpers. ')
175
+ output.print('Implement custom actions in your helper classes.')
34
176
  }
35
- output.print('PS: Actions are retrieved from enabled helpers. ')
36
- output.print('Implement custom actions in your helper classes.')
37
177
  }
@@ -0,0 +1,218 @@
1
+ import fs from 'fs'
2
+ import * as parse5 from 'parse5'
3
+ import { DOMImplementation, XMLSerializer } from '@xmldom/xmldom'
4
+ import xpath from 'xpath'
5
+ import Locator from '../locator.js'
6
+ import { xpathLocator } from '../utils.js'
7
+
8
+ export default async function query(locator, context, options = {}) {
9
+ const html = options.file ? fs.readFileSync(options.file, 'utf8') : await readStdin()
10
+
11
+ if (!html || !html.trim()) {
12
+ console.error('codeceptq: no HTML input. Pipe HTML via stdin or use --file <path>.')
13
+ process.exitCode = 2
14
+ return
15
+ }
16
+
17
+ let xpathExpr
18
+ let contextExpr = null
19
+ try {
20
+ xpathExpr = buildXPath(locator, options)
21
+ if (context) contextExpr = buildXPath(context, {})
22
+ } catch (err) {
23
+ console.error(`codeceptq: cannot build XPath: ${err.message}`)
24
+ process.exitCode = 2
25
+ return
26
+ }
27
+
28
+ const { doc, source } = htmlToDoc(html)
29
+
30
+ let nodes
31
+ try {
32
+ if (contextExpr) {
33
+ const ctxNodes = toArray(xpath.select(contextExpr, doc))
34
+ const seen = new Set()
35
+ nodes = []
36
+ for (const ctx of ctxNodes) {
37
+ for (const m of toArray(xpath.select(xpathExpr, ctx))) {
38
+ if (!seen.has(m)) {
39
+ seen.add(m)
40
+ nodes.push(m)
41
+ }
42
+ }
43
+ }
44
+ } else {
45
+ nodes = toArray(xpath.select(xpathExpr, doc))
46
+ }
47
+ } catch (err) {
48
+ console.error(`codeceptq: XPath evaluation failed for "${xpathExpr}": ${err.message}`)
49
+ process.exitCode = 2
50
+ return
51
+ }
52
+
53
+ const limit = parseInt(options.limit, 10) || 20
54
+ const snippetLen = parseInt(options.snippet, 10) || 500
55
+ const truncated = nodes.slice(0, limit)
56
+ const where = options.file || 'stdin'
57
+
58
+ if (options.json) {
59
+ process.stdout.write(
60
+ JSON.stringify(
61
+ {
62
+ locator,
63
+ context: context || null,
64
+ xpath: xpathExpr,
65
+ contextXPath: contextExpr,
66
+ source: where,
67
+ total: nodes.length,
68
+ shown: truncated.length,
69
+ matches: truncated.map(n => ({
70
+ line: n.__line ?? null,
71
+ snippet: renderSnippet(n, source, snippetLen, options.full),
72
+ })),
73
+ },
74
+ null,
75
+ 2,
76
+ ) + '\n',
77
+ )
78
+ } else {
79
+ if (nodes.length === 0) {
80
+ console.log(`No matches for ${quote(locator)}${context ? ` within ${quote(context)}` : ''} in ${where}`)
81
+ console.log(`(xpath: ${xpathExpr})`)
82
+ } else {
83
+ const noun = nodes.length === 1 ? 'match' : 'matches'
84
+ const more = nodes.length > truncated.length ? ` (showing first ${truncated.length})` : ''
85
+ console.log(`${nodes.length} ${noun} for ${quote(locator)}${context ? ` within ${quote(context)}` : ''} in ${where}${more}`)
86
+ console.log()
87
+ truncated.forEach((node, i) => {
88
+ const line = node.__line ?? '?'
89
+ console.log(`${i + 1}. Line ${line}`)
90
+ const snippet = renderSnippet(node, source, snippetLen, options.full)
91
+ snippet.split('\n').forEach(l => console.log(' ' + l))
92
+ console.log()
93
+ })
94
+ }
95
+ }
96
+
97
+ if (nodes.length === 0) process.exitCode = 1
98
+ }
99
+
100
+ function buildXPath(input, options) {
101
+ const literal = xpathLocator.literal(input)
102
+ if (options.field) return Locator.field.byText(literal)
103
+ if (options.click || options.clickable) return Locator.clickable.wide(literal)
104
+ if (options.checkable) return Locator.checkable.byText(literal)
105
+ if (options.select) {
106
+ return Locator.select.byVisibleText(literal).replace(/\.\/(option|optgroup)/g, './/$1')
107
+ }
108
+
109
+ if (options.xpath) return new Locator({ xpath: input }).toXPath()
110
+ if (options.css) return new Locator({ css: input }).toXPath()
111
+
112
+ const loc = new Locator(input)
113
+ if (loc.type === 'fuzzy') {
114
+ return xpathLocator.combine([Locator.clickable.wide(literal), Locator.field.byText(literal)])
115
+ }
116
+ return loc.toXPath()
117
+ }
118
+
119
+ function htmlToDoc(html) {
120
+ const p5doc = parse5.parse(html, { sourceCodeLocationInfo: true })
121
+ const impl = new DOMImplementation()
122
+ const doc = impl.createDocument(null, null, null)
123
+ walkParse5(p5doc, doc, doc)
124
+ return { doc, source: html }
125
+ }
126
+
127
+ function walkParse5(p5node, xmlParent, xmlDoc) {
128
+ for (const child of p5node.childNodes || []) {
129
+ const name = child.nodeName
130
+ if (name === '#text') {
131
+ if (child.value != null) {
132
+ const t = xmlDoc.createTextNode(child.value)
133
+ if (child.sourceCodeLocation) t.__line = child.sourceCodeLocation.startLine
134
+ xmlParent.appendChild(t)
135
+ }
136
+ } else if (name === '#comment') {
137
+ try {
138
+ xmlParent.appendChild(xmlDoc.createComment(child.data || ''))
139
+ } catch {
140
+ // ignore comments xmldom rejects
141
+ }
142
+ } else if (name === '#documentType') {
143
+ // skip doctype
144
+ } else {
145
+ const tagName = child.tagName || name
146
+ let el
147
+ try {
148
+ el = xmlDoc.createElement(tagName)
149
+ } catch {
150
+ continue
151
+ }
152
+ for (const attr of child.attrs || []) {
153
+ try {
154
+ el.setAttribute(attr.name, attr.value)
155
+ } catch {
156
+ // ignore attrs xmldom rejects (namespaces, invalid names)
157
+ }
158
+ }
159
+ const loc = child.sourceCodeLocation
160
+ if (loc) {
161
+ el.__line = loc.startLine
162
+ el.__startOffset = loc.startOffset
163
+ el.__endOffset = loc.endOffset
164
+ el.__startTagEndOffset = loc.startTag ? loc.startTag.endOffset : loc.endOffset
165
+ }
166
+ xmlParent.appendChild(el)
167
+ walkParse5(child, el, xmlDoc)
168
+ }
169
+ }
170
+ }
171
+
172
+ function renderSnippet(node, source, snippetLen, full) {
173
+ if (typeof node.__startOffset !== 'number') {
174
+ try {
175
+ return new XMLSerializer().serializeToString(node)
176
+ } catch {
177
+ return `<${node.nodeName || '?'}>`
178
+ }
179
+ }
180
+ const start = node.__startOffset
181
+ const end = node.__endOffset ?? start
182
+ if (full) return source.slice(start, end)
183
+
184
+ const tagEnd = node.__startTagEndOffset ?? end
185
+ const openingTag = source.slice(start, tagEnd)
186
+ if (end <= tagEnd) return openingTag
187
+
188
+ const totalLen = end - start
189
+ if (totalLen <= snippetLen) return source.slice(start, end)
190
+
191
+ const remaining = Math.max(0, snippetLen - openingTag.length)
192
+ if (remaining < 20) return openingTag + ' …'
193
+ return openingTag + source.slice(tagEnd, tagEnd + remaining) + ' …'
194
+ }
195
+
196
+ function readStdin() {
197
+ return new Promise((resolve, reject) => {
198
+ if (process.stdin.isTTY) {
199
+ resolve('')
200
+ return
201
+ }
202
+ let data = ''
203
+ process.stdin.setEncoding('utf8')
204
+ process.stdin.on('data', chunk => (data += chunk))
205
+ process.stdin.on('end', () => resolve(data))
206
+ process.stdin.on('error', reject)
207
+ })
208
+ }
209
+
210
+ function toArray(v) {
211
+ if (Array.isArray(v)) return v
212
+ if (v == null || v === '' || typeof v === 'boolean' || typeof v === 'number') return []
213
+ return [v]
214
+ }
215
+
216
+ function quote(s) {
217
+ return `'${String(s).replace(/'/g, "\\'")}'`
218
+ }
package/lib/config.js CHANGED
@@ -15,8 +15,9 @@ const defaultConfig = {
15
15
  hooks: [],
16
16
  gherkin: {},
17
17
  plugins: {
18
- screenshotOnFail: {
19
- enabled: true, // will be disabled by default in 2.0
18
+ screenshot: {
19
+ enabled: true,
20
+ on: 'fail',
20
21
  },
21
22
  },
22
23
  stepTimeout: 0,
@@ -32,9 +33,27 @@ const defaultConfig = {
32
33
  ],
33
34
  }
34
35
 
36
+ // Array<{ fn: (cfg) => void, ran: boolean, error?: Error }>
35
37
  let hooks = []
36
38
  let config = {}
37
39
 
40
+ // Apply a single hook against `cfg`, swallowing errors so one broken hook
41
+ // can't take down the whole run. The failure is logged through the
42
+ // framework's own output module (when available) so it shows up in test
43
+ // reports; the hook is still marked ran so it doesn't get retried.
44
+ function applyHook(hook, cfg) {
45
+ try {
46
+ hook.fn(cfg)
47
+ } catch (err) {
48
+ hook.error = err
49
+ const out = globalThis.codeceptjs?.output
50
+ if (out && typeof out.error === 'function') out.error(`config hook failed: ${err.message}`)
51
+ else console.error('config hook failed:', err)
52
+ } finally {
53
+ hook.ran = true
54
+ }
55
+ }
56
+
38
57
  const configFileNames = ['codecept.config.js', 'codecept.conf.js', 'codecept.js', 'codecept.config.cjs', 'codecept.conf.cjs', 'codecept.config.ts', 'codecept.conf.ts']
39
58
 
40
59
  /**
@@ -49,7 +68,11 @@ class Config {
49
68
  */
50
69
  static create(newConfig) {
51
70
  config = deepMerge(deepClone(defaultConfig), newConfig)
52
- hooks.forEach(f => f(config))
71
+ // Re-apply every hook against the freshly built config; hooks added later
72
+ // (e.g. from plugin boot) stay pending until runPendingHooks. Array
73
+ // iterators re-check length on each step, so hooks pushed during a hook
74
+ // execution are visited in this same pass.
75
+ for (const hook of hooks) applyHook(hook, config)
53
76
  return config
54
77
  }
55
78
 
@@ -121,7 +144,48 @@ class Config {
121
144
  }
122
145
 
123
146
  static addHook(fn) {
124
- hooks.push(fn)
147
+ hooks.push({ fn, ran: false })
148
+ }
149
+
150
+ /**
151
+ * Run every hook that hasn't been applied to the current config yet.
152
+ * Hooks added after `Config.create()` (e.g. from plugin boot code) stay
153
+ * pending until this is called; once it runs, they're marked applied so
154
+ * subsequent calls are no-ops. Hooks added while pending hooks are running
155
+ * are picked up in the same pass (the array iterator re-checks length).
156
+ *
157
+ * Failures are logged through `output.error` and don't abort the loop —
158
+ * a broken hook can't poison the run, but its error is visible.
159
+ *
160
+ * @param {Object<string, *>} [cfg] target config (defaults to the live singleton)
161
+ * @return {boolean} true if any hook ran
162
+ */
163
+ static runPendingHooks(cfg = config) {
164
+ let ran = false
165
+ for (const hook of hooks) {
166
+ if (hook.ran) continue
167
+ applyHook(hook, cfg)
168
+ ran = true
169
+ }
170
+ return ran
171
+ }
172
+
173
+ /**
174
+ * Number of registered config hooks. Useful for snapshotting before a phase
175
+ * (e.g. plugin loading) and re-running only the hooks added during it.
176
+ * @return {number}
177
+ */
178
+ static hooksCount() {
179
+ return hooks.length
180
+ }
181
+
182
+ /**
183
+ * Run hooks in `[fromIndex, end)` against the given config object, mutating it.
184
+ * @param {number} fromIndex
185
+ * @param {Object<string, *>} cfg
186
+ */
187
+ static runHooksFrom(fromIndex, cfg) {
188
+ for (let i = fromIndex; i < hooks.length; i++) hooks[i](cfg)
125
189
  }
126
190
 
127
191
  /**
@@ -150,6 +214,15 @@ async function loadConfigFile(configFile) {
150
214
  const require = createRequire(import.meta.url)
151
215
  const extensionName = path.extname(configFile)
152
216
 
217
+ // Populate the in-process registry that packages like @codeceptjs/configure
218
+ // look up at config-import time (their proxies throw if `globalThis.codeceptjs`
219
+ // is missing). initCodeceptGlobals sets this too, but only later during
220
+ // bootstrap — config files are imported here first.
221
+ if (!globalThis.codeceptjs) {
222
+ const indexModule = await import('./index.js')
223
+ globalThis.codeceptjs = indexModule.default || indexModule
224
+ }
225
+
153
226
  // .conf.js config file
154
227
  if (extensionName === '.js' || extensionName === '.ts' || extensionName === '.cjs') {
155
228
  let configModule
package/lib/container.js CHANGED
@@ -15,6 +15,7 @@ import store from './store.js'
15
15
  import Result from './result.js'
16
16
  import ai from './ai.js'
17
17
  import actorFactory from './actor.js'
18
+ import Config from './config.js'
18
19
 
19
20
  let asyncHelperPromise
20
21
 
@@ -121,6 +122,18 @@ class Container {
121
122
  // Wait for all async helpers to finish loading and populate the actor
122
123
  await asyncHelperPromise
123
124
 
125
+ // Plugins may have registered Config hooks during their boot. Run anything
126
+ // that hasn't been applied yet and re-feed the mutated helper config to the
127
+ // already-instantiated helpers.
128
+ if (Config.runPendingHooks(config)) {
129
+ for (const name of Object.keys(container.helpers)) {
130
+ const helper = container.helpers[name]
131
+ if (helper && typeof helper._setConfig === 'function' && config.helpers && config.helpers[name]) {
132
+ helper._setConfig(config.helpers[name])
133
+ }
134
+ }
135
+ }
136
+
124
137
  if (opts && opts.ai) ai.enable(config.ai) // enable AI Assistant
125
138
  if (config.gherkin) await loadGherkinStepsAsync(config.gherkin.steps || [])
126
139
  if (opts && typeof opts.timeouts === 'boolean') store.timeouts = opts.timeouts
@@ -748,12 +761,24 @@ async function createPlugins(config, options = {}) {
748
761
  }
749
762
 
750
763
  async function loadGherkinStepsAsync(paths) {
764
+ // Import BDD module to access step file tracking functions and step DSL
765
+ const bddModule = await import('./mocha/bdd.js')
766
+
751
767
  global.Before = fn => event.dispatcher.on(event.test.started, fn)
752
768
  global.After = fn => event.dispatcher.on(event.test.finished, fn)
753
769
  global.Fail = fn => event.dispatcher.on(event.test.failed, fn)
754
770
 
755
- // Import BDD module to access step file tracking functions
756
- const bddModule = await import('./mocha/bdd.js')
771
+ // Scope-inject Given/When/Then/And while loading step files so they work
772
+ // with noGlobals: true. When noGlobals: false, globals.js has already set
773
+ // them as permanent globals — skip to avoid deleting them at the end.
774
+ const injectStepDsl = !!store.noGlobals
775
+ if (injectStepDsl) {
776
+ global.Given = bddModule.Given
777
+ global.When = bddModule.When
778
+ global.Then = bddModule.Then
779
+ global.And = bddModule.And
780
+ global.DefineParameterType = bddModule.defineParameterType
781
+ }
757
782
 
758
783
  // If gherkin.steps is string, then this will iterate through that folder and send all step def js files to loadSupportObject
759
784
  // If gherkin.steps is Array, it will go the old way
@@ -781,6 +806,13 @@ async function loadGherkinStepsAsync(paths) {
781
806
  delete global.Before
782
807
  delete global.After
783
808
  delete global.Fail
809
+ if (injectStepDsl) {
810
+ delete global.Given
811
+ delete global.When
812
+ delete global.Then
813
+ delete global.And
814
+ delete global.DefineParameterType
815
+ }
784
816
  }
785
817
 
786
818
  function loadGherkinSteps(paths) {
@@ -513,6 +513,43 @@ class WebElement {
513
513
  return simplifyHtmlElement(outerHTML, maxLength)
514
514
  }
515
515
 
516
+ /**
517
+ * Plain-object snapshot of the element — text, simplified HTML, visibility,
518
+ * enabled state, and a curated set of attributes. Each underlying call is
519
+ * isolated so a single failure (e.g. detached element) doesn't poison the
520
+ * rest. Suitable for JSON.stringify, log output, MCP tool responses.
521
+ *
522
+ * @param {object} [opts]
523
+ * @param {number} [opts.maxHtmlLength=300] passed through to toSimplifiedHTML
524
+ * @param {string[]} [opts.attrs] attribute names to surface
525
+ * @returns {Promise<{text?: string, html?: string, visible?: boolean, enabled?: boolean, attrs?: object}>}
526
+ */
527
+ async describe({ maxHtmlLength = 300, attrs = ['id', 'class', 'name', 'role', 'type', 'href', 'value', 'aria-label', 'placeholder', 'data-testid'] } = {}) {
528
+ const out = {}
529
+ await Promise.all([
530
+ this.toSimplifiedHTML(maxHtmlLength).then(v => { if (v) out.html = v }, () => {}),
531
+ this.getText().then(v => { const t = v?.trim(); if (t) out.text = t }, () => {}),
532
+ this.isVisible().then(v => { out.visible = v }, () => {}),
533
+ this.isEnabled().then(v => { out.enabled = v }, () => {}),
534
+ ])
535
+ const collected = {}
536
+ await Promise.all(attrs.map(async name => {
537
+ try {
538
+ const v = await this.getAttribute(name)
539
+ if (v != null && v !== '') collected[name] = v
540
+ } catch {}
541
+ }))
542
+ if (Object.keys(collected).length) out.attrs = collected
543
+ return out
544
+ }
545
+
546
+ // Make accidental JSON.stringify (e.g. returning a WebElement from MCP run_code)
547
+ // produce a usable hint instead of `{}` — the underlying handle isn't
548
+ // serializable. Use .describe() for the real plain-object snapshot.
549
+ toJSON() {
550
+ return `[WebElement ${this.helperType} — call .describe() for a plain-object snapshot or .toSimplifiedHTML() for HTML]`
551
+ }
552
+
516
553
  _normalizeLocator(locator) {
517
554
  if (typeof locator === 'string') {
518
555
  return locator
package/lib/globals.js CHANGED
@@ -27,9 +27,19 @@ export async function initCodeceptGlobals(dir, config, container) {
27
27
  global.codecept_dir = dir
28
28
  global.output_dir = fsPath.resolve(dir, config.output)
29
29
 
30
+ // pause/inject/share stay global even under noGlobals — they're the everyday
31
+ // debugging/wiring entry points and have no useful import alternative for
32
+ // page-object code that runs before the container is available.
33
+ global.pause = async (...args) => {
34
+ const pauseModule = await import('./pause.js')
35
+ return (pauseModule.default || pauseModule)(...args)
36
+ }
37
+ global.inject = () => container.support()
38
+ global.share = container.share
39
+
30
40
  if (config.noGlobals) return;
31
41
 
32
- output.print(output.styles.debug('Global functions are deprecated. Use `import { Helper, pause, within, session } from "codeceptjs"` instead. Set `noGlobals: true` in config to disable globals.'));
42
+ output.print(output.styles.debug('Global functions are deprecated. Use `import { Helper, within, session } from "codeceptjs"` instead. Set `noGlobals: true` in config to disable globals.'));
33
43
 
34
44
  const HelperModule = await import('@codeceptjs/helper')
35
45
  global.Helper = global.codecept_helper = HelperModule.default || HelperModule
@@ -40,12 +50,6 @@ export async function initCodeceptGlobals(dir, config, container) {
40
50
  }
41
51
  global.Actor = global.actor
42
52
 
43
- // Use dynamic imports for modules to avoid circular dependencies
44
- global.pause = async (...args) => {
45
- const pauseModule = await import('./pause.js')
46
- return (pauseModule.default || pauseModule)(...args)
47
- }
48
-
49
53
  global.within = async (...args) => {
50
54
  return (await import('./effects.js')).within(...args)
51
55
  }
@@ -62,9 +66,6 @@ export async function initCodeceptGlobals(dir, config, container) {
62
66
  return locator.build(locatorQuery)
63
67
  }
64
68
 
65
- global.inject = () => container.support()
66
- global.share = container.share
67
-
68
69
  const secretModule = await import('./secret.js')
69
70
  global.secret = secretModule.secret || (secretModule.default && secretModule.default.secret)
70
71