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
package/bin/mcp-server.js CHANGED
@@ -1,18 +1,36 @@
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'
4
5
  import Codecept from '../lib/codecept.js'
5
6
  import container from '../lib/container.js'
6
7
  import { getParamsToString } from '../lib/parser.js'
7
- import { methodsOfObject } from '../lib/utils.js'
8
+ import { methodsOfObject, safeStringify, truncateString } from '../lib/utils.js'
9
+ import {
10
+ captureSnapshot,
11
+ pickActingHelper,
12
+ traceDirFor,
13
+ snapshotDirFor,
14
+ artifactsToFileUrls,
15
+ writeTraceMarkdown,
16
+ TraceReader,
17
+ ariaDiff,
18
+ } from '../lib/utils/trace.js'
8
19
  import event from '../lib/event.js'
9
- import { fileURLToPath } from 'url'
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'
26
+ import { setPauseHandler, pauseNow } from '../lib/pause.js'
27
+ import { EventEmitter } from 'events'
28
+ import { fileURLToPath, pathToFileURL } from 'url'
10
29
  import { dirname, resolve as resolvePath } from 'path'
11
30
  import path from 'path'
12
- import crypto from 'crypto'
13
31
  import { spawn } from 'child_process'
14
32
  import { createRequire } from 'module'
15
- import { existsSync, readdirSync, writeFileSync } from 'fs'
33
+ import { existsSync, readdirSync } from 'fs'
16
34
  import { mkdirp } from 'mkdirp'
17
35
 
18
36
  const require = createRequire(import.meta.url)
@@ -23,6 +41,129 @@ const __dirname = dirname(__filename)
23
41
  let codecept = null
24
42
  let containerInitialized = false
25
43
  let browserStarted = false
44
+ let shellSessionActive = false
45
+ let bootstrapDone = false
46
+ let currentPluginsSig = ''
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
49
+
50
+ event.dispatcher.on(event.test.before, test => {
51
+ try {
52
+ const title = (test && (test.fullTitle ? test.fullTitle() : test.title)) || 'MCP Session'
53
+ currentAiTraceDir = traceDirFor(test?.file, title, outputBaseDir())
54
+ } catch {}
55
+ })
56
+
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
+ }
78
+
79
+ async function ensureBootstrap() {
80
+ if (bootstrapDone) return
81
+ await codecept.bootstrap()
82
+ bootstrapDone = true
83
+ }
84
+
85
+ async function startShellSession() {
86
+ if (shellSessionActive) return
87
+ await ensureBootstrap()
88
+ recorder.start()
89
+ event.emit(event.suite.before, {
90
+ fullTitle: () => 'MCP Session',
91
+ tests: [],
92
+ retries: () => {},
93
+ })
94
+ event.emit(event.test.before, {
95
+ title: 'MCP Session',
96
+ artifacts: {},
97
+ retries: () => {},
98
+ })
99
+ shellSessionActive = true
100
+ }
101
+
102
+ async function endShellSession() {
103
+ if (!shellSessionActive) return
104
+ try { event.emit(event.test.after, {}) } catch {}
105
+ try { event.emit(event.suite.after, {}) } catch {}
106
+ try { event.emit(event.all.result, {}) } catch {}
107
+ shellSessionActive = false
108
+ }
109
+
110
+ async function ensureSession() {
111
+ if (shellSessionActive || pausedController) return
112
+ await startShellSession()
113
+ }
114
+
115
+ function normalizePluginOverrides(plugins) {
116
+ if (!plugins || typeof plugins !== 'object') return {}
117
+ const out = {}
118
+ for (const [name, opts] of Object.entries(plugins)) {
119
+ if (opts === false) continue
120
+ out[name] = (opts === true || opts == null) ? {} : opts
121
+ }
122
+ return out
123
+ }
124
+
125
+ function applyPluginOverrides(config, plugins) {
126
+ config.plugins = config.plugins || {}
127
+ for (const [name, opts] of Object.entries(plugins)) {
128
+ config.plugins[name] = { ...(config.plugins[name] || {}), ...opts, enabled: true }
129
+ }
130
+ }
131
+
132
+ function pluginsSignature(plugins) {
133
+ const keys = Object.keys(plugins).sort()
134
+ return JSON.stringify(keys.map(k => [k, plugins[k]]))
135
+ }
136
+
137
+ async function teardownContainer() {
138
+ if (!containerInitialized) return
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 = ''
149
+ }
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
+ })
166
+ }
26
167
 
27
168
  let runLock = Promise.resolve()
