codeceptjs 4.0.0-rc.18 → 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 (220) hide show
  1. package/bin/codecept.js +5 -1
  2. package/bin/codeceptq.js +49 -0
  3. package/bin/mcp-server.js +250 -82
  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/command/dryRun.js +9 -3
  79. package/lib/command/init.js +247 -266
  80. package/lib/command/query.js +218 -0
  81. package/lib/config.js +9 -0
  82. package/lib/element/WebElement.js +37 -0
  83. package/lib/globals.js +11 -10
  84. package/lib/helper/Playwright.js +4 -1
  85. package/lib/html.js +3 -0
  86. package/lib/index.js +9 -1
  87. package/lib/locator.js +2 -2
  88. package/lib/mocha/factory.js +5 -1
  89. package/lib/mocha/inject.js +1 -1
  90. package/lib/parser.js +2 -2
  91. package/lib/plugin/browser.js +2 -1
  92. package/lib/plugin/expose.js +159 -0
  93. package/lib/workers.js +1 -15
  94. package/package.json +7 -5
  95. package/docs/webapi/amOnPage.mustache +0 -11
  96. package/docs/webapi/appendField.mustache +0 -16
  97. package/docs/webapi/attachFile.mustache +0 -24
  98. package/docs/webapi/blur.mustache +0 -18
  99. package/docs/webapi/checkOption.mustache +0 -13
  100. package/docs/webapi/clearCookie.mustache +0 -9
  101. package/docs/webapi/clearField.mustache +0 -14
  102. package/docs/webapi/click.mustache +0 -29
  103. package/docs/webapi/clickLink.mustache +0 -8
  104. package/docs/webapi/closeCurrentTab.mustache +0 -7
  105. package/docs/webapi/closeOtherTabs.mustache +0 -8
  106. package/docs/webapi/dontSee.mustache +0 -11
  107. package/docs/webapi/dontSeeCheckboxIsChecked.mustache +0 -10
  108. package/docs/webapi/dontSeeCookie.mustache +0 -8
  109. package/docs/webapi/dontSeeCurrentPathEquals.mustache +0 -10
  110. package/docs/webapi/dontSeeCurrentUrlEquals.mustache +0 -10
  111. package/docs/webapi/dontSeeElement.mustache +0 -12
  112. package/docs/webapi/dontSeeElementInDOM.mustache +0 -8
  113. package/docs/webapi/dontSeeInCurrentUrl.mustache +0 -4
  114. package/docs/webapi/dontSeeInField.mustache +0 -16
  115. package/docs/webapi/dontSeeInSource.mustache +0 -8
  116. package/docs/webapi/dontSeeInTitle.mustache +0 -8
  117. package/docs/webapi/dontSeeTraffic.mustache +0 -13
  118. package/docs/webapi/doubleClick.mustache +0 -13
  119. package/docs/webapi/downloadFile.mustache +0 -12
  120. package/docs/webapi/dragAndDrop.mustache +0 -9
  121. package/docs/webapi/dragSlider.mustache +0 -11
  122. package/docs/webapi/executeAsyncScript.mustache +0 -24
  123. package/docs/webapi/executeScript.mustache +0 -26
  124. package/docs/webapi/fillField.mustache +0 -21
  125. package/docs/webapi/flushNetworkTraffics.mustache +0 -5
  126. package/docs/webapi/focus.mustache +0 -13
  127. package/docs/webapi/forceClick.mustache +0 -28
  128. package/docs/webapi/forceRightClick.mustache +0 -18
  129. package/docs/webapi/grabAllWindowHandles.mustache +0 -7
  130. package/docs/webapi/grabAttributeFrom.mustache +0 -10
  131. package/docs/webapi/grabAttributeFromAll.mustache +0 -9
  132. package/docs/webapi/grabBrowserLogs.mustache +0 -9
  133. package/docs/webapi/grabCookie.mustache +0 -11
  134. package/docs/webapi/grabCssPropertyFrom.mustache +0 -11
  135. package/docs/webapi/grabCssPropertyFromAll.mustache +0 -10
  136. package/docs/webapi/grabCurrentUrl.mustache +0 -9
  137. package/docs/webapi/grabCurrentWindowHandle.mustache +0 -6
  138. package/docs/webapi/grabDataFromPerformanceTiming.mustache +0 -20
  139. package/docs/webapi/grabElementBoundingRect.mustache +0 -20
  140. package/docs/webapi/grabGeoLocation.mustache +0 -8
  141. package/docs/webapi/grabHTMLFrom.mustache +0 -10
  142. package/docs/webapi/grabHTMLFromAll.mustache +0 -9
  143. package/docs/webapi/grabNumberOfOpenTabs.mustache +0 -8
  144. package/docs/webapi/grabNumberOfVisibleElements.mustache +0 -9
  145. package/docs/webapi/grabPageScrollPosition.mustache +0 -8
  146. package/docs/webapi/grabPopupText.mustache +0 -5
  147. package/docs/webapi/grabRecordedNetworkTraffics.mustache +0 -10
  148. package/docs/webapi/grabSource.mustache +0 -8
  149. package/docs/webapi/grabTextFrom.mustache +0 -10
  150. package/docs/webapi/grabTextFromAll.mustache +0 -9
  151. package/docs/webapi/grabTitle.mustache +0 -8
  152. package/docs/webapi/grabValueFrom.mustache +0 -9
  153. package/docs/webapi/grabValueFromAll.mustache +0 -8
  154. package/docs/webapi/grabWebElement.mustache +0 -9
  155. package/docs/webapi/grabWebElements.mustache +0 -9
  156. package/docs/webapi/moveCursorTo.mustache +0 -16
  157. package/docs/webapi/openNewTab.mustache +0 -7
  158. package/docs/webapi/pressKey.mustache +0 -12
  159. package/docs/webapi/pressKeyDown.mustache +0 -12
  160. package/docs/webapi/pressKeyUp.mustache +0 -12
  161. package/docs/webapi/pressKeyWithKeyNormalization.mustache +0 -60
  162. package/docs/webapi/refreshPage.mustache +0 -6
  163. package/docs/webapi/resizeWindow.mustache +0 -6
  164. package/docs/webapi/rightClick.mustache +0 -14
  165. package/docs/webapi/saveElementScreenshot.mustache +0 -10
  166. package/docs/webapi/saveScreenshot.mustache +0 -12
  167. package/docs/webapi/say.mustache +0 -10
  168. package/docs/webapi/scrollIntoView.mustache +0 -11
  169. package/docs/webapi/scrollPageToBottom.mustache +0 -6
  170. package/docs/webapi/scrollPageToTop.mustache +0 -6
  171. package/docs/webapi/scrollTo.mustache +0 -12
  172. package/docs/webapi/see.mustache +0 -11
  173. package/docs/webapi/seeAttributesOnElements.mustache +0 -9
  174. package/docs/webapi/seeCheckboxIsChecked.mustache +0 -10
  175. package/docs/webapi/seeCookie.mustache +0 -8
  176. package/docs/webapi/seeCssPropertiesOnElements.mustache +0 -9
  177. package/docs/webapi/seeCurrentPathEquals.mustache +0 -10
  178. package/docs/webapi/seeCurrentUrlEquals.mustache +0 -11
  179. package/docs/webapi/seeElement.mustache +0 -12
  180. package/docs/webapi/seeElementInDOM.mustache +0 -8
  181. package/docs/webapi/seeFileDownloaded.mustache +0 -23
  182. package/docs/webapi/seeInCurrentUrl.mustache +0 -8
  183. package/docs/webapi/seeInField.mustache +0 -17
  184. package/docs/webapi/seeInPopup.mustache +0 -8
  185. package/docs/webapi/seeInSource.mustache +0 -7
  186. package/docs/webapi/seeInTitle.mustache +0 -8
  187. package/docs/webapi/seeNumberOfElements.mustache +0 -11
  188. package/docs/webapi/seeNumberOfVisibleElements.mustache +0 -10
  189. package/docs/webapi/seeTextEquals.mustache +0 -9
  190. package/docs/webapi/seeTitleEquals.mustache +0 -8
  191. package/docs/webapi/seeTraffic.mustache +0 -36
  192. package/docs/webapi/selectOption.mustache +0 -26
  193. package/docs/webapi/setCookie.mustache +0 -16
  194. package/docs/webapi/setGeoLocation.mustache +0 -12
  195. package/docs/webapi/startRecordingTraffic.mustache +0 -8
  196. package/docs/webapi/startRecordingWebSocketMessages.mustache +0 -8
  197. package/docs/webapi/stopRecordingTraffic.mustache +0 -5
  198. package/docs/webapi/stopRecordingWebSocketMessages.mustache +0 -7
  199. package/docs/webapi/switchTo.mustache +0 -9
  200. package/docs/webapi/switchToNextTab.mustache +0 -10
  201. package/docs/webapi/switchToPreviousTab.mustache +0 -10
  202. package/docs/webapi/type.mustache +0 -21
  203. package/docs/webapi/uncheckOption.mustache +0 -13
  204. package/docs/webapi/wait.mustache +0 -8
  205. package/docs/webapi/waitForClickable.mustache +0 -11
  206. package/docs/webapi/waitForCookie.mustache +0 -9
  207. package/docs/webapi/waitForDetached.mustache +0 -10
  208. package/docs/webapi/waitForDisabled.mustache +0 -6
  209. package/docs/webapi/waitForElement.mustache +0 -11
  210. package/docs/webapi/waitForEnabled.mustache +0 -6
  211. package/docs/webapi/waitForFunction.mustache +0 -17
  212. package/docs/webapi/waitForInvisible.mustache +0 -10
  213. package/docs/webapi/waitForNumberOfTabs.mustache +0 -9
  214. package/docs/webapi/waitForText.mustache +0 -13
  215. package/docs/webapi/waitForValue.mustache +0 -10
  216. package/docs/webapi/waitForVisible.mustache +0 -10
  217. package/docs/webapi/waitInUrl.mustache +0 -9
  218. package/docs/webapi/waitNumberOfVisibleElements.mustache +0 -10
  219. package/docs/webapi/waitToHide.mustache +0 -10
  220. package/docs/webapi/waitUrlEquals.mustache +0 -10
