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
package/bin/mcp-server.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
@@ -17,6 +18,11 @@ import {
17
18
  } from '../lib/utils/trace.js'
18
19
  import event from '../lib/event.js'
19
20
  import recorder from '../lib/recorder.js'
21
+ import WebElement from '../lib/element/WebElement.js'
22
+ import { locate, within, session, secret, inject, pause } from '../lib/index.js'
23
+ import { tryTo, retryTo, hopeThat } from '../lib/effects.js'
24
+ import step from '../lib/steps.js'
25
+ import { element, eachElement, expectElement, expectAnyElement, expectAllElements } from '../lib/els.js'
20
26
  import { setPauseHandler, pauseNow } from '../lib/pause.js'
21
27
  import { EventEmitter } from 'events'
22
28
  import { fileURLToPath, pathToFileURL } from 'url'
@@ -39,6 +45,7 @@ let shellSessionActive = false
39
45
  let bootstrapDone = false
40
46
  let currentPluginsSig = ''
41
47
  let currentAiTraceDir = null // mirrors the dir aiTrace plugin computes per test/session
48
+ let aiTraceEnabled = false // tracked across the session so tool responses can surface a hint when off
42
49
 
43
50
  event.dispatcher.on(event.test.before, test => {
44
51
  try {
@@ -47,7 +54,27 @@ event.dispatcher.on(event.test.before, test => {
47
54
  } catch {}
48
55
  })
49
56
 
50
- const SESSION_REQUIRED_ERROR = 'No active CodeceptJS session. Call `start_browser` to open a shell session, or `run_test` (use `pause()` in the test, or set `pauseAt`) to inspect during a test run.'
57
+ function aiTraceHint() {
58
+ if (aiTraceEnabled) return undefined
59
+ return 'aiTrace plugin is disabled — re-run start_browser with plugins={ aiTrace: { enabled: true } } to capture per-step DOM/ARIA/console traces for debugging.'
60
+ }
61
+
62
+ function applyMochaGrep(grep) {
63
+ if (!grep) return
64
+ const mocha = typeof container.mocha === 'function' ? container.mocha() : container.mocha
65
+ if (mocha && typeof mocha.grep === 'function') mocha.grep(grep)
66
+ }
67
+
68
+ function pauseAtMatcher(pauseAt) {
69
+ if (pauseAt == null) return () => false
70
+ if (typeof pauseAt === 'number') return (idx) => idx === pauseAt
71
+ if (typeof pauseAt === 'string') {
72
+ const m = pauseAt.match(/^\/(.+)\/([gimsuy]*)$/)
73
+ const re = m ? new RegExp(m[1], m[2]) : new RegExp(pauseAt.replace(/[.+?^${}()|[\]\\]/g, '\\$&'), 'i')
74
+ return (_idx, name) => re.test(name)
75
+ }
76
+ return () => false
77
+ }
51
78
 
52
79
  async function ensureBootstrap() {
53
80
  if (bootstrapDone) return
@@ -80,9 +107,9 @@ async function endShellSession() {
80
107
  shellSessionActive = false
81
108
  }
82
109
 
83
- function ensureSession() {
110
+ async function ensureSession() {
84
111
  if (shellSessionActive || pausedController) return
85
- throw new Error(SESSION_REQUIRED_ERROR)
112
+ await startShellSession()
86
113
  }
87
114
 
88
115
  function normalizePluginOverrides(plugins) {
@@ -109,18 +136,33 @@ function pluginsSignature(plugins) {
109
136
 
110
137
  async function teardownContainer() {
111
138
  if (!containerInitialized) return
112
- await endShellSession()
113
- const helpers = container.helpers()
114
- for (const helperName in helpers) {
115
- const helper = helpers[helperName]
116
- try { if (helper._finish) await helper._finish() } catch {}
139
+ try {
140
+ await closeBrowser()
141
+ try { if (codecept?.teardown) await codecept.teardown() } catch {}
142
+ } finally {
143
+ containerInitialized = false
144
+ browserStarted = false
145
+ bootstrapDone = false
146
+ aiTraceEnabled = false
147
+ codecept = null
148
+ currentPluginsSig = ''
117
149
  }
118
- try { if (codecept?.teardown) await codecept.teardown() } catch {}
119
- containerInitialized = false
120
- browserStarted = false
121
- bootstrapDone = false
122
- codecept = null
123
- currentPluginsSig = ''
150
+ }
151
+
152
+ let shutdownStarted = false
153
+ function installShutdownHooks() {
154
+ const onSignal = (signal) => {
155
+ if (shutdownStarted) return
156
+ shutdownStarted = true
157
+ teardownContainer().finally(() => process.exit(signal === 'SIGINT' ? 130 : 0))
158
+ }
159
+ process.on('SIGTERM', () => onSignal('SIGTERM'))
160
+ process.on('SIGINT', () => onSignal('SIGINT'))
161
+ process.on('beforeExit', () => {
162
+ if (shutdownStarted) return
163
+ shutdownStarted = true
164
+ teardownContainer().catch(() => {})
165
+ })
124
166
  }
125
167
 
126
168
  let runLock = Promise.resolve()
@@ -331,15 +373,17 @@ function outputBaseDir() {
331
373
  // pause(), the handler registered via setPauseHandler resolves a "paused"
332
374
  // promise that run_test is racing against test completion. The "pause" tool
333
375
  // then drives the REPL by mutating next/abort and resolving the controller.
334
- let pausedController = null // { resolveContinue, registeredVariables }
335
- let pendingRunPromise = null // run_test's run() promise while paused
336
- let pendingRunResults = null // results array being collected while paused
337
- let pendingRunCleanup = null // cleanup callback to detach test.after / step.after listeners
338
- let pendingTestFile = null // file path of the test currently running
339
- let pendingStepInfo = null // { index, name, status } of the last step that fired step.after
376
+ let pausedController = null
377
+ let pendingRunPromise = null
378
+ let pendingRunResults = null
379
+ let pendingRunCleanup = null
380
+ let pendingTestFile = null
381
+ let pendingStepInfo = null
382
+ let abortRun = false
340
383
  const pauseEvents = new EventEmitter()
341
384
 
342
385
  setPauseHandler(({ registeredVariables }) => {
386
+ if (abortRun) return Promise.reject(new Error('MCP session aborted'))
343
387
  return new Promise(resolve => {
344
388
  pausedController = {
345
389
  registeredVariables,
@@ -352,6 +396,33 @@ setPauseHandler(({ registeredVariables }) => {
352
396
  })
353
397
  })
354
398
 
399
+ async function cancelRun() {
400
+ if (!pendingRunPromise && !pausedController) return false
401
+ abortRun = true
402
+ if (typeof pendingRunCleanup === 'function') { try { pendingRunCleanup() } catch {} }
403
+ if (pausedController) { try { pausedController.resolveContinue() } catch {} ; pausedController = null }
404
+ if (pendingRunPromise) {
405
+ try { await Promise.race([pendingRunPromise.catch(() => {}), new Promise(r => setTimeout(r, 5000))]) } catch {}
406
+ }
407
+ pendingRunPromise = null
408
+ pendingRunResults = null
409
+ pendingTestFile = null
410
+ pendingStepInfo = null
411
+ abortRun = false
412
+ return true
413
+ }
414
+
415
+ async function closeBrowser() {
416
+ if (!containerInitialized) return
417
+ await cancelRun()
418
+ await endShellSession()
419
+ for (const helper of Object.values(container.helpers() || {})) {
420
+ try { if (helper._cleanup) await helper._cleanup() } catch {}
421
+ try { if (helper._finishTest) await helper._finishTest() } catch {}
422
+ }
423
+ browserStarted = false
424
+ }
425
+
355
426
  async function captureLiveArtifacts(prefix = 'pause') {
356
427
  const helper = pickActingHelper(container.helpers())
357
428
  if (!helper) return {}
@@ -388,10 +459,16 @@ function collectRunCompletion(errorMessage) {
388
459
  pendingRunResults = null
389
460
  pendingTestFile = null
390
461
  pendingStepInfo = null
462
+ let error = errorMessage || null
463
+ if (!error && results.length === 0) {
464
+ error = 'No tests ran and no error was reported. The Mocha instance may have been disposed (set mocha.cleanReferencesAfterRun=false in config) or the test file matched no scenarios.'
465
+ }
391
466
  return {
392
- status: 'completed',
467
+ status: error ? 'failed' : 'completed',
468
+ aiTraceDir: currentAiTraceDir,
393
469
  reporterJson: { stats, tests: results },
394
- error: errorMessage,
470
+ error,
471
+ aiTraceHint: aiTraceHint(),
395
472
  }
396
473
  }
397
474
 
@@ -399,11 +476,13 @@ function pausedPayload() {
399
476
  return {
400
477
  status: 'paused',
401
478
  file: pendingTestFile,
479
+ aiTraceDir: currentAiTraceDir,
402
480
  pausedAfter: pendingStepInfo,
403
481
  suggestions: [
404
482
  'Call snapshot to capture URL/HTML/ARIA/screenshot/console/storage at this point',
405
483
  'Call run_code to inspect or manipulate state (e.g. return await I.grabText("h1"))',
406
484
  'Call continue to release the pause and let the test run the next step (or finish)',
485
+ 'Query a saved step snapshot offline: codeceptq <locator> --file <aiTraceDir>/<NNNN>_<step>_page.html',
407
486
  ],
408
487
  }
409
488
  }
@@ -443,98 +522,133 @@ async function initCodecept(configPath, pluginOverrides) {
443
522
  // aiTrace is the canonical per-step ARIA/HTML/screenshot capture for MCP.
444
523
  // Always on so run_code / continue can read the latest snapshot from disk
445
524
  // instead of double-capturing through grabAriaSnapshot etc.
446
- applyPluginOverrides(config, { aiTrace: {}, ...plugins })
525
+ applyPluginOverrides(config, { aiTrace: { on: 'step' }, browser: { show: false }, ...plugins })
447
526
 
448
527
  codecept = new Codecept(config, {})
449
528
  await codecept.init(testRoot)
450
- await container.create(config, {})
451
529
  await container.started()
452
530
 
453
531
  containerInitialized = true
454
532
  browserStarted = true
533
+ aiTraceEnabled = config.plugins?.aiTrace?.enabled === true
455
534
  currentPluginsSig = sig
456
535
  }
457
536
 
458
- const PLUGINS_DESCRIPTION = 'Enable CodeceptJS plugins for this run, mirroring the CLI `-p` flag. Keys are plugin names (e.g. screencast, aiTrace, pause, pageInfo, heal, retryFailedStep, screenshotOnFail, autoDelay). Value `true` or `{}` enables with defaults; an object merges options, e.g. {"screencast": {"saveScreenshots": true}, "aiTrace": {"on": "fail"}}. Changing the plugin set tears down and re-initializes the container (closes the browser).'
537
+ async function formatReturnValue(value) {
538
+ if (value instanceof WebElement) return await value.describe()
539
+ if (Array.isArray(value) && value.length && value.every(v => v instanceof WebElement)) {
540
+ return await Promise.all(value.map(v => v.describe()))
541
+ }
542
+ return value
543
+ }
459
544
 
460
545
  const server = new Server(
461
546
  { name: 'codeceptjs-mcp-server', version: '1.0.0' },
462
547
  { capabilities: { tools: {} } }
463
548
  )
464
549
 
550
+ const PLUGINS_PROP = {
551
+ type: 'object',
552
+ description: 'Plugin configs to enable for this session, keyed by plugin name. Same shape as `plugins` in codecept.conf.js — each value is the plugin\'s config object (`enabled: true` is added automatically). Common entries:\n' +
553
+ ' • { browser: { show: true } } — visible browser (headed)\n' +
554
+ ' • { browser: { show: false } } — headless\n' +
555
+ ' • { browser: { browser: "firefox", windowSize: "1280x720" } } — switch browser + viewport\n' +
556
+ ' • { pause: { on: "fail" } } / { screenshot: { on: "step" } } / { aiTrace: {} }\n' +
557
+ 'Override or add to whatever the project config already enables.',
558
+ additionalProperties: { type: 'object' },
559
+ }
560
+
561
+ const CONFIG_PROP = {
562
+ type: 'string',
563
+ description: 'Path to codecept.conf.js (or .cjs). Defaults to $CODECEPTJS_CONFIG, then ./codecept.conf.js in $CODECEPTJS_PROJECT_DIR or cwd. Only needed for projects with a non-standard config location.',
564
+ }
565
+
465
566
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
466
567
  tools: [
467
568
  {
468
569
  name: 'list_tests',
469
- description: 'List all tests in the CodeceptJS project',
470
- inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
570
+ description: 'List all tests in the CodeceptJS project. Uses the active session if start_browser was called, otherwise auto-inits with project defaults.',
571
+ inputSchema: { type: 'object', properties: {} },
471
572
  },
472
573
  {
473
574
  name: 'list_actions',
474
- description: 'List all available CodeceptJS actions (I.* methods)',
475
- inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
575
+ description: 'List all available CodeceptJS actions (I.* methods). Uses the active session if start_browser was called, otherwise auto-inits with project defaults.',
576
+ inputSchema: { type: 'object', properties: {} },
476
577
  },
477
578
  {
478
579
  name: 'run_code',
479
- description: 'Run arbitrary CodeceptJS code.',
580
+ description: 'Run arbitrary CodeceptJS code. Response includes `availableObjects` listing every symbol in scope (I, helpers, container, step, tryTo, within, etc.).',
480
581
  inputSchema: {
481
582
  type: 'object',
482
583
  properties: {
483
584
  code: { type: 'string' },
484
585
  timeout: { type: 'number' },
485
- config: { type: 'string' },
486
586
  saveArtifacts: { type: 'boolean' },
587
+ settleMs: { type: 'number', description: 'Wait N ms after the code finishes before capturing artifacts. Default 300. Set higher (1000+) when actions trigger slow re-renders, or 0 to skip.' },
487
588
  },
488
589
  required: ['code'],
489
590
  },
490
591
  },
491
592
  {
492
593
  name: 'run_test',
493
- description: 'Run a specific test. If the test calls pause() — or if pauseAt is set and reached — returns early with status "paused" so the agent can inspect via run_code and release with continue. Otherwise returns the json reporter result on completion. To learn step indices for pauseAt, run "list" with --steps or call run_step_by_step first.',
594
+ description: 'Run a specific test. Returns reporter JSON with one entry per scenario; each entry has a `traceFile` (file:// URL) pointing to the aiTrace markdown for that scenario — Read it on failures to see the failing step\'s DOM/ARIA/screenshot. If aiTrace is disabled the response includes an `aiTraceHint`. If the test calls pause() — or if pauseAt is set and reached — returns early with status "paused" so the agent can inspect via run_code and release with continue. To learn step indices for pauseAt, call run_step_by_step first. Auto-inits with project defaults if no session is active call start_browser first to customize launch (e.g. plugins={ browser: { show: true } } to watch the run).',
494
595
  inputSchema: {
495
596
  type: 'object',
496
597
  properties: {
497
598
  test: { type: 'string' },
498
599
  timeout: { type: 'number' },
499
- config: { type: 'string' },
500
- pauseAt: { type: 'number', description: '1-based step index. Test will pause after the Nth step completes. Useful as a programmatic breakpoint without editing the test.' },
501
- plugins: { type: 'object', description: PLUGINS_DESCRIPTION, additionalProperties: true },
600
+ grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
601
+ pauseAt: {
602
+ description: 'Programmatic breakpoint. Either a 1-based step index (number) or a step-name match (string — substring case-insensitive, or `/regex/i` literal). Examples: 5 / "fill field" / "/grab.*url/i".',
603
+ oneOf: [{ type: 'number' }, { type: 'string' }],
604
+ },
605
+ plugins: PLUGINS_PROP,
502
606
  },
503
607
  required: ['test'],
504
608
  },
505
609
  },
506
610
  {
507
611
  name: 'run_step_by_step',
508
- description: 'Run a test interactively, pausing after every step. Returns paused payload after the first step (URL/title/contentSize, last step info, suggestions). Call continue to advance one step (and re-pause), or run_code/snapshot to inspect state. The test runs to completion when no more steps remain.',
612
+ description: 'Run a test interactively, pausing after every step. Returns paused payload after the first step (URL/title/contentSize, last step info, suggestions). Call continue to advance one step (and re-pause), or run_code/snapshot to inspect state. On completion each scenario in `reporterJson.tests[]` has a `traceFile` (file:// URL) for the per-step aiTrace markdown — Read it for the full execution log. Much more useful when start_browser was called with plugins={ browser: { show: true } } so you can watch what happens between pauses.',
509
613
  inputSchema: {
510
614
  type: 'object',
511
615
  properties: {
512
616
  test: { type: 'string' },
513
617
  timeout: { type: 'number' },
514
- config: { type: 'string' },
515
- plugins: { type: 'object', description: PLUGINS_DESCRIPTION, additionalProperties: true },
618
+ grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
619
+ plugins: PLUGINS_PROP,
516
620
  },
517
621
  required: ['test'],
518
622
  },
519
623
  },
520
624
  {
521
625
  name: 'start_browser',
522
- description: 'Start the browser session.',
523
- inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
626
+ description: 'Start the session — initializes the codeceptjs container, loads helpers, and applies any plugin overrides. This is the only tool that customizes initialization; every other tool either uses the active session or auto-inits with project defaults.\n\n' +
627
+ 'MCP enforces two plugin defaults so the agent gets useful telemetry:\n' +
628
+ ' • aiTrace: { on: "step", enabled: true } — per-step DOM/ARIA/console/screenshot traces for debugging\n' +
629
+ ' • browser: { show: false, enabled: true } — headless by default\n' +
630
+ 'Both can be overridden via the `plugins` arg. To watch the run live: plugins={ browser: { show: true } }. To skip per-step trace overhead on a re-run: plugins={ aiTrace: { enabled: false } } (or { on: "fail" } to only capture failures). To switch config or plugins mid-session, call stop_browser first.',
631
+ inputSchema: {
632
+ type: 'object',
633
+ properties: {
634
+ config: CONFIG_PROP,
635
+ plugins: PLUGINS_PROP,
636
+ },
637
+ },
524
638
  },
525
639
  {
526
640
  name: 'stop_browser',
527
- description: 'Stop the browser session.',
641
+ description: 'Stop the session, close browsers, and tear down the container. Required before re-initing with different config or plugins.',
528
642
  inputSchema: { type: 'object', properties: {} },
529
643
  },
530
644
  {
531
645
  name: 'snapshot',
532
- description: 'Capture current browser state (HTML, ARIA, screenshot, console, URL) without performing any action.',
646
+ description: 'Capture current browser state (HTML, ARIA, screenshot, console, URL) without performing any action. Returns `traceFile` (file:// URL) to a markdown trace bundling the captured artifacts — Read it for full context. Auto-inits with project defaults if no session is active.',
533
647
  inputSchema: {
534
648
  type: 'object',
535
649
  properties: {
536
- config: { type: 'string' },
537
650
  fullPage: { type: 'boolean' },
651
+ settleMs: { type: 'number', description: 'Wait N ms before capturing. Default 300. Set higher when the previous action is still re-rendering, or 0 to skip.' },
538
652
  },
539
653
  },
540
654
  },
@@ -548,6 +662,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
548
662
  },
549
663
  },
550
664
  },
665
+ {
666
+ name: 'cancel',
667
+ description: 'Abort the currently paused or in-progress test run without closing the browser. Use when you want to bail out of a paused test and start something else without going through stop_browser/start_browser. The browser session and Mocha state stay alive.',
668
+ inputSchema: { type: 'object', properties: {} },
669
+ },
551
670
  ],
552
671
  }))
553
672
 
@@ -557,8 +676,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
557
676
  try {
558
677
  switch (name) {
559
678
  case 'list_tests': {
560
- const configPath = args?.config
561
- await initCodecept(configPath)
679
+ await initCodecept()
562
680
 
563
681
  codecept.loadTests()
564
682
  const tests = codecept.testFiles.map(testFile => {
@@ -573,8 +691,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
573
691
  }
574
692
 
575
693
  case 'list_actions': {
576
- const configPath = args?.config
577
- await initCodecept(configPath)
694
+ await initCodecept()
578
695
 
579
696
  const helpers = container.helpers()
580
697
  const supportI = container.support('I')
@@ -602,27 +719,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
602
719
  }
603
720
 
604
721
  case 'start_browser': {
605
- const configPath = args?.config
722
+ const { config: configPath, plugins } = args || {}
606
723
  if (browserStarted && shellSessionActive) {
607
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session already active' }, null, 2) }] }
724
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session already active', plugins: plugins ?? null }, null, 2) }] }
725
+ }
726
+ await initCodecept(configPath, plugins)
727
+ if (containerInitialized && !browserStarted) {
728
+ for (const helper of Object.values(container.helpers() || {})) {
729
+ try { if (helper._beforeSuite) await helper._beforeSuite() } catch {}
730
+ }
731
+ browserStarted = true
608
732
  }
609
- await initCodecept(configPath)
610
733
  await startShellSession()
611
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session started — run_code and snapshot are now available' }, null, 2) }] }
734
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session started — run_code and snapshot are now available', plugins: plugins ?? null }, null, 2) }] }
612
735
  }
613
736
 
614
737
  case 'stop_browser': {
615
738
  if (!containerInitialized) {
616
739
  return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser not initialized' }, null, 2) }] }
617
740
  }
618
- await teardownContainer()
619
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped successfully' }, null, 2) }] }
741
+ await closeBrowser()
742
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped — Mocha and config preserved; call start_browser to reopen' }, null, 2) }] }
620
743
  }
621
744
 
622
745
  case 'snapshot': {
623
- const { config: configPath, fullPage = false } = args || {}
624
- await initCodecept(configPath)
625
- ensureSession()
746
+ const { fullPage = false, settleMs = 300 } = args || {}
747
+ await initCodecept()
748
+ await ensureSession()
626
749
 
627
750
  const helper = pickActingHelper(container.helpers())
628
751
  if (!helper) throw new Error('No supported acting helper available (Playwright, Puppeteer, WebDriver).')
@@ -630,6 +753,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
630
753
  const dir = snapshotDirFor(outputBaseDir())
631
754
  mkdirp.sync(dir)
632
755
 
756
+ if (settleMs > 0) await new Promise(r => setTimeout(r, settleMs))
633
757
  const captured = await captureSnapshot(helper, { dir, prefix: 'snapshot', fullPage })
634
758
  const traceFile = writeTraceMarkdown({
635
759
  dir,
@@ -648,6 +772,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
648
772
  dir,
649
773
  traceFile: pathToFileURL(traceFile).href,
650
774
  artifacts: artifactsToFileUrls(captured, dir),
775
+ aiTraceHint: aiTraceHint(),
651
776
  }, null, 2),
652
777
  }],
653
778
  }
@@ -684,21 +809,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
684
809
  })
685
810
  }
686
811
 
812
+ case 'cancel': {
813
+ const cancelled = await cancelRun()
814
+ await ensureSession()
815
+ return { content: [{ type: 'text', text: JSON.stringify({ status: cancelled ? 'Run cancelled — browser kept open' : 'No run in progress' }, null, 2) }] }
816
+ }
817
+
687
818
  case 'run_code': {
688
- const { code, timeout = 60000, config: configPath, saveArtifacts = true } = args
689
- await initCodecept(configPath)
690
- ensureSession()
819
+ const { code, timeout = 60000, saveArtifacts = true, settleMs = 300 } = args
820
+ await initCodecept()
821
+ await ensureSession()
691
822
 
692
- const I = container.support('I')
693
- if (!I) throw new Error('I object not available. Make sure helpers are configured.')
823
+ const support = container.supportObjects() || {}
824
+ if (!support.I) throw new Error('I object not available. Make sure helpers are configured.')
694
825
 
695
826
  const result = { status: 'unknown', output: '', error: null, commands: [], artifacts: {} }
696
827
 
697
828
  const commands = []
829
+ let lastStepValue
698
830
  const onStepAfter = step => {
699
831
  try { commands.push(step.toString()) } catch {}
700
832
  }
833
+ const onStepPassed = (step, val) => {
834
+ if (val !== undefined) lastStepValue = val
835
+ }
701
836
  event.dispatcher.on(event.step.after, onStepAfter)
837
+ event.dispatcher.on(event.step.passed, onStepPassed)
702
838
 
703
839
  const traceDir = traceDirFor(`mcp_${Date.now()}`, 'run_code', outputBaseDir())
704
840
  mkdirp.sync(traceDir)
@@ -728,13 +864,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
728
864
  console[m] = captureLog(m)
729
865
  }
730
866
 
867
+ const scope = {
868
+ locate, within, session, secret, inject, pause, share: container.share,
869
+ tryTo, retryTo, hopeThat,
870
+ step, element, eachElement, expectElement, expectAnyElement, expectAllElements,
871
+ container, helpers: container.helpers(),
872
+ ...support,
873
+ }
874
+ const paramNames = ['I', ...Object.keys(scope).filter(k => k !== 'I').sort()]
875
+ const paramValues = paramNames.map(k => scope[k])
876
+
877
+ const wasPaused = !!pausedController
878
+ if (wasPaused) recorder.session.start('mcp_run_code')
879
+
731
880
  let returnValue
732
881
  try {
733
- const asyncFn = new Function('I', `return (async () => { ${code} })()`)
882
+ const asyncFn = new Function(...paramNames, `return (async () => { ${code} })()`)
734
883
  returnValue = await Promise.race([
735
- asyncFn(I),
884
+ asyncFn(...paramValues),
736
885
  new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
737
886
  ])
887
+ await recorder.promise()
738
888
 
739
889
  result.status = 'success'
740
890
  result.output = 'Code executed successfully'
@@ -745,11 +895,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
745
895
  } finally {
746
896
  for (const m of consoleMethods) console[m] = origConsoleMethods[m]
747
897
  try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
898
+ try { event.dispatcher.removeListener(event.step.passed, onStepPassed) } catch {}
899
+ if (wasPaused) {
900
+ try { recorder.session.restore('mcp_run_code') } catch {}
901
+ } else {
902
+ try { recorder.reset() } catch {}
903
+ }
748
904
  }
749
905
 
750
906
  result.commands = commands
751
907
  result.logs = consoleLogs
752
908
  if (consoleLogs.length === MAX_LOG_ENTRIES) result.logsTruncated = true
909
+ result.availableObjects = paramNames
910
+
911
+ if (returnValue === undefined) returnValue = await Promise.resolve(lastStepValue)
912
+ returnValue = await formatReturnValue(returnValue)
753
913
 
754
914
  if (returnValue !== undefined) {
755
915
  const json = typeof returnValue === 'string' ? returnValue : safeStringify(returnValue, [], 2)
@@ -763,6 +923,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
763
923
  const helper = pickActingHelper(container.helpers())
764
924
  if (helper) {
765
925
  try {
926
+ if (settleMs > 0) await new Promise(r => setTimeout(r, settleMs))
766
927
  captured = await captureSnapshot(helper, { dir: traceDir, prefix: 'mcp' })
767
928
  result.artifacts = artifactsToFileUrls(captured, traceDir)
768
929
  } catch (e) {
@@ -790,6 +951,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
790
951
  })
791
952
  result.dir = traceDir
792
953
  result.traceFile = pathToFileURL(traceFile).href
954
+ result.aiTraceHint = aiTraceHint()
793
955
 
794
956
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
795
957
  }
@@ -799,9 +961,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
799
961
  if (pausedController) {
800
962
  throw new Error('A previous run_test is still paused. Call "continue" first.')
801
963
  }
802
- const { test, timeout = 60000, config: configPathArg, pauseAt, plugins } = args || {}
803
- await initCodecept(configPathArg, plugins)
964
+ const { test, timeout = 60000, pauseAt, grep, plugins } = args || {}
965
+ await initCodecept(undefined, plugins)
804
966
  await endShellSession()
967
+ applyMochaGrep(grep)
805
968
 
806
969
  return await withSilencedIO(async () => {
807
970
  codecept.loadTests()
@@ -822,26 +985,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
822
985
  pendingTestFile = testFile
823
986
  pendingStepInfo = null
824
987
  let stepIndex = 0
988
+ const matchPauseAt = pauseAtMatcher(pauseAt)
825
989
 
826
990
  const onAfter = t => {
991
+ const aiTrace = t.artifacts?.aiTrace
827
992
  pendingRunResults.push({
828
993
  title: t.title,
829
994
  file: t.file,
830
995
  status: t.err ? 'failed' : 'passed',
831
996
  error: t.err?.message,
832
997
  duration: t.duration,
998
+ traceFile: aiTrace ? pathToFileURL(aiTrace).href : null,
833
999
  })
834
1000
  }
835
1001
  const onStepAfter = step => {
836
1002
  stepIndex += 1
837
- try {
838
- pendingStepInfo = { index: stepIndex, name: step.toString(), status: step.status }
839
- } catch {
840
- pendingStepInfo = { index: stepIndex }
841
- }
842
- if (typeof pauseAt === 'number' && stepIndex === pauseAt) {
843
- pauseNow()
844
- }
1003
+ const idx = stepIndex
1004
+ const name = (() => { try { return step.toString() } catch { return '' } })()
1005
+ recorder.add('mcp pause info', () => {
1006
+ pendingStepInfo = { index: idx, name, status: step.status }
1007
+ })
1008
+ if (matchPauseAt(idx, name)) pauseNow()
845
1009
  }
846
1010
  event.dispatcher.on(event.test.after, onAfter)
847
1011
  event.dispatcher.on(event.step.after, onStepAfter)
@@ -883,6 +1047,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
883
1047
  }
884
1048
 
885
1049
  const final = collectRunCompletion(runError?.message)
1050
+ await startShellSession()
886
1051
  return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
887
1052
  })
888
1053
  })
@@ -893,9 +1058,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
893
1058
  if (pausedController) {
894
1059
  throw new Error('A previous run is still paused. Call "continue" first.')
895
1060
  }
896
- const { test, timeout = 60000, config: configPath, plugins } = args || {}
897
- await initCodecept(configPath, plugins)
1061
+ const { test, timeout = 60000, grep, plugins } = args || {}
1062
+ await initCodecept(undefined, plugins)
898
1063
  await endShellSession()
1064
+ applyMochaGrep(grep)
899
1065
 
900
1066
  return await withSilencedIO(async () => {
901
1067
  codecept.loadTests()
@@ -918,22 +1084,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
918
1084
  let stepIndex = 0
919
1085
 
920
1086
  const onAfter = t => {
1087
+ const aiTrace = t.artifacts?.aiTrace
921
1088
  pendingRunResults.push({
922
1089
  title: t.title,
923
1090
  file: t.file,
924
1091
  status: t.err ? 'failed' : 'passed',
925
1092
  error: t.err?.message,
926
1093
  duration: t.duration,
1094
+ traceFile: aiTrace ? pathToFileURL(aiTrace).href : null,
927
1095
  })
928
1096
  }
929
1097
  const onStepAfter = step => {
930
1098
  stepIndex += 1
931
- try {
932
- pendingStepInfo = { index: stepIndex, name: step.toString(), status: step.status }
933
- } catch {
934
- pendingStepInfo = { index: stepIndex }
935
- }
936
- // Pause after every step — agent calls continue to advance.
1099
+ const idx = stepIndex
1100
+ const name = (() => { try { return step.toString() } catch { return '' } })()
1101
+ recorder.add('mcp pause info', () => {
1102
+ pendingStepInfo = { index: idx, name, status: step.status }
1103
+ })
937
1104
  pauseNow()
938
1105
  }
939
1106
  event.dispatcher.on(event.test.after, onAfter)
@@ -975,8 +1142,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
975
1142
  }
976
1143
  }
977
1144
 
978
- // Test had zero steps (or finished before first pause) — return completion
979
1145
  const final = collectRunCompletion(runError?.message)
1146
+ await startShellSession()
980
1147
  return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
981
1148
  })
982
1149
  })
@@ -994,6 +1161,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
994
1161
  })
995
1162
 
996
1163
  async function main() {
1164
+ installShutdownHooks()
997
1165
  const transport = new StdioServerTransport()
998
1166
  await server.connect(transport)
999
1167
  }