28
169
  async function withLock(fn) {
@@ -224,19 +365,136 @@ async function resolveTestToFile({ cli, root, configPath, test }) {
224
365
  return fsFound ? normalizePath(fsFound) : null
225
366
  }
226
367
 
227
- function clearString(str) {
228
- return str.replace(/[^a-zA-Z0-9]/g, '_')
368
+ function outputBaseDir() {
369
+ return global.output_dir || resolvePath(process.cwd(), 'output')
370
+ }
371
+
372
+ // In-process pause coordination. When a test running through run_test calls
373
+ // pause(), the handler registered via setPauseHandler resolves a "paused"
374
+ // promise that run_test is racing against test completion. The "pause" tool
375
+ // then drives the REPL by mutating next/abort and resolving the controller.
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
383
+ const pauseEvents = new EventEmitter()
384
+
385
+ setPauseHandler(({ registeredVariables }) => {
386
+ if (abortRun) return Promise.reject(new Error('MCP session aborted'))
387
+ return new Promise(resolve => {
388
+ pausedController = {
389
+ registeredVariables,
390
+ resolveContinue: () => {
391
+ pausedController = null
392
+ resolve()
393
+ },
394
+ }
395
+ pauseEvents.emit('paused')
396
+ })
397
+ })
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
+
426
+ async function captureLiveArtifacts(prefix = 'pause') {
427
+ const helper = pickActingHelper(container.helpers())
428
+ if (!helper) return {}
429
+ const dir = snapshotDirFor(outputBaseDir())
430
+ mkdirp.sync(dir)
431
+ const captured = await captureSnapshot(helper, { dir, prefix })
432
+ return artifactsToFileUrls(captured, dir)
433
+ }
434
+
435
+ async function gatherPageBrief() {
436
+ const helper = pickActingHelper(container.helpers())
437
+ if (!helper) return {}
438
+ const out = {}
439
+ try { if (helper.grabCurrentUrl) out.url = await helper.grabCurrentUrl() } catch {}
440
+ try { if (helper.grabTitle) out.title = await helper.grabTitle() } catch {}
441
+ try {
442
+ if (helper.grabSource) {
443
+ const html = await helper.grabSource()
444
+ out.contentSize = typeof html === 'string' ? html.length : null
445
+ }
446
+ } catch {}
447
+ return out
448
+ }
449
+
450
+ function collectRunCompletion(errorMessage) {
451
+ const results = pendingRunResults || []
452
+ const stats = {
453
+ tests: results.length,
454
+ passes: results.filter(r => r.status === 'passed').length,
455
+ failures: results.filter(r => r.status === 'failed').length,
456
+ }
457
+ if (typeof pendingRunCleanup === 'function') pendingRunCleanup()
458
+ pendingRunPromise = null
459
+ pendingRunResults = null
460
+ pendingTestFile = null
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
+ }
466
+ return {
467
+ status: error ? 'failed' : 'completed',
468
+ aiTraceDir: currentAiTraceDir,
469
+ reporterJson: { stats, tests: results },
470
+ error,
471
+ aiTraceHint: aiTraceHint(),
472
+ }
229
473
  }
230
474
 
231
- function getTraceDir(testTitle, testFile) {
232
- const hash = crypto.createHash('sha256').update(testFile + testTitle).digest('hex').slice(0, 8)
233
- const cleanTitle = clearString(testTitle).slice(0, 200)
234
- const outputDir = global.output_dir || resolvePath(process.cwd(), 'output')
235
- return resolvePath(outputDir, `trace_${cleanTitle}_${hash}`)
475
+ function pausedPayload() {
476
+ return {
477
+ status: 'paused',
478
+ file: pendingTestFile,
479
+ aiTraceDir: currentAiTraceDir,
480
+ pausedAfter: pendingStepInfo,
481
+ suggestions: [
482
+ 'Call snapshot to capture URL/HTML/ARIA/screenshot/console/storage at this point',
483
+ 'Call run_code to inspect or manipulate state (e.g. return await I.grabText("h1"))',
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',
486
+ ],
487
+ }
236
488
  }
237
489
 
238
- async function initCodecept(configPath) {
239
- if (containerInitialized) return
490
+ async function initCodecept(configPath, pluginOverrides) {
491
+ const plugins = normalizePluginOverrides(pluginOverrides)
492
+ const sig = pluginsSignature(plugins)
493
+
494
+ if (containerInitialized) {
495
+ if (!Object.keys(plugins).length || sig === currentPluginsSig) return
496
+ await teardownContainer()
497
+ }
240
498
 
241
499
  const testRoot = process.env.CODECEPTJS_PROJECT_DIR || process.cwd()
242
500
 
@@ -261,13 +519,27 @@ async function initCodecept(configPath) {
261
519
  const { getConfig } = await import('../lib/command/utils.js')
262
520
  const config = await getConfig(configPath)
263
521
 
522
+ // aiTrace is the canonical per-step ARIA/HTML/screenshot capture for MCP.
523
+ // Always on so run_code / continue can read the latest snapshot from disk
524
+ // instead of double-capturing through grabAriaSnapshot etc.
525
+ applyPluginOverrides(config, { aiTrace: { on: 'step' }, browser: { show: false }, ...plugins })
526
+
264
527
  codecept = new Codecept(config, {})
265
528
  await codecept.init(testRoot)
266
- await container.create(config, {})
267
529
  await container.started()
268
530
 
269
531
  containerInitialized = true
270
532
  browserStarted = true
533
+ aiTraceEnabled = config.plugins?.aiTrace?.enabled === true
534
+ currentPluginsSig = sig
535
+ }
536
+
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
271
543
  }
272
544
 
273
545
  const server = new Server(
@@ -275,66 +547,124 @@ const server = new Server(
275
547
  { capabilities: { tools: {} } }
276
548
  )
277
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
+
278
566
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
279
567
  tools: [
280
568
  {
281
569
  name: 'list_tests',
282
- description: 'List all tests in the CodeceptJS project',
283
- 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: {} },
284
572
  },
285
573
  {
286
574
  name: 'list_actions',
287
- description: 'List all available CodeceptJS actions (I.* methods)',
288
- 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: {} },
289
577
  },
290
578
  {
291
579
  name: 'run_code',
292
- 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.).',
293
581
  inputSchema: {
294
582
  type: 'object',
295
583
  properties: {
296
584
  code: { type: 'string' },
297
585
  timeout: { type: 'number' },
298
- config: { type: 'string' },
299
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.' },
300
588
  },
301
589
  required: ['code'],
302
590
  },
303
591
  },
304
592
  {
305
593
  name: 'run_test',
306
- description: 'Run a specific test.',
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).',
307
595
  inputSchema: {
308
596
  type: 'object',
309
597
  properties: {
310
598
  test: { type: 'string' },
311
599
  timeout: { type: 'number' },
312
- config: { type: 'string' },
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,
313
606
  },
314
607
  required: ['test'],
315
608
  },
316
609
  },
317
610
  {
318
611
  name: 'run_step_by_step',
319
- description: 'Run a test step by step with pauses between steps.',
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.',
320
613
  inputSchema: {
321
614
  type: 'object',
322
615
  properties: {
323
616
  test: { type: 'string' },
324
617
  timeout: { type: 'number' },
325
- config: { type: 'string' },
618
+ grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
619
+ plugins: PLUGINS_PROP,
326
620
  },
327
621
  required: ['test'],
328
622
  },
329
623
  },
330
624
  {
331
625
  name: 'start_browser',
332
- description: 'Start the browser session.',
333
- 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
+ },
334
638
  },