@@ -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
@@ -214,6 +214,15 @@ async function loadConfigFile(configFile) {
214
214
  const require = createRequire(import.meta.url)
215
215
  const extensionName = path.extname(configFile)
216
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
+
217
226
  // .conf.js config file
218
227
  if (extensionName === '.js' || extensionName === '.ts' || extensionName === '.cjs') {
219
228
  let configModule
@@ -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
 
@@ -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])
package/lib/html.js CHANGED
@@ -323,6 +323,9 @@ async function formatHtml(html) {
323
323
  wrap_line_length: 0,
324
324
  preserve_newlines: false,
325
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: [],
326
329
  })
327
330
  } catch (e) {
328
331
  return processed
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]
@@ -31,7 +31,8 @@ import output from '../output.js'
31
31
  * logs a hint and skips the override.
32
32
  */
33
33
  export default async function (config = {}) {
34
- const opts = parseArgs(config._args || [])
34
+ const { _args, enabled, ...rest } = config
35
+ const opts = { ...rest, ...parseArgs(_args || []) }
35
36
  if (Object.keys(opts).length === 0) return
36
37
 
37
38
  const configure = await tryImportConfigure()
@@ -0,0 +1,159 @@
1
+ import Container from '../container.js'
2
+
3
+ const RESERVED_NAMES = new Set(['I', 'test', 'suite'])
4
+ const SHORTHAND_PROPERTIES = new Set(['page', 'browser', 'browserContext', 'context'])
5
+
6
+ const defaultConfig = {
7
+ inject: {},
8
+ }
9
+
10
+ /**
11
+ * Exposes properties from helper instances as injectable test arguments.
12
+ * Use it to access the underlying Playwright/Puppeteer `page`, the wdio `browser` client,
13
+ * or any other helper internal directly from a Scenario:
14
+ *
15
+ * ```js
16
+ * Scenario('listen for requests', async ({ I, page, browser }) => {
17
+ * page.on('request', r => console.log(r.url()))
18
+ * await page.evaluate(() => 1 + 1)
19
+ * I.amOnPage('/')
20
+ * })
21
+ * ```
22
+ *
23
+ * The injected value is a live proxy: every property access reads the *current*
24
+ * helper property, so mid-test reassignments (popups, `switchToNextTab`,
25
+ * `openNewTab`) are reflected automatically. Calls are not wrapped as
26
+ * CodeceptJS steps — `await page.evaluate(...)` runs as native Playwright.
27
+ *
28
+ * #### Configuration
29
+ *
30
+ * `inject` maps an injection name to a `HelperName.propertyName` string. A
31
+ * value with no dot is shorthand for "first configured browser helper that
32
+ * exposes this property" (allowed properties: `page`, `browser`,
33
+ * `browserContext`, `context`).
34
+ *
35
+ * ```js
36
+ * plugins: {
37
+ * expose: {
38
+ * enabled: true,
39
+ * inject: {
40
+ * page: 'Playwright.page',
41
+ * browser: 'Playwright.browser',
42
+ * browserContext: 'Playwright.browserContext',
43
+ * frame: 'Playwright.context', // current frame set by switchTo
44
+ * wdio: 'WebDriver.browser',
45
+ * }
46
+ * }
47
+ * }
48
+ * ```
49
+ *
50
+ * Shorthand:
51
+ *
52
+ * ```js
53
+ * plugins: {
54
+ * expose: {
55
+ * enabled: true,
56
+ * inject: {
57
+ * page: 'page', // resolves to Playwright.page or Puppeteer.page
58
+ * }
59
+ * }
60
+ * }
61
+ * ```
62
+ *
63
+ * #### Caveats
64
+ *
65
+ * - The injected value is a `Proxy`, not the actual `Page`/`Browser` instance,
66
+ * so `page instanceof Page` is `false`. Use duck typing instead.
67
+ * - Cached method references lose the live binding. Call `page.click(...)`,
68
+ * not `const click = page.click; click(...)`.
69
+ * - In dry-run mode the underlying helper property is `undefined`; accessing
70
+ * any property on the proxy returns `undefined` rather than throwing.
71
+ */
72
+ export default function (config = {}) {
73
+ config = { ...defaultConfig, ...config }
74
+
75
+ const mappings = parseMappings(config.inject)
76
+
77
+ const support = {}
78
+ for (const [name, { helperName, property }] of Object.entries(mappings)) {
79
+ support[name] = makeLiveProxy(helperName, property)
80
+ }
81
+ Container.append({ support })
82
+ }
83
+
84
+ function parseMappings(inject) {
85
+ const out = {}
86
+ for (const [name, value] of Object.entries(inject || {})) {
87
+ if (RESERVED_NAMES.has(name)) {
88
+ throw new Error(`expose plugin: inject name '${name}' is reserved`)
89
+ }
90
+ if (typeof value !== 'string' || !value) {
91
+ throw new Error(`expose plugin: inject value for '${name}' must be a non-empty string`)
92
+ }
93
+
94
+ let helperName
95
+ let property
96
+
97
+ if (value.includes('.')) {
98
+ const dot = value.indexOf('.')
99
+ helperName = value.slice(0, dot)
100
+ property = value.slice(dot + 1)
101
+ if (!helperName || !property) {
102
+ throw new Error(`expose plugin: invalid inject value '${value}' for '${name}' (expected 'HelperName.propertyName')`)
103
+ }
104
+ if (!Container.helpers(helperName)) {
105
+ throw new Error(`expose plugin: helper '${helperName}' is not configured (needed for inject '${name}')`)
106
+ }
107
+ } else {
108
+ property = value
109
+ if (!SHORTHAND_PROPERTIES.has(property)) {
110
+ throw new Error(`expose plugin: shorthand '${property}' is not a known helper property for '${name}' (use 'HelperName.${property}' instead)`)
111
+ }
112
+ helperName = Container.STANDARD_ACTING_HELPERS.find(h => Container.helpers(h))
113
+ if (!helperName) {
114
+ throw new Error(`expose plugin: no standard browser helper configured (needed for inject '${name}')`)
115
+ }
116
+ }
117
+
118
+ out[name] = { helperName, property }
119
+ }
120
+ return out
121
+ }
122
+
123
+ function makeLiveProxy(helperName, property) {
124
+ const resolve = () => Container.helpers(helperName)?.[property]
125
+ return new Proxy(function () {}, {
126
+ get(_, prop) {
127
+ const target = resolve()
128
+ if (target == null) return undefined
129
+ const value = target[prop]
130
+ if (typeof value === 'function') return value.bind(target)
131
+ return value
132
+ },
133
+ has(_, prop) {
134
+ const target = resolve()
135
+ return target != null && prop in target
136
+ },
137
+ apply(_, thisArg, args) {
138
+ const target = resolve()
139
+ return target?.apply(thisArg, args)
140
+ },
141
+ set(_, prop, value) {
142
+ const target = resolve()
143
+ if (target != null) target[prop] = value
144
+ return true
145
+ },
146
+ getPrototypeOf() {
147
+ const target = resolve()
148
+ return target != null ? Object.getPrototypeOf(target) : null
149
+ },
150
+ ownKeys() {
151
+ const target = resolve()
152
+ return target != null ? Reflect.ownKeys(target) : []
153
+ },
154
+ getOwnPropertyDescriptor(_, prop) {
155
+ const target = resolve()
156
+ return target != null ? Object.getOwnPropertyDescriptor(target, prop) : undefined
157
+ },
158
+ })
159
+ }
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeceptjs",
3
- "version": "4.0.0-rc.18",
3
+ "version": "4.0.0-rc.19",
4
4
  "type": "module",
5
5
  "description": "Supercharged End 2 End Testing Framework for NodeJS",
6
6
  "keywords": [
@@ -26,7 +26,8 @@
26
26
  "lib",
27
27
  "translations",
28
28
  "typings/**/*.d.ts",
29
- "docs/webapi/**"
29
+ "docs/*.md",
30
+ "docs/helpers/**"
30
31
  ],
31
32
  "main": "lib/index.js",
32
33
  "module": "lib/index.js",
@@ -46,7 +47,8 @@
46
47
  },
47
48
  "bin": {
48
49
  "codeceptjs": "./bin/codecept.js",
49
- "codeceptjs-mcp": "./bin/mcp-server.js"
50
+ "codeceptjs-mcp": "./bin/mcp-server.js",
51
+ "codeceptq": "./bin/codeceptq.js"
50
52
  },
51
53
  "repository": "codeceptjs/CodeceptJS",
52
54
  "scripts": {
@@ -131,6 +133,7 @@
131
133
  "resq": "1.11.0",
132
134
  "sprintf-js": "1.1.3",
133
135
  "uuid": "11.1.0",
136
+ "xpath": "0.0.34",
134
137
  "zod": "^4.1.11"
135
138
  },
136
139
  "optionalDependencies": {
@@ -192,8 +195,7 @@
192
195
  "typescript": "5.9.3",
193
196
  "wdio-docker-service": "3.2.1",
194
197
  "webdriverio": "9.23.0",
195
- "xml2js": "0.6.2",
196
- "xpath": "0.0.34"
198
+ "xml2js": "0.6.2"
197
199
  },
198
200
  "peerDependencies": {
199
201
  "tsx": "^4.0.0"