335
639
  {
336
640
  name: 'stop_browser',
337
- 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.',
642
+ inputSchema: { type: 'object', properties: {} },
643
+ },
644
+ {
645
+ name: 'snapshot',
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.',
647
+ inputSchema: {
648
+ type: 'object',
649
+ properties: {
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.' },
652
+ },
653
+ },
654
+ },
655
+ {
656
+ name: 'continue',
657
+ description: 'Release a paused test (one that called pause() during run_test) and let it run to completion. Returns the final reporter result. Use run_code to inspect or manipulate state while the test is paused — both tools share the same container.',
658
+ inputSchema: {
659
+ type: 'object',
660
+ properties: {
661
+ timeout: { type: 'number' },
662
+ },
663
+ },
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.',
338
668
  inputSchema: { type: 'object', properties: {} },
339
669
  },
340
670
  ],
@@ -346,8 +676,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
346
676
  try {
347
677
  switch (name) {
348
678
  case 'list_tests': {
349
- const configPath = args?.config
350
- await initCodecept(configPath)
679
+ await initCodecept()
351
680
 
352
681
  codecept.loadTests()
353
682
  const tests = codecept.testFiles.map(testFile => {
@@ -362,8 +691,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
362
691
  }
363
692
 
364
693
  case 'list_actions': {
365
- const configPath = args?.config
366
- await initCodecept(configPath)
694
+ await initCodecept()
367
695
 
368
696
  const helpers = container.helpers()
369
697
  const supportI = container.support('I')
@@ -391,225 +719,433 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
391
719
  }
392
720
 
393
721
  case 'start_browser': {
394
- const configPath = args?.config
395
- if (browserStarted) {
396
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser already started' }, null, 2) }] }
722
+ const { config: configPath, plugins } = args || {}
723
+ if (browserStarted && shellSessionActive) {
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
397
732
  }
398
- await initCodecept(configPath)
399
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser started successfully' }, null, 2) }] }
733
+ await startShellSession()
734
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session started — run_code and snapshot are now available', plugins: plugins ?? null }, null, 2) }] }
400
735
  }
401
736
 
402
737
  case 'stop_browser': {
403
738
  if (!containerInitialized) {
404
739
  return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser not initialized' }, null, 2) }] }
405
740
  }
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) }] }
743
+ }
406
744
 
407
- const helpers = container.helpers()
408
- for (const helperName in helpers) {
409
- const helper = helpers[helperName]
410
- try { if (helper._finish) await helper._finish() } catch {}
745
+ case 'snapshot': {
746
+ const { fullPage = false, settleMs = 300 } = args || {}
747
+ await initCodecept()
748
+ await ensureSession()
749
+
750
+ const helper = pickActingHelper(container.helpers())
751
+ if (!helper) throw new Error('No supported acting helper available (Playwright, Puppeteer, WebDriver).')
752
+
753
+ const dir = snapshotDirFor(outputBaseDir())
754
+ mkdirp.sync(dir)
755
+
756
+ if (settleMs > 0) await new Promise(r => setTimeout(r, settleMs))
757
+ const captured = await captureSnapshot(helper, { dir, prefix: 'snapshot', fullPage })
758
+ const traceFile = writeTraceMarkdown({
759
+ dir,
760
+ title: 'snapshot',
761
+ file: 'mcp',
762
+ durationMs: 0,
763
+ commands: [],
764
+ captured,
765
+ })
766
+
767
+ return {
768
+ content: [{
769
+ type: 'text',
770
+ text: JSON.stringify({
771
+ status: 'success',
772
+ dir,
773
+ traceFile: pathToFileURL(traceFile).href,
774
+ artifacts: artifactsToFileUrls(captured, dir),
775
+ aiTraceHint: aiTraceHint(),
776
+ }, null, 2),
777
+ }],
411
778
  }
779
+ }
780
+
781
+ case 'continue': {
782
+ if (!pausedController) throw new Error('No paused test. Run a test first via run_test or run_step_by_step; this tool becomes available if the test pauses.')
783
+ const { timeout = 60000 } = args || {}
784
+ return await withSilencedIO(async () => {
785
+ pausedController.resolveContinue()
786
+ if (!pendingRunPromise) {
787
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'continued' }, null, 2) }] }
788
+ }
789
+
790
+ // Race: test pauses again (step-by-step or another pause()) vs test finishes.
791
+ const pausedAgain = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused')))
792
+ const completed = pendingRunPromise.then(() => 'completed', () => 'completed')
793
+ const which = await Promise.race([
794
+ pausedAgain,
795
+ completed,
796
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
797
+ ])
412
798
 
413
- browserStarted = false
414
- containerInitialized = false
799
+ if (which === 'paused') {
800
+ const page = await gatherPageBrief()
801
+ return { content: [{ type: 'text', text: JSON.stringify({ ...pausedPayload(), page }, null, 2) }] }
802
+ }
803
+
804
+ let runError = null
805
+ try { await pendingRunPromise } catch (err) { runError = err }
806
+ const file = pendingTestFile
807
+ const final = collectRunCompletion(runError?.message)
808
+ return { content: [{ type: 'text', text: JSON.stringify({ ...final, file }, null, 2) }] }
809
+ })
810
+ }
415
811
 
416
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped successfully' }, null, 2) }] }
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) }] }
417
816
  }
418
817
 
419
818
  case 'run_code': {
420
- const { code, timeout = 60000, config: configPath, saveArtifacts = true } = args
421
- await initCodecept(configPath)
819
+ const { code, timeout = 60000, saveArtifacts = true, settleMs = 300 } = args
820
+ await initCodecept()
821
+ await ensureSession()
822
+
823
+ const support = container.supportObjects() || {}
824
+ if (!support.I) throw new Error('I object not available. Make sure helpers are configured.')
825
+
826
+ const result = { status: 'unknown', output: '', error: null, commands: [], artifacts: {} }
827
+
828
+ const commands = []
829
+ let lastStepValue
830
+ const onStepAfter = step => {
831
+ try { commands.push(step.toString()) } catch {}
832
+ }
833
+ const onStepPassed = (step, val) => {
834
+ if (val !== undefined) lastStepValue = val
835
+ }
836
+ event.dispatcher.on(event.step.after, onStepAfter)
837
+ event.dispatcher.on(event.step.passed, onStepPassed)
838
+
839
+ const traceDir = traceDirFor(`mcp_${Date.now()}`, 'run_code', outputBaseDir())
840
+ mkdirp.sync(traceDir)
841
+ const startedAt = Date.now()
842
+
843
+ // Pin the latest aiTrace ARIA file before running the code, so we
844
+ // can diff after. aiTrace owns per-step capture; we just read it.
845
+ const reader = new TraceReader(currentAiTraceDir)
846
+ const ariaBefore = reader.last('aria')
847
+
848
+ const MAX_LOG_ENTRIES = 100
849
+ const MAX_LOG_MSG_BYTES = 2000
850
+ const MAX_RETURN_BYTES = 20000
851
+ const consoleLogs = []
852
+ const consoleMethods = ['log', 'info', 'warn', 'error', 'debug']
853
+ const origConsoleMethods = {}
854
+ const captureLog = level => (...args) => {
855
+ if (consoleLogs.length >= MAX_LOG_ENTRIES) return
856
+ const message = args.map(a => {
857
+ if (typeof a === 'string') return a
858
+ return truncateString(safeStringify(a, [], 2), MAX_LOG_MSG_BYTES).value
859
+ }).join(' ')
860
+ consoleLogs.push({ level, message, t: Date.now() - startedAt })
861
+ }
862
+ for (const m of consoleMethods) {
863
+ origConsoleMethods[m] = console[m]
864
+ console[m] = captureLog(m)
865
+ }
422
866
 
423
- const I = container.support('I')
424
- if (!I) throw new Error('I object not available. Make sure helpers are configured.')
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])
425
876
 
426
- const result = { status: 'unknown', output: '', error: null, artifacts: {} }
877
+ const wasPaused = !!pausedController
878
+ if (wasPaused) recorder.session.start('mcp_run_code')
427
879
 
880
+ let returnValue
428
881
  try {
429
- const asyncFn = new Function('I', `return (async () => { ${code} })()`)
430
- await Promise.race([
431
- asyncFn(I),
882
+ const asyncFn = new Function(...paramNames, `return (async () => { ${code} })()`)
883
+ returnValue = await Promise.race([
884
+ asyncFn(...paramValues),
432
885
  new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
433
886
  ])
887
+ await recorder.promise()
434
888
 
435
889
  result.status = 'success'
436
890
  result.output = 'Code executed successfully'
437
-
438
- if (saveArtifacts) {
439
- const helpers = container.helpers()
440
- const helper = Object.values(helpers)[0]
441
- if (helper) {
442
- try {
443
- const traceDir = getTraceDir('mcp', 'run_code')
444
- mkdirp.sync(traceDir)
445
-
446
- if (helper.grabAriaSnapshot) {
447
- const aria = await helper.grabAriaSnapshot()
448
- const ariaFile = path.join(traceDir, 'aria.txt')
449
- writeFileSync(ariaFile, aria)
450
- result.artifacts.aria = `file://${ariaFile}`
451
- }
452
-
453
- if (helper.grabCurrentUrl) {
454
- result.artifacts.url = await helper.grabCurrentUrl()
455
- }
456
-
457
- if (helper.grabBrowserLogs) {
458
- const logs = (await helper.grabBrowserLogs()) || []
459
- const logsFile = path.join(traceDir, 'console.json')
460
- writeFileSync(logsFile, JSON.stringify(logs, null, 2))
461
- result.artifacts.consoleLogs = `file://${logsFile}`
462
- }
463
-
464
- if (helper.grabSource) {
465
- const html = await helper.grabSource()
466
- const htmlFile = path.join(traceDir, 'page.html')
467
- writeFileSync(htmlFile, html)
468
- result.artifacts.html = `file://${htmlFile}`
469
- }
470
-
471
- if (helper.saveScreenshot) {
472
- const screenshotFile = path.join(traceDir, 'screenshot.png')
473
- await helper.saveScreenshot(screenshotFile)
474
- result.artifacts.screenshot = `file://${screenshotFile}`
475
- }
476
- } catch (e) {
477
- result.output += ` (Warning: ${e.message})`
478
- }
479
- }
480
- }
481
891
  } catch (error) {
482
892
  result.status = 'failed'
483
893
  result.error = error.message
484
894
  result.output = error.stack || error.message
895
+ } finally {
896
+ for (const m of consoleMethods) console[m] = origConsoleMethods[m]
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
+ }
904
+ }
905
+
906
+ result.commands = commands
907
+ result.logs = consoleLogs
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)
913
+
914
+ if (returnValue !== undefined) {
915
+ const json = typeof returnValue === 'string' ? returnValue : safeStringify(returnValue, [], 2)
916
+ const stringified = truncateString(json, MAX_RETURN_BYTES)
917
+ result.returnValue = stringified.value
918
+ if (stringified.truncated) result.returnValueTruncated = true
485
919
  }
486
920
 
921
+ let captured = {}
922
+ if (saveArtifacts) {
923
+ const helper = pickActingHelper(container.helpers())
924
+ if (helper) {
925
+ try {
926
+ if (settleMs > 0) await new Promise(r => setTimeout(r, settleMs))
927
+ captured = await captureSnapshot(helper, { dir: traceDir, prefix: 'mcp' })
928
+ result.artifacts = artifactsToFileUrls(captured, traceDir)
929
+ } catch (e) {
930
+ result.output += ` (Warning: ${e.message})`
931
+ }
932
+ }
933
+ }
934
+
935
+ // Diff against the latest aiTrace ARIA file produced by the steps
936
+ // that just ran inside this run_code call.
937
+ const ariaAfter = reader.last('aria')
938
+ if (ariaBefore && ariaAfter && ariaBefore !== ariaAfter) {
939
+ const diff = ariaDiff(ariaBefore, ariaAfter)
940
+ if (diff) result.ariaDiff = diff
941
+ }
942
+
943
+ const traceFile = writeTraceMarkdown({
944
+ dir: traceDir,
945
+ title: 'run_code',
946
+ file: 'mcp',
947
+ durationMs: Date.now() - startedAt,
948
+ commands,
949
+ captured,
950
+ error: result.error,
951
+ })
952
+ result.dir = traceDir
953
+ result.traceFile = pathToFileURL(traceFile).href
954
+ result.aiTraceHint = aiTraceHint()
955
+
487
956
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
488
957
  }
489
958
 
490
959
  case 'run_test': {
491
960
  return await withLock(async () => {
492
- const { test, timeout = 60000, config: configPathArg } = args || {}
493
- const { configPath, configDir } = resolveConfigPath(configPathArg)
494
-
495
- const { cli, root } = findCodeceptCliUpwards(configDir)
496
- const isNodeScript = cli.endsWith('.js')
961
+ if (pausedController) {
962
+ throw new Error('A previous run_test is still paused. Call "continue" first.')
963
+ }
964
+ const { test, timeout = 60000, pauseAt, grep, plugins } = args || {}
965
+ await initCodecept(undefined, plugins)
966
+ await endShellSession()
967
+ applyMochaGrep(grep)
968
+
969
+ return await withSilencedIO(async () => {
970
+ codecept.loadTests()
971
+
972
+ let testFiles = codecept.testFiles
973
+ if (test) {
974
+ const testName = normalizePath(test).toLowerCase()
975
+ testFiles = codecept.testFiles.filter(f => {
976
+ const filePath = normalizePath(f).toLowerCase()
977
+ return filePath.includes(testName) || filePath.endsWith(testName)
978
+ })
979
+ }
497
980
 
498
- const resolvedFile = await resolveTestToFile({ cli, root, configPath, test })
499
- const runArgs = ['run', '--config', configPath, '--reporter', 'json']
981
+ if (!testFiles.length) throw new Error(`No tests found matching: ${test}`)
982
+ const testFile = testFiles[0]
983
+
984
+ pendingRunResults = []
985
+ pendingTestFile = testFile
986
+ pendingStepInfo = null
987
+ let stepIndex = 0
988
+ const matchPauseAt = pauseAtMatcher(pauseAt)
989
+
990
+ const onAfter = t => {
991
+ const aiTrace = t.artifacts?.aiTrace
992
+ pendingRunResults.push({
993
+ title: t.title,
994
+ file: t.file,
995
+ status: t.err ? 'failed' : 'passed',
996
+ error: t.err?.message,
997
+ duration: t.duration,
998
+ traceFile: aiTrace ? pathToFileURL(aiTrace).href : null,
999
+ })
1000
+ }
1001
+ const onStepAfter = step => {
1002
+ stepIndex += 1
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()
1009
+ }
1010
+ event.dispatcher.on(event.test.after, onAfter)
1011
+ event.dispatcher.on(event.step.after, onStepAfter)
1012
+ pendingRunCleanup = () => {
1013
+ try { event.dispatcher.removeListener(event.test.after, onAfter) } catch {}
1014
+ try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
1015
+ pendingRunCleanup = null
1016
+ }
500
1017
 
501
- if (resolvedFile) runArgs.push(resolvedFile)
502
- else if (looksLikePath(test)) runArgs.push(test)
503
- else runArgs.push('--grep', String(test))
1018
+ let runError = null
1019
+ const runPromise = (async () => {
1020
+ try {
1021
+ await ensureBootstrap()
1022
+ await codecept.run(testFile)
1023
+ } catch (err) {
1024
+ runError = err
1025
+ throw err
1026
+ }
1027
+ })()
504
1028
 
505
- const res = isNodeScript
506
- ? await runCmd(process.execPath, [cli, ...runArgs], { cwd: root, timeout })
507
- : await runCmd(cli, runArgs, { cwd: root, timeout })
1029
+ const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused')))
1030
+ const completedPromise = runPromise.then(() => 'completed', () => 'completed')
508
1031
 
509
- const { code, out, err } = res
1032
+ const which = await Promise.race([
1033
+ completedPromise,
1034
+ pausedPromise,
1035
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
1036
+ ])
510
1037
 
511
- let parsed = null
512
- const jsonStart = out.indexOf('{')
513
- const jsonEnd = out.lastIndexOf('}')
514
- if (jsonStart !== -1 && jsonEnd !== -1 && jsonEnd > jsonStart) {
515
- try { parsed = JSON.parse(out.slice(jsonStart, jsonEnd + 1)) } catch {}
516
- }
1038
+ if (which === 'paused') {
1039
+ pendingRunPromise = runPromise
1040
+ const page = await gatherPageBrief()
1041
+ return {
1042
+ content: [{
1043
+ type: 'text',
1044
+ text: JSON.stringify({ ...pausedPayload(), page }, null, 2),
1045
+ }],
1046
+ }
1047
+ }
517
1048
 
518
- return {
519
- content: [{
520
- type: 'text',
521
- text: JSON.stringify({
522
- meta: { exitCode: code, cli, root, configPath, args: runArgs, resolvedFile: resolvedFile || null },
523
- reporterJson: parsed,
524
- stderr: err ? err.slice(0, 20000) : '',
525
- rawStdout: parsed ? '' : out.slice(0, 20000),
526
- }, null, 2),
527
- }],
528
- }
1049
+ const final = collectRunCompletion(runError?.message)
1050
+ await startShellSession()
1051
+ return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
1052
+ })
529
1053
  })
530
1054
  }
531
1055
 
532
1056
  case 'run_step_by_step': {
533
- const { test, timeout = 60000, config: configPath } = args
534
- await initCodecept(configPath)
535
-
536
- return await withSilencedIO(async () => {
537
- codecept.loadTests()
538
-
539
- let testFiles = codecept.testFiles
540
- if (test) {
541
- const testName = normalizePath(test).toLowerCase()
542
- testFiles = codecept.testFiles.filter(f => {
543
- const filePath = normalizePath(f).toLowerCase()
544
- return filePath.includes(testName) || filePath.endsWith(testName)
545
- })
546
- }
547
-
548
- if (!testFiles.length) throw new Error(`No tests found matching: ${test}`)
549
-
550
- const results = []
551
- const currentSteps = {}
552
- let currentTestTitle = null
553
- const testFile = testFiles[0]
554
-
555
- const onBefore = (t) => {
556
- const traceDir = getTraceDir(t.title, t.file)
557
- currentTestTitle = t.title
558
- currentSteps[t.title] = []
559
- results.push({
560
- test: t.title,
561
- file: t.file,
562
- traceFile: `file://${resolvePath(traceDir, 'trace.md')}`,
563
- status: 'running',
564
- steps: [],
565
- })
1057
+ return await withLock(async () => {
1058
+ if (pausedController) {
1059
+ throw new Error('A previous run is still paused. Call "continue" first.')
566
1060
  }
1061
+ const { test, timeout = 60000, grep, plugins } = args || {}
1062
+ await initCodecept(undefined, plugins)
1063
+ await endShellSession()
1064
+ applyMochaGrep(grep)
1065
+
1066
+ return await withSilencedIO(async () => {
1067
+ codecept.loadTests()
1068
+
1069
+ let testFiles = codecept.testFiles
1070
+ if (test) {
1071
+ const testName = normalizePath(test).toLowerCase()
1072
+ testFiles = codecept.testFiles.filter(f => {
1073
+ const filePath = normalizePath(f).toLowerCase()
1074
+ return filePath.includes(testName) || filePath.endsWith(testName)
1075
+ })
1076
+ }
567
1077
 
568
- const onAfter = (t) => {
569
- const r = results.find(x => x.test === t.title)
570
- if (r) {
571
- r.status = t.err ? 'failed' : 'completed'
572
- if (t.err) r.error = t.err.message
1078
+ if (!testFiles.length) throw new Error(`No tests found matching: ${test}`)
1079
+ const testFile = testFiles[0]
1080
+
1081
+ pendingRunResults = []
1082
+ pendingTestFile = testFile
1083
+ pendingStepInfo = null
1084
+ let stepIndex = 0
1085
+
1086
+ const onAfter = t => {
1087
+ const aiTrace = t.artifacts?.aiTrace
1088
+ pendingRunResults.push({
1089
+ title: t.title,
1090
+ file: t.file,
1091
+ status: t.err ? 'failed' : 'passed',
1092
+ error: t.err?.message,
1093
+ duration: t.duration,
1094
+ traceFile: aiTrace ? pathToFileURL(aiTrace).href : null,
1095
+ })
1096
+ }
1097
+ const onStepAfter = step => {
1098
+ stepIndex += 1
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
+ })
1104
+ pauseNow()
1105
+ }
1106
+ event.dispatcher.on(event.test.after, onAfter)
1107
+ event.dispatcher.on(event.step.after, onStepAfter)
1108
+ pendingRunCleanup = () => {
1109
+ try { event.dispatcher.removeListener(event.test.after, onAfter) } catch {}
1110
+ try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
1111
+ pendingRunCleanup = null
573
1112
  }
574
- currentTestTitle = null
575
- }
576
1113
 
577
- const onStepAfter = (step) => {
578
- if (!currentTestTitle || !currentSteps[currentTestTitle]) return
579
- currentSteps[currentTestTitle].push({
580
- step: step.toString(),
581
- status: step.status,
582
- time: step.endTime - step.startTime,
583
- })
584
- const r = results.find(x => x.test === currentTestTitle)
585
- if (r) r.steps = [...currentSteps[currentTestTitle]]
586
- }
1114
+ let runError = null
1115
+ const runPromise = (async () => {
1116
+ try {
1117
+ await ensureBootstrap()
1118
+ await codecept.run(testFile)
1119
+ } catch (err) {
1120
+ runError = err
1121
+ throw err
1122
+ }
1123
+ })()
587
1124
 
588
- event.dispatcher.on(event.test.before, onBefore)
589
- event.dispatcher.on(event.test.after, onAfter)
590
- event.dispatcher.on(event.step.after, onStepAfter)
1125
+ const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused')))
1126
+ const completedPromise = runPromise.then(() => 'completed', () => 'completed')
591
1127
 
592
- try {
593
- await Promise.race([
594
- (async () => {
595
- await codecept.bootstrap()
596
- await codecept.run(testFile)
597
- })(),
1128
+ const which = await Promise.race([
1129
+ completedPromise,
1130
+ pausedPromise,
598
1131
  new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
599
1132
  ])
600
- } catch (error) {
601
- const lastRunning = results.filter(r => r.status === 'running').pop()
602
- if (lastRunning) {
603
- lastRunning.status = 'failed'
604
- lastRunning.error = error.message
1133
+
1134
+ if (which === 'paused') {
1135
+ pendingRunPromise = runPromise
1136
+ const page = await gatherPageBrief()
1137
+ return {
1138
+ content: [{
1139
+ type: 'text',
1140
+ text: JSON.stringify({ ...pausedPayload(), page }, null, 2),
1141
+ }],
1142
+ }
605
1143
  }
606
- } finally {
607
- try { event.dispatcher.removeListener(event.test.before, onBefore) } catch {}
608
- try { event.dispatcher.removeListener(event.test.after, onAfter) } catch {}
609
- try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
610
- }
611
1144
 
612
- return { content: [{ type: 'text', text: JSON.stringify({ results, stepByStep: true }, null, 2) }] }
1145
+ const final = collectRunCompletion(runError?.message)
1146
+ await startShellSession()
1147
+ return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
1148
+ })
613
1149
  })
614
1150
  }
615
1151
 
@@ -625,6 +1161,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
625
1161
  })
626
1162
 
627
1163
  async function main() {
1164
+ installShutdownHooks()
628
1165
  const transport = new StdioServerTransport()
629
1166
  await server.connect(transport)
630
1167
  }