codeceptjs 4.0.0-rc.2 → 4.0.0-rc.20

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 (294) hide show
  1. package/README.md +39 -27
  2. package/bin/codecept.js +15 -2
  3. package/bin/codeceptq.js +49 -0
  4. package/bin/mcp-server.js +1187 -0
  5. package/docs/advanced.md +201 -0
  6. package/docs/agents.md +159 -0
  7. package/docs/ai.md +537 -0
  8. package/docs/aitrace.md +266 -0
  9. package/docs/api.md +332 -0
  10. package/docs/assertions.md +415 -0
  11. package/docs/auth.md +318 -0
  12. package/docs/basics.md +424 -0
  13. package/docs/bdd.md +539 -0
  14. package/docs/best.md +240 -0
  15. package/docs/bootstrap.md +132 -0
  16. package/docs/commands.md +352 -0
  17. package/docs/community-helpers.md +63 -0
  18. package/docs/configuration.md +230 -0
  19. package/docs/continuous-integration.md +497 -0
  20. package/docs/custom-helpers.md +297 -0
  21. package/docs/data.md +448 -0
  22. package/docs/debugging.md +332 -0
  23. package/docs/detox.md +235 -0
  24. package/docs/docker.md +136 -0
  25. package/docs/effects.md +179 -0
  26. package/docs/element-based-testing.md +295 -0
  27. package/docs/element-selection.md +125 -0
  28. package/docs/els.md +328 -0
  29. package/docs/examples.md +161 -0
  30. package/docs/heal.md +213 -0
  31. package/docs/helpers/ApiDataFactory.md +267 -0
  32. package/docs/helpers/Appium.md +1405 -0
  33. package/docs/helpers/Detox.md +665 -0
  34. package/docs/helpers/ExpectHelper.md +275 -0
  35. package/docs/helpers/FileSystem.md +152 -0
  36. package/docs/helpers/GraphQL.md +152 -0
  37. package/docs/helpers/GraphQLDataFactory.md +226 -0
  38. package/docs/helpers/JSONResponse.md +255 -0
  39. package/docs/helpers/Mochawesome.md +8 -0
  40. package/docs/helpers/MockRequest.md +377 -0
  41. package/docs/helpers/MockServer.md +212 -0
  42. package/docs/helpers/Playwright.md +2969 -0
  43. package/docs/helpers/Polly.md +44 -0
  44. package/docs/helpers/Protractor.md +1769 -0
  45. package/docs/helpers/Puppeteer-firefox.md +86 -0
  46. package/docs/helpers/Puppeteer.md +2690 -0
  47. package/docs/helpers/REST.md +289 -0
  48. package/docs/helpers/SoftExpectHelper.md +352 -0
  49. package/docs/helpers/WebDriver.md +2682 -0
  50. package/docs/hooks.md +339 -0
  51. package/docs/index.md +111 -0
  52. package/docs/installation.md +83 -0
  53. package/docs/internal-api.md +265 -0
  54. package/docs/internal-test-server.md +89 -0
  55. package/docs/locators.md +355 -0
  56. package/docs/mcp.md +485 -0
  57. package/docs/migration-4.md +556 -0
  58. package/docs/mobile.md +338 -0
  59. package/docs/pageobjects.md +399 -0
  60. package/docs/parallel.md +585 -0
  61. package/docs/playwright.md +714 -0
  62. package/docs/plugins.md +866 -0
  63. package/docs/puppeteer.md +314 -0
  64. package/docs/quickstart.md +120 -0
  65. package/docs/react.md +70 -0
  66. package/docs/reports.md +483 -0
  67. package/docs/retry.md +274 -0
  68. package/docs/secrets.md +150 -0
  69. package/docs/sessions.md +80 -0
  70. package/docs/shadow.md +68 -0
  71. package/docs/test-structure.md +275 -0
  72. package/docs/timeouts.md +183 -0
  73. package/docs/translation.md +247 -0
  74. package/docs/tutorial.md +271 -0
  75. package/docs/typescript.md +374 -0
  76. package/docs/web-element.md +251 -0
  77. package/docs/webdriver.md +708 -0
  78. package/docs/within.md +55 -0
  79. package/lib/ai.js +3 -2
  80. package/lib/aria.js +260 -0
  81. package/lib/assertions.js +18 -0
  82. package/lib/codecept.js +26 -23
  83. package/lib/command/check.js +2 -1
  84. package/lib/command/dryRun.js +24 -5
  85. package/lib/command/generate.js +2 -0
  86. package/lib/command/gherkin/snippets.js +5 -4
  87. package/lib/command/init.js +248 -269
  88. package/lib/command/list.js +150 -10
  89. package/lib/command/query.js +218 -0
  90. package/lib/command/run-multiple.js +2 -0
  91. package/lib/command/run-workers.js +2 -0
  92. package/lib/command/run.js +1 -1
  93. package/lib/command/workers/runTests.js +10 -10
  94. package/lib/config.js +77 -4
  95. package/lib/container.js +114 -17
  96. package/lib/effects.js +17 -0
  97. package/lib/element/WebElement.js +246 -2
  98. package/lib/els.js +12 -6
  99. package/lib/globals.js +32 -19
  100. package/lib/heal.js +4 -3
  101. package/lib/helper/ApiDataFactory.js +2 -1
  102. package/lib/helper/Appium.js +8 -8
  103. package/lib/helper/FileSystem.js +3 -2
  104. package/lib/helper/GraphQLDataFactory.js +2 -1
  105. package/lib/helper/Playwright.js +228 -162
  106. package/lib/helper/Puppeteer.js +208 -76
  107. package/lib/helper/WebDriver.js +173 -68
  108. package/lib/helper/errors/MultipleElementsFound.js +27 -110
  109. package/lib/helper/errors/NonFocusedType.js +8 -0
  110. package/lib/helper/extras/Download.js +45 -0
  111. package/lib/helper/extras/PlaywrightReactVueLocator.js +45 -36
  112. package/lib/helper/extras/elementSelection.js +58 -0
  113. package/lib/helper/extras/focusCheck.js +43 -0
  114. package/lib/helper/extras/richTextEditor.js +178 -0
  115. package/lib/helper/scripts/dropFile.js +11 -0
  116. package/lib/history.js +3 -2
  117. package/lib/html.js +103 -16
  118. package/lib/index.js +9 -1
  119. package/lib/listener/config.js +6 -4
  120. package/lib/listener/emptyRun.js +2 -1
  121. package/lib/listener/globalRetry.js +32 -6
  122. package/lib/listener/helpers.js +4 -1
  123. package/lib/listener/mocha.js +2 -1
  124. package/lib/listener/pageobjects.js +43 -0
  125. package/lib/listener/result.js +3 -2
  126. package/lib/locator.js +126 -3
  127. package/lib/mocha/cli.js +14 -2
  128. package/lib/mocha/factory.js +7 -2
  129. package/lib/mocha/inject.js +1 -1
  130. package/lib/mocha/scenarioConfig.js +2 -1
  131. package/lib/mocha/ui.js +5 -6
  132. package/lib/parser.js +2 -2
  133. package/lib/pause.js +38 -4
  134. package/lib/plugin/aiTrace.js +453 -0
  135. package/lib/plugin/analyze.js +1 -1
  136. package/lib/plugin/auth.js +3 -3
  137. package/lib/plugin/browser.js +77 -0
  138. package/lib/plugin/expose.js +159 -0
  139. package/lib/plugin/heal.js +44 -1
  140. package/lib/plugin/pageInfo.js +53 -49
  141. package/lib/plugin/pause.js +131 -0
  142. package/lib/plugin/pauseOnFail.js +10 -34
  143. package/lib/plugin/retryFailedStep.js +28 -19
  144. package/lib/plugin/screencast.js +287 -0
  145. package/lib/plugin/screenshot.js +563 -0
  146. package/lib/plugin/screenshotOnFail.js +8 -171
  147. package/lib/rerun.js +2 -1
  148. package/lib/result.js +2 -1
  149. package/lib/step/base.js +3 -2
  150. package/lib/step/config.js +15 -2
  151. package/lib/step/record.js +2 -2
  152. package/lib/store.js +72 -3
  153. package/lib/translation.js +2 -1
  154. package/lib/utils/mask_data.js +2 -1
  155. package/lib/utils/pluginParser.js +151 -0
  156. package/lib/utils/trace.js +297 -0
  157. package/lib/utils.js +77 -3
  158. package/lib/workers.js +52 -22
  159. package/package.json +19 -13
  160. package/typings/index.d.ts +19 -5
  161. package/docs/webapi/amOnPage.mustache +0 -11
  162. package/docs/webapi/appendField.mustache +0 -11
  163. package/docs/webapi/attachFile.mustache +0 -12
  164. package/docs/webapi/blur.mustache +0 -18
  165. package/docs/webapi/checkOption.mustache +0 -13
  166. package/docs/webapi/clearCookie.mustache +0 -9
  167. package/docs/webapi/clearField.mustache +0 -9
  168. package/docs/webapi/click.mustache +0 -29
  169. package/docs/webapi/clickLink.mustache +0 -8
  170. package/docs/webapi/closeCurrentTab.mustache +0 -7
  171. package/docs/webapi/closeOtherTabs.mustache +0 -8
  172. package/docs/webapi/dontSee.mustache +0 -11
  173. package/docs/webapi/dontSeeCheckboxIsChecked.mustache +0 -10
  174. package/docs/webapi/dontSeeCookie.mustache +0 -8
  175. package/docs/webapi/dontSeeCurrentPathEquals.mustache +0 -10
  176. package/docs/webapi/dontSeeCurrentUrlEquals.mustache +0 -10
  177. package/docs/webapi/dontSeeElement.mustache +0 -8
  178. package/docs/webapi/dontSeeElementInDOM.mustache +0 -8
  179. package/docs/webapi/dontSeeInCurrentUrl.mustache +0 -4
  180. package/docs/webapi/dontSeeInField.mustache +0 -11
  181. package/docs/webapi/dontSeeInSource.mustache +0 -8
  182. package/docs/webapi/dontSeeInTitle.mustache +0 -8
  183. package/docs/webapi/dontSeeTraffic.mustache +0 -13
  184. package/docs/webapi/doubleClick.mustache +0 -13
  185. package/docs/webapi/downloadFile.mustache +0 -12
  186. package/docs/webapi/dragAndDrop.mustache +0 -9
  187. package/docs/webapi/dragSlider.mustache +0 -11
  188. package/docs/webapi/executeAsyncScript.mustache +0 -24
  189. package/docs/webapi/executeScript.mustache +0 -26
  190. package/docs/webapi/fillField.mustache +0 -16
  191. package/docs/webapi/flushNetworkTraffics.mustache +0 -5
  192. package/docs/webapi/focus.mustache +0 -13
  193. package/docs/webapi/forceClick.mustache +0 -28
  194. package/docs/webapi/forceRightClick.mustache +0 -18
  195. package/docs/webapi/grabAllWindowHandles.mustache +0 -7
  196. package/docs/webapi/grabAttributeFrom.mustache +0 -10
  197. package/docs/webapi/grabAttributeFromAll.mustache +0 -9
  198. package/docs/webapi/grabBrowserLogs.mustache +0 -9
  199. package/docs/webapi/grabCookie.mustache +0 -11
  200. package/docs/webapi/grabCssPropertyFrom.mustache +0 -11
  201. package/docs/webapi/grabCssPropertyFromAll.mustache +0 -10
  202. package/docs/webapi/grabCurrentUrl.mustache +0 -9
  203. package/docs/webapi/grabCurrentWindowHandle.mustache +0 -6
  204. package/docs/webapi/grabDataFromPerformanceTiming.mustache +0 -20
  205. package/docs/webapi/grabElementBoundingRect.mustache +0 -20
  206. package/docs/webapi/grabGeoLocation.mustache +0 -8
  207. package/docs/webapi/grabHTMLFrom.mustache +0 -10
  208. package/docs/webapi/grabHTMLFromAll.mustache +0 -9
  209. package/docs/webapi/grabNumberOfOpenTabs.mustache +0 -8
  210. package/docs/webapi/grabNumberOfVisibleElements.mustache +0 -9
  211. package/docs/webapi/grabPageScrollPosition.mustache +0 -8
  212. package/docs/webapi/grabPopupText.mustache +0 -5
  213. package/docs/webapi/grabRecordedNetworkTraffics.mustache +0 -10
  214. package/docs/webapi/grabSource.mustache +0 -8
  215. package/docs/webapi/grabTextFrom.mustache +0 -10
  216. package/docs/webapi/grabTextFromAll.mustache +0 -9
  217. package/docs/webapi/grabTitle.mustache +0 -8
  218. package/docs/webapi/grabValueFrom.mustache +0 -9
  219. package/docs/webapi/grabValueFromAll.mustache +0 -8
  220. package/docs/webapi/grabWebElement.mustache +0 -9
  221. package/docs/webapi/grabWebElements.mustache +0 -9
  222. package/docs/webapi/moveCursorTo.mustache +0 -12
  223. package/docs/webapi/openNewTab.mustache +0 -7
  224. package/docs/webapi/pressKey.mustache +0 -12
  225. package/docs/webapi/pressKeyDown.mustache +0 -12
  226. package/docs/webapi/pressKeyUp.mustache +0 -12
  227. package/docs/webapi/pressKeyWithKeyNormalization.mustache +0 -60
  228. package/docs/webapi/refreshPage.mustache +0 -6
  229. package/docs/webapi/resizeWindow.mustache +0 -6
  230. package/docs/webapi/rightClick.mustache +0 -14
  231. package/docs/webapi/saveElementScreenshot.mustache +0 -10
  232. package/docs/webapi/saveScreenshot.mustache +0 -12
  233. package/docs/webapi/say.mustache +0 -10
  234. package/docs/webapi/scrollIntoView.mustache +0 -11
  235. package/docs/webapi/scrollPageToBottom.mustache +0 -6
  236. package/docs/webapi/scrollPageToTop.mustache +0 -6
  237. package/docs/webapi/scrollTo.mustache +0 -12
  238. package/docs/webapi/see.mustache +0 -11
  239. package/docs/webapi/seeAttributesOnElements.mustache +0 -9
  240. package/docs/webapi/seeCheckboxIsChecked.mustache +0 -10
  241. package/docs/webapi/seeCookie.mustache +0 -8
  242. package/docs/webapi/seeCssPropertiesOnElements.mustache +0 -9
  243. package/docs/webapi/seeCurrentPathEquals.mustache +0 -10
  244. package/docs/webapi/seeCurrentUrlEquals.mustache +0 -11
  245. package/docs/webapi/seeElement.mustache +0 -8
  246. package/docs/webapi/seeElementInDOM.mustache +0 -8
  247. package/docs/webapi/seeInCurrentUrl.mustache +0 -8
  248. package/docs/webapi/seeInField.mustache +0 -12
  249. package/docs/webapi/seeInPopup.mustache +0 -8
  250. package/docs/webapi/seeInSource.mustache +0 -7
  251. package/docs/webapi/seeInTitle.mustache +0 -8
  252. package/docs/webapi/seeNumberOfElements.mustache +0 -11
  253. package/docs/webapi/seeNumberOfVisibleElements.mustache +0 -10
  254. package/docs/webapi/seeTextEquals.mustache +0 -9
  255. package/docs/webapi/seeTitleEquals.mustache +0 -8
  256. package/docs/webapi/seeTraffic.mustache +0 -36
  257. package/docs/webapi/selectOption.mustache +0 -21
  258. package/docs/webapi/setCookie.mustache +0 -16
  259. package/docs/webapi/setGeoLocation.mustache +0 -12
  260. package/docs/webapi/startRecordingTraffic.mustache +0 -8
  261. package/docs/webapi/startRecordingWebSocketMessages.mustache +0 -8
  262. package/docs/webapi/stopRecordingTraffic.mustache +0 -5
  263. package/docs/webapi/stopRecordingWebSocketMessages.mustache +0 -7
  264. package/docs/webapi/switchTo.mustache +0 -9
  265. package/docs/webapi/switchToNextTab.mustache +0 -10
  266. package/docs/webapi/switchToPreviousTab.mustache +0 -10
  267. package/docs/webapi/type.mustache +0 -21
  268. package/docs/webapi/uncheckOption.mustache +0 -13
  269. package/docs/webapi/wait.mustache +0 -8
  270. package/docs/webapi/waitForClickable.mustache +0 -11
  271. package/docs/webapi/waitForCookie.mustache +0 -9
  272. package/docs/webapi/waitForDetached.mustache +0 -10
  273. package/docs/webapi/waitForDisabled.mustache +0 -6
  274. package/docs/webapi/waitForElement.mustache +0 -11
  275. package/docs/webapi/waitForEnabled.mustache +0 -6
  276. package/docs/webapi/waitForFunction.mustache +0 -17
  277. package/docs/webapi/waitForInvisible.mustache +0 -10
  278. package/docs/webapi/waitForNumberOfTabs.mustache +0 -9
  279. package/docs/webapi/waitForText.mustache +0 -13
  280. package/docs/webapi/waitForValue.mustache +0 -10
  281. package/docs/webapi/waitForVisible.mustache +0 -10
  282. package/docs/webapi/waitInUrl.mustache +0 -9
  283. package/docs/webapi/waitNumberOfVisibleElements.mustache +0 -10
  284. package/docs/webapi/waitToHide.mustache +0 -10
  285. package/docs/webapi/waitUrlEquals.mustache +0 -10
  286. package/lib/helper/AI.js +0 -214
  287. package/lib/listener/enhancedGlobalRetry.js +0 -110
  288. package/lib/plugin/enhancedRetryFailedStep.js +0 -99
  289. package/lib/plugin/htmlReporter.js +0 -3648
  290. package/lib/plugin/stepByStepReport.js +0 -427
  291. package/lib/plugin/subtitles.js +0 -89
  292. package/lib/retryCoordinator.js +0 -207
  293. package/typings/promiseBasedTypes.d.ts +0 -9469
  294. package/typings/types.d.ts +0 -11402
@@ -0,0 +1,1187 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
5
+ import Codecept from '../lib/codecept.js'
6
+ import container from '../lib/container.js'
7
+ import { getParamsToString } from '../lib/parser.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'
19
+ import event from '../lib/event.js'
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'
29
+ import { dirname, resolve as resolvePath } from 'path'
30
+ import path from 'path'
31
+ import { spawn } from 'child_process'
32
+ import { createRequire } from 'module'
33
+ import { existsSync, readdirSync } from 'fs'
34
+ import { mkdirp } from 'mkdirp'
35
+
36
+ const require = createRequire(import.meta.url)
37
+
38
+ const __filename = fileURLToPath(import.meta.url)
39
+ const __dirname = dirname(__filename)
40
+
41
+ let codecept = null
42
+ let containerInitialized = false
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
+ }
167
+
168
+ let runLock = Promise.resolve()
169
+ async function withLock(fn) {
170
+ const prev = runLock
171
+ let release
172
+ runLock = new Promise(r => (release = r))
173
+ await prev
174
+ try { return await fn() }
175
+ finally { release() }
176
+ }
177
+
178
+ async function withSilencedIO(fn) {
179
+ const origOut = process.stdout.write.bind(process.stdout)
180
+ const origErr = process.stderr.write.bind(process.stderr)
181
+
182
+ process.stdout.write = () => true
183
+ process.stderr.write = () => true
184
+
185
+ try {
186
+ return await fn()
187
+ } finally {
188
+ process.stdout.write = origOut
189
+ process.stderr.write = origErr
190
+ }
191
+ }
192
+
193
+ function runCmd(cmd, args, { cwd = process.cwd(), timeout = 60000 } = {}) {
194
+ return new Promise((resolve, reject) => {
195
+ const child = spawn(cmd, args, {
196
+ cwd,
197
+ env: { ...process.env, NODE_ENV: process.env.NODE_ENV || 'test' },
198
+ stdio: ['ignore', 'pipe', 'pipe'],
199
+ })
200
+
201
+ let out = ''
202
+ let err = ''
203
+
204
+ const t = setTimeout(() => {
205
+ child.kill('SIGKILL')
206
+ reject(new Error(`Timeout after ${timeout}ms`))
207
+ }, timeout)
208
+
209
+ child.stdout.on('data', d => (out += d.toString('utf8')))
210
+ child.stderr.on('data', d => (err += d.toString('utf8')))
211
+
212
+ child.on('error', e => {
213
+ clearTimeout(t)
214
+ reject(e)
215
+ })
216
+
217
+ child.on('close', code => {
218
+ clearTimeout(t)
219
+ resolve({ code, out, err })
220
+ })
221
+ })
222
+ }
223
+
224
+ function resolveConfigPath(configPath) {
225
+ const cwd = process.cwd()
226
+ const envRoot = process.env.CODECEPTJS_PROJECT_DIR
227
+
228
+ if (configPath && !path.isAbsolute(configPath)) {
229
+ const base = envRoot || cwd
230
+ configPath = path.resolve(base, configPath)
231
+ }
232
+
233
+ if (!configPath) {
234
+ const base = envRoot || cwd
235
+ configPath = process.env.CODECEPTJS_CONFIG || path.resolve(base, 'codecept.conf.js')
236
+ if (!existsSync(configPath)) configPath = path.resolve(base, 'codecept.conf.cjs')
237
+ }
238
+
239
+ if (!existsSync(configPath)) {
240
+ throw new Error(
241
+ `CodeceptJS config not found: ${configPath}\n` +
242
+ `CODECEPTJS_CONFIG=${process.env.CODECEPTJS_CONFIG || 'not set'}\n` +
243
+ `CODECEPTJS_PROJECT_DIR=${process.env.CODECEPTJS_PROJECT_DIR || 'not set'}\n` +
244
+ `cwd=${cwd}`
245
+ )
246
+ }
247
+
248
+ return { configPath, configDir: path.dirname(configPath) }
249
+ }
250
+
251
+ function findCodeceptCliUpwards(startDir, { maxUp = 8 } = {}) {
252
+ let dir = startDir
253
+
254
+ for (let i = 0; i <= maxUp; i++) {
255
+ const candidates = [
256
+ path.resolve(dir, 'bin', 'codecept.js'),
257
+ path.resolve(dir, 'node_modules', 'codeceptjs', 'bin', 'codecept.js'),
258
+ path.resolve(dir, 'node_modules', '.bin', 'codeceptjs.cmd'),
259
+ path.resolve(dir, 'node_modules', '.bin', 'codeceptjs'),
260
+ ]
261
+
262
+ for (const p of candidates) {
263
+ if (existsSync(p)) return { cli: p, root: dir }
264
+ }
265
+
266
+ try {
267
+ const pkgJson = require.resolve('codeceptjs/package.json', { paths: [dir] })
268
+ const pkgDir = path.dirname(pkgJson)
269
+ const jsCli = path.resolve(pkgDir, 'bin', 'codecept.js')
270
+ if (existsSync(jsCli)) return { cli: jsCli, root: dir }
271
+ } catch {}
272
+
273
+ const parent = path.dirname(dir)
274
+ if (parent === dir) break
275
+ dir = parent
276
+ }
277
+
278
+ throw new Error(`Cannot find CodeceptJS CLI walking up from: ${startDir}`)
279
+ }
280
+
281
+ function looksLikePath(v) {
282
+ return typeof v === 'string' && (
283
+ v.includes('/') || v.includes('\\') ||
284
+ v.endsWith('.js') || v.endsWith('.ts')
285
+ )
286
+ }
287
+
288
+ function normalizePath(p) {
289
+ return String(p).replace(/\\/g, '/')
290
+ }
291
+
292
+ function findFileByBasename(rootDir, baseNames, { maxDepth = 8 } = {}) {
293
+ const targets = new Set(baseNames.map(x => x.toLowerCase()))
294
+
295
+ function walk(dir, depth) {
296
+ if (depth > maxDepth) return null
297
+
298
+ let entries
299
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return null }
300
+
301
+ for (const e of entries) {
302
+ const full = path.join(dir, e.name)
303
+
304
+ if (e.isDirectory()) {
305
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === 'output') continue
306
+ const res = walk(full, depth + 1)
307
+ if (res) return res
308
+ continue
309
+ }
310
+
311
+ if (targets.has(e.name.toLowerCase())) return full
312
+ }
313
+
314
+ return null
315
+ }
316
+
317
+ return walk(rootDir, 0)
318
+ }
319
+
320
+ async function listTestsJson({ cli, root, configPath }) {
321
+ const args = ['list', '--config', configPath, '--json']
322
+ const isNodeScript = cli.endsWith('.js')
323
+
324
+ const res = isNodeScript
325
+ ? await runCmd(process.execPath, [cli, ...args], { cwd: root, timeout: 60000 })
326
+ : await runCmd(cli, args, { cwd: root, timeout: 60000 })
327
+
328
+ const out = (res.out || '').trim()
329
+ try { return JSON.parse(out) } catch { return null }
330
+ }
331
+
332
+ function extractFilesFromListJson(json) {
333
+ if (!json) return []
334
+ if (Array.isArray(json)) return json.map(String)
335
+ if (Array.isArray(json.tests)) return json.tests.map(String)
336
+ if (Array.isArray(json.files)) return json.files.map(String)
337
+ if (Array.isArray(json.testFiles)) return json.testFiles.map(String)
338
+ return []
339
+ }
340
+
341
+ async function resolveTestToFile({ cli, root, configPath, test }) {
342
+ if (looksLikePath(test)) return test
343
+
344
+ const raw = String(test).trim()
345
+ const candidates = [
346
+ raw,
347
+ `${raw}.js`,
348
+ `${raw}.ts`,
349
+ `${raw}_test.js`,
350
+ `${raw}.test.js`,
351
+ ].map(x => x.toLowerCase())
352
+
353
+ const json = await listTestsJson({ cli, root, configPath })
354
+ const files = extractFilesFromListJson(json).map(normalizePath)
355
+
356
+ if (files.length) {
357
+ const byName = files.find(f => candidates.some(c => path.basename(f).toLowerCase() === c))
358
+ if (byName) return byName
359
+
360
+ const byContains = files.find(f => f.toLowerCase().includes(raw.toLowerCase()))
361
+ if (byContains) return byContains
362
+ }
363
+
364
+ const fsFound = findFileByBasename(root, candidates)
365
+ return fsFound ? normalizePath(fsFound) : null
366
+ }
367
+
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 waitForTestResult(runPromise, timeout) {
416
+ const pausedPromise = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused')))
417
+ const completedPromise = runPromise.then(() => 'completed', () => 'completed')
418
+ let timeoutId
419
+ const timeoutPromise = new Promise((_, reject) => {
420
+ timeoutId = setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)
421
+ })
422
+ try {
423
+ return { status: await Promise.race([completedPromise, pausedPromise, timeoutPromise]) }
424
+ } catch (err) {
425
+ await cancelRun()
426
+ return { status: 'aborted', error: err.message }
427
+ } finally {
428
+ clearTimeout(timeoutId)
429
+ }
430
+ }
431
+
432
+ async function closeBrowser() {
433
+ if (!containerInitialized) return
434
+ await cancelRun()
435
+ await endShellSession()
436
+ for (const helper of Object.values(container.helpers() || {})) {
437
+ try { if (helper._cleanup) await helper._cleanup() } catch {}
438
+ try { if (helper._finishTest) await helper._finishTest() } catch {}
439
+ }
440
+ browserStarted = false
441
+ }
442
+
443
+ async function captureLiveArtifacts(prefix = 'pause') {
444
+ const helper = pickActingHelper(container.helpers())
445
+ if (!helper) return {}
446
+ const dir = snapshotDirFor(outputBaseDir())
447
+ mkdirp.sync(dir)
448
+ const captured = await captureSnapshot(helper, { dir, prefix })
449
+ return artifactsToFileUrls(captured, dir)
450
+ }
451
+
452
+ async function gatherPageBrief() {
453
+ const helper = pickActingHelper(container.helpers())
454
+ if (!helper) return {}
455
+ const out = {}
456
+ try { if (helper.grabCurrentUrl) out.url = await helper.grabCurrentUrl() } catch {}
457
+ try { if (helper.grabTitle) out.title = await helper.grabTitle() } catch {}
458
+ try {
459
+ if (helper.grabSource) {
460
+ const html = await helper.grabSource()
461
+ out.contentSize = typeof html === 'string' ? html.length : null
462
+ }
463
+ } catch {}
464
+ return out
465
+ }
466
+
467
+ function collectRunCompletion(errorMessage) {
468
+ const results = pendingRunResults || []
469
+ const stats = {
470
+ tests: results.length,
471
+ passes: results.filter(r => r.status === 'passed').length,
472
+ failures: results.filter(r => r.status === 'failed').length,
473
+ }
474
+ if (typeof pendingRunCleanup === 'function') pendingRunCleanup()
475
+ pendingRunPromise = null
476
+ pendingRunResults = null
477
+ pendingTestFile = null
478
+ pendingStepInfo = null
479
+ let error = errorMessage || null
480
+ if (!error && results.length === 0) {
481
+ 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.'
482
+ }
483
+ return {
484
+ status: error ? 'failed' : 'completed',
485
+ aiTraceDir: currentAiTraceDir,
486
+ reporterJson: { stats, tests: results },
487
+ error,
488
+ aiTraceHint: aiTraceHint(),
489
+ }
490
+ }
491
+
492
+ function pausedPayload() {
493
+ return {
494
+ status: 'paused',
495
+ file: pendingTestFile,
496
+ aiTraceDir: currentAiTraceDir,
497
+ pausedAfter: pendingStepInfo,
498
+ suggestions: [
499
+ 'Call snapshot to capture URL/HTML/ARIA/screenshot/console/storage at this point',
500
+ 'Call run_code to inspect or manipulate state (e.g. return await I.grabText("h1"))',
501
+ 'Call continue to release the pause and let the test run the next step (or finish)',
502
+ 'Query a saved step snapshot offline: codeceptq <locator> --file <aiTraceDir>/<NNNN>_<step>_page.html',
503
+ ],
504
+ }
505
+ }
506
+
507
+ async function initCodecept(configPath, pluginOverrides) {
508
+ const plugins = normalizePluginOverrides(pluginOverrides)
509
+ const sig = pluginsSignature(plugins)
510
+
511
+ if (containerInitialized) {
512
+ if (!Object.keys(plugins).length || sig === currentPluginsSig) return
513
+ await teardownContainer()
514
+ }
515
+
516
+ const testRoot = process.env.CODECEPTJS_PROJECT_DIR || process.cwd()
517
+
518
+ if (!configPath) {
519
+ configPath = process.env.CODECEPTJS_CONFIG || resolvePath(testRoot, 'codecept.conf.js')
520
+ if (!existsSync(configPath)) configPath = resolvePath(testRoot, 'codecept.conf.cjs')
521
+ }
522
+
523
+ if (!existsSync(configPath)) {
524
+ throw new Error(
525
+ `CodeceptJS config not found: ${configPath}\n` +
526
+ `CODECEPTJS_CONFIG=${process.env.CODECEPTJS_CONFIG || 'not set'}\n` +
527
+ `CODECEPTJS_PROJECT_DIR=${process.env.CODECEPTJS_PROJECT_DIR || 'not set'}\n` +
528
+ `cwd=${process.cwd()}`
529
+ )
530
+ }
531
+
532
+ console.log = () => {}
533
+ console.error = () => {}
534
+ console.warn = () => {}
535
+
536
+ const { getConfig } = await import('../lib/command/utils.js')
537
+ const config = await getConfig(configPath)
538
+
539
+ // aiTrace is the canonical per-step ARIA/HTML/screenshot capture for MCP.
540
+ // Always on so run_code / continue can read the latest snapshot from disk
541
+ // instead of double-capturing through grabAriaSnapshot etc.
542
+ applyPluginOverrides(config, { aiTrace: { on: 'step' }, browser: { show: false }, ...plugins })
543
+
544
+ codecept = new Codecept(config, {})
545
+ await codecept.init(testRoot)
546
+ await container.started()
547
+
548
+ containerInitialized = true
549
+ browserStarted = true
550
+ aiTraceEnabled = config.plugins?.aiTrace?.enabled === true
551
+ currentPluginsSig = sig
552
+ }
553
+
554
+ async function formatReturnValue(value) {
555
+ if (value instanceof WebElement) return await value.describe()
556
+ if (Array.isArray(value) && value.length && value.every(v => v instanceof WebElement)) {
557
+ return await Promise.all(value.map(v => v.describe()))
558
+ }
559
+ return value
560
+ }
561
+
562
+ const server = new Server(
563
+ { name: 'codeceptjs-mcp-server', version: '1.0.0' },
564
+ { capabilities: { tools: {} } }
565
+ )
566
+
567
+ const PLUGINS_PROP = {
568
+ type: 'object',
569
+ 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' +
570
+ ' • { browser: { show: true } } — visible browser (headed)\n' +
571
+ ' • { browser: { show: false } } — headless\n' +
572
+ ' • { browser: { browser: "firefox", windowSize: "1280x720" } } — switch browser + viewport\n' +
573
+ ' • { pause: { on: "fail" } } / { screenshot: { on: "step" } } / { aiTrace: {} }\n' +
574
+ 'Override or add to whatever the project config already enables.',
575
+ additionalProperties: { type: 'object' },
576
+ }
577
+
578
+ const CONFIG_PROP = {
579
+ type: 'string',
580
+ 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.',
581
+ }
582
+
583
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
584
+ tools: [
585
+ {
586
+ name: 'list_tests',
587
+ description: 'List all tests in the CodeceptJS project. Uses the active session if start_browser was called, otherwise auto-inits with project defaults.',
588
+ inputSchema: { type: 'object', properties: {} },
589
+ },
590
+ {
591
+ name: 'list_actions',
592
+ description: 'List all available CodeceptJS actions (I.* methods). Uses the active session if start_browser was called, otherwise auto-inits with project defaults.',
593
+ inputSchema: { type: 'object', properties: {} },
594
+ },
595
+ {
596
+ name: 'run_code',
597
+ description: 'Run arbitrary CodeceptJS code. Response includes `availableObjects` listing every symbol in scope (I, helpers, container, step, tryTo, within, etc.).',
598
+ inputSchema: {
599
+ type: 'object',
600
+ properties: {
601
+ code: { type: 'string' },
602
+ timeout: { type: 'number' },
603
+ saveArtifacts: { type: 'boolean' },
604
+ 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.' },
605
+ },
606
+ required: ['code'],
607
+ },
608
+ },
609
+ {
610
+ name: 'run_test',
611
+ 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).',
612
+ inputSchema: {
613
+ type: 'object',
614
+ properties: {
615
+ test: { type: 'string' },
616
+ timeout: { type: 'number' },
617
+ grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
618
+ pauseAt: {
619
+ 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".',
620
+ oneOf: [{ type: 'number' }, { type: 'string' }],
621
+ },
622
+ plugins: PLUGINS_PROP,
623
+ },
624
+ required: ['test'],
625
+ },
626
+ },
627
+ {
628
+ name: 'run_step_by_step',
629
+ 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.',
630
+ inputSchema: {
631
+ type: 'object',
632
+ properties: {
633
+ test: { type: 'string' },
634
+ timeout: { type: 'number' },
635
+ grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
636
+ plugins: PLUGINS_PROP,
637
+ },
638
+ required: ['test'],
639
+ },
640
+ },
641
+ {
642
+ name: 'start_browser',
643
+ 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' +
644
+ 'MCP enforces two plugin defaults so the agent gets useful telemetry:\n' +
645
+ ' • aiTrace: { on: "step", enabled: true } — per-step DOM/ARIA/console/screenshot traces for debugging\n' +
646
+ ' • browser: { show: false, enabled: true } — headless by default\n' +
647
+ '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.',
648
+ inputSchema: {
649
+ type: 'object',
650
+ properties: {
651
+ config: CONFIG_PROP,
652
+ plugins: PLUGINS_PROP,
653
+ },
654
+ },
655
+ },
656
+ {
657
+ name: 'stop_browser',
658
+ description: 'Stop the session, close browsers, and tear down the container. Required before re-initing with different config or plugins.',
659
+ inputSchema: { type: 'object', properties: {} },
660
+ },
661
+ {
662
+ name: 'snapshot',
663
+ 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.',
664
+ inputSchema: {
665
+ type: 'object',
666
+ properties: {
667
+ fullPage: { type: 'boolean' },
668
+ 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.' },
669
+ },
670
+ },
671
+ },
672
+ {
673
+ name: 'continue',
674
+ 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.',
675
+ inputSchema: {
676
+ type: 'object',
677
+ properties: {
678
+ timeout: { type: 'number' },
679
+ },
680
+ },
681
+ },
682
+ {
683
+ name: 'cancel',
684
+ 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.',
685
+ inputSchema: { type: 'object', properties: {} },
686
+ },
687
+ ],
688
+ }))
689
+
690
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
691
+ const { name, arguments: args } = request.params
692
+
693
+ try {
694
+ switch (name) {
695
+ case 'list_tests': {
696
+ await initCodecept()
697
+
698
+ codecept.loadTests()
699
+ const tests = codecept.testFiles.map(testFile => {
700
+ const relativePath = testFile.replace(process.cwd(), '').replace(/\\/g, '/')
701
+ return {
702
+ file: testFile,
703
+ relativePath: relativePath.startsWith('/') ? relativePath.slice(1) : relativePath,
704
+ }
705
+ })
706
+
707
+ return { content: [{ type: 'text', text: JSON.stringify({ count: tests.length, tests }, null, 2) }] }
708
+ }
709
+
710
+ case 'list_actions': {
711
+ await initCodecept()
712
+
713
+ const helpers = container.helpers()
714
+ const supportI = container.support('I')
715
+ const actions = []
716
+ const actionDetails = []
717
+
718
+ for (const helperName in helpers) {
719
+ const helper = helpers[helperName]
720
+ methodsOfObject(helper).forEach(action => {
721
+ if (actions.includes(action)) return
722
+ actions.push(action)
723
+ const params = getParamsToString(helper[action])
724
+ actionDetails.push({ helper: helperName, action, signature: `I.${action}(${params})` })
725
+ })
726
+ }
727
+
728
+ for (const n in supportI) {
729
+ if (actions.includes(n)) continue
730
+ const actor = supportI[n]
731
+ const params = getParamsToString(actor)
732
+ actionDetails.push({ helper: 'SupportObject', action: n, signature: `I.${n}(${params})` })
733
+ }
734
+
735
+ return { content: [{ type: 'text', text: JSON.stringify({ count: actionDetails.length, actions: actionDetails }, null, 2) }] }
736
+ }
737
+
738
+ case 'start_browser': {
739
+ const { config: configPath, plugins } = args || {}
740
+ if (browserStarted && shellSessionActive) {
741
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session already active', plugins: plugins ?? null }, null, 2) }] }
742
+ }
743
+ await initCodecept(configPath, plugins)
744
+ if (containerInitialized && !browserStarted) {
745
+ for (const helper of Object.values(container.helpers() || {})) {
746
+ try { if (helper._beforeSuite) await helper._beforeSuite() } catch {}
747
+ }
748
+ browserStarted = true
749
+ }
750
+ await startShellSession()
751
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session started — run_code and snapshot are now available', plugins: plugins ?? null }, null, 2) }] }
752
+ }
753
+
754
+ case 'stop_browser': {
755
+ if (!containerInitialized) {
756
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser not initialized' }, null, 2) }] }
757
+ }
758
+ await closeBrowser()
759
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped — Mocha and config preserved; call start_browser to reopen' }, null, 2) }] }
760
+ }
761
+
762
+ case 'snapshot': {
763
+ const { fullPage = false, settleMs = 300 } = args || {}
764
+ await initCodecept()
765
+ await ensureSession()
766
+
767
+ const helper = pickActingHelper(container.helpers())
768
+ if (!helper) throw new Error('No supported acting helper available (Playwright, Puppeteer, WebDriver).')
769
+
770
+ const dir = snapshotDirFor(outputBaseDir())
771
+ mkdirp.sync(dir)
772
+
773
+ if (settleMs > 0) await new Promise(r => setTimeout(r, settleMs))
774
+ const captured = await captureSnapshot(helper, { dir, prefix: 'snapshot', fullPage })
775
+ const traceFile = writeTraceMarkdown({
776
+ dir,
777
+ title: 'snapshot',
778
+ file: 'mcp',
779
+ durationMs: 0,
780
+ commands: [],
781
+ captured,
782
+ })
783
+
784
+ return {
785
+ content: [{
786
+ type: 'text',
787
+ text: JSON.stringify({
788
+ status: 'success',
789
+ dir,
790
+ traceFile: pathToFileURL(traceFile).href,
791
+ artifacts: artifactsToFileUrls(captured, dir),
792
+ aiTraceHint: aiTraceHint(),
793
+ }, null, 2),
794
+ }],
795
+ }
796
+ }
797
+
798
+ case 'continue': {
799
+ 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.')
800
+ const { timeout = 60000 } = args || {}
801
+ return await withSilencedIO(async () => {
802
+ pausedController.resolveContinue()
803
+ if (!pendingRunPromise) {
804
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'continued' }, null, 2) }] }
805
+ }
806
+
807
+ // Race: test pauses again (step-by-step or another pause()) vs test finishes.
808
+ const pausedAgain = new Promise(resolve => pauseEvents.once('paused', () => resolve('paused')))
809
+ const completed = pendingRunPromise.then(() => 'completed', () => 'completed')
810
+ const which = await Promise.race([
811
+ pausedAgain,
812
+ completed,
813
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
814
+ ])
815
+
816
+ if (which === 'paused') {
817
+ const page = await gatherPageBrief()
818
+ return { content: [{ type: 'text', text: JSON.stringify({ ...pausedPayload(), page }, null, 2) }] }
819
+ }
820
+
821
+ let runError = null
822
+ try { await pendingRunPromise } catch (err) { runError = err }
823
+ const file = pendingTestFile
824
+ const final = collectRunCompletion(runError?.message)
825
+ return { content: [{ type: 'text', text: JSON.stringify({ ...final, file }, null, 2) }] }
826
+ })
827
+ }
828
+
829
+ case 'cancel': {
830
+ const cancelled = await cancelRun()
831
+ await ensureSession()
832
+ return { content: [{ type: 'text', text: JSON.stringify({ status: cancelled ? 'Run cancelled — browser kept open' : 'No run in progress' }, null, 2) }] }
833
+ }
834
+
835
+ case 'run_code': {
836
+ const { code, timeout = 60000, saveArtifacts = true, settleMs = 300 } = args
837
+ await initCodecept()
838
+ await ensureSession()
839
+
840
+ const support = container.supportObjects() || {}
841
+ if (!support.I) throw new Error('I object not available. Make sure helpers are configured.')
842
+
843
+ const result = { status: 'unknown', output: '', error: null, commands: [], artifacts: {} }
844
+
845
+ const commands = []
846
+ let lastStepValue
847
+ const onStepAfter = step => {
848
+ try { commands.push(step.toString()) } catch {}
849
+ }
850
+ const onStepPassed = (step, val) => {
851
+ if (val !== undefined) lastStepValue = val
852
+ }
853
+ event.dispatcher.on(event.step.after, onStepAfter)
854
+ event.dispatcher.on(event.step.passed, onStepPassed)
855
+
856
+ const traceDir = traceDirFor(`mcp_${Date.now()}`, 'run_code', outputBaseDir())
857
+ mkdirp.sync(traceDir)
858
+ const startedAt = Date.now()
859
+
860
+ // Pin the latest aiTrace ARIA file before running the code, so we
861
+ // can diff after. aiTrace owns per-step capture; we just read it.
862
+ const reader = new TraceReader(currentAiTraceDir)
863
+ const ariaBefore = reader.last('aria')
864
+
865
+ const MAX_LOG_ENTRIES = 100
866
+ const MAX_LOG_MSG_BYTES = 2000
867
+ const MAX_RETURN_BYTES = 20000
868
+ const consoleLogs = []
869
+ const consoleMethods = ['log', 'info', 'warn', 'error', 'debug']
870
+ const origConsoleMethods = {}
871
+ const captureLog = level => (...args) => {
872
+ if (consoleLogs.length >= MAX_LOG_ENTRIES) return
873
+ const message = args.map(a => {
874
+ if (typeof a === 'string') return a
875
+ return truncateString(safeStringify(a, [], 2), MAX_LOG_MSG_BYTES).value
876
+ }).join(' ')
877
+ consoleLogs.push({ level, message, t: Date.now() - startedAt })
878
+ }
879
+ for (const m of consoleMethods) {
880
+ origConsoleMethods[m] = console[m]
881
+ console[m] = captureLog(m)
882
+ }
883
+
884
+ const scope = {
885
+ locate, within, session, secret, inject, pause, share: container.share,
886
+ tryTo, retryTo, hopeThat,
887
+ step, element, eachElement, expectElement, expectAnyElement, expectAllElements,
888
+ container, helpers: container.helpers(),
889
+ ...support,
890
+ }
891
+ const paramNames = ['I', ...Object.keys(scope).filter(k => k !== 'I').sort()]
892
+ const paramValues = paramNames.map(k => scope[k])
893
+
894
+ const wasPaused = !!pausedController
895
+ if (wasPaused) recorder.session.start('mcp_run_code')
896
+
897
+ let returnValue
898
+ try {
899
+ const asyncFn = new Function(...paramNames, `return (async () => { ${code} })()`)
900
+ returnValue = await Promise.race([
901
+ asyncFn(...paramValues),
902
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
903
+ ])
904
+ await recorder.promise()
905
+
906
+ result.status = 'success'
907
+ result.output = 'Code executed successfully'
908
+ } catch (error) {
909
+ result.status = 'failed'
910
+ result.error = error.message
911
+ result.output = error.stack || error.message
912
+ } finally {
913
+ for (const m of consoleMethods) console[m] = origConsoleMethods[m]
914
+ try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
915
+ try { event.dispatcher.removeListener(event.step.passed, onStepPassed) } catch {}
916
+ if (wasPaused) {
917
+ try { recorder.session.restore('mcp_run_code') } catch {}
918
+ } else {
919
+ try { recorder.reset() } catch {}
920
+ }
921
+ }
922
+
923
+ result.commands = commands
924
+ result.logs = consoleLogs
925
+ if (consoleLogs.length === MAX_LOG_ENTRIES) result.logsTruncated = true
926
+ result.availableObjects = paramNames
927
+
928
+ if (returnValue === undefined) returnValue = await Promise.resolve(lastStepValue)
929
+ returnValue = await formatReturnValue(returnValue)
930
+
931
+ if (returnValue !== undefined) {
932
+ const json = typeof returnValue === 'string' ? returnValue : safeStringify(returnValue, [], 2)
933
+ const stringified = truncateString(json, MAX_RETURN_BYTES)
934
+ result.returnValue = stringified.value
935
+ if (stringified.truncated) result.returnValueTruncated = true
936
+ }
937
+
938
+ let captured = {}
939
+ if (saveArtifacts) {
940
+ const helper = pickActingHelper(container.helpers())
941
+ if (helper) {
942
+ try {
943
+ if (settleMs > 0) await new Promise(r => setTimeout(r, settleMs))
944
+ captured = await captureSnapshot(helper, { dir: traceDir, prefix: 'mcp' })
945
+ result.artifacts = artifactsToFileUrls(captured, traceDir)
946
+ } catch (e) {
947
+ result.output += ` (Warning: ${e.message})`
948
+ }
949
+ }
950
+ }
951
+
952
+ // Diff against the latest aiTrace ARIA file produced by the steps
953
+ // that just ran inside this run_code call.
954
+ const ariaAfter = reader.last('aria')
955
+ if (ariaBefore && ariaAfter && ariaBefore !== ariaAfter) {
956
+ const diff = ariaDiff(ariaBefore, ariaAfter)
957
+ if (diff) result.ariaDiff = diff
958
+ }
959
+
960
+ const traceFile = writeTraceMarkdown({
961
+ dir: traceDir,
962
+ title: 'run_code',
963
+ file: 'mcp',
964
+ durationMs: Date.now() - startedAt,
965
+ commands,
966
+ captured,
967
+ error: result.error,
968
+ })
969
+ result.dir = traceDir
970
+ result.traceFile = pathToFileURL(traceFile).href
971
+ result.aiTraceHint = aiTraceHint()
972
+
973
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
974
+ }
975
+
976
+ case 'run_test': {
977
+ return await withLock(async () => {
978
+ if (pausedController) {
979
+ throw new Error('A previous run_test is still paused. Call "continue" first.')
980
+ }
981
+ const { test, timeout = 60000, pauseAt, grep, plugins } = args || {}
982
+ await initCodecept(undefined, plugins)
983
+ await endShellSession()
984
+ applyMochaGrep(grep)
985
+
986
+ return await withSilencedIO(async () => {
987
+ codecept.loadTests()
988
+
989
+ let testFiles = codecept.testFiles
990
+ if (test) {
991
+ const testName = normalizePath(test).toLowerCase()
992
+ testFiles = codecept.testFiles.filter(f => {
993
+ const filePath = normalizePath(f).toLowerCase()
994
+ return filePath.includes(testName) || filePath.endsWith(testName)
995
+ })
996
+ }
997
+
998
+ if (!testFiles.length) throw new Error(`No tests found matching: ${test}`)
999
+ const testFile = testFiles[0]
1000
+
1001
+ pendingRunResults = []
1002
+ pendingTestFile = testFile
1003
+ pendingStepInfo = null
1004
+ let stepIndex = 0
1005
+ const matchPauseAt = pauseAtMatcher(pauseAt)
1006
+
1007
+ const onAfter = t => {
1008
+ const aiTrace = t.artifacts?.aiTrace
1009
+ pendingRunResults.push({
1010
+ title: t.title,
1011
+ file: t.file,
1012
+ status: t.err ? 'failed' : 'passed',
1013
+ error: t.err?.message,
1014
+ duration: t.duration,
1015
+ traceFile: aiTrace ? pathToFileURL(aiTrace).href : null,
1016
+ })
1017
+ }
1018
+ const onStepAfter = step => {
1019
+ stepIndex += 1
1020
+ const idx = stepIndex
1021
+ const name = (() => { try { return step.toString() } catch { return '' } })()
1022
+ recorder.add('mcp pause info', () => {
1023
+ pendingStepInfo = { index: idx, name, status: step.status }
1024
+ })
1025
+ if (matchPauseAt(idx, name)) pauseNow()
1026
+ }
1027
+ event.dispatcher.on(event.test.after, onAfter)
1028
+ event.dispatcher.on(event.step.after, onStepAfter)
1029
+ pendingRunCleanup = () => {
1030
+ try { event.dispatcher.removeListener(event.test.after, onAfter) } catch {}
1031
+ try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
1032
+ pendingRunCleanup = null
1033
+ }
1034
+
1035
+ let runError = null
1036
+ const runPromise = (async () => {
1037
+ try {
1038
+ await ensureBootstrap()
1039
+ await codecept.run(testFile)
1040
+ } catch (err) {
1041
+ runError = err
1042
+ throw err
1043
+ }
1044
+ })()
1045
+ pendingRunPromise = runPromise
1046
+
1047
+ const result = await waitForTestResult(runPromise, timeout)
1048
+ if (result.status === 'aborted') {
1049
+ await startShellSession()
1050
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: result.error }, null, 2) }] }
1051
+ }
1052
+
1053
+ if (result.status === 'paused') {
1054
+ const page = await gatherPageBrief()
1055
+ return {
1056
+ content: [{
1057
+ type: 'text',
1058
+ text: JSON.stringify({ ...pausedPayload(), page }, null, 2),
1059
+ }],
1060
+ }
1061
+ }
1062
+
1063
+ pendingRunPromise = null
1064
+ const final = collectRunCompletion(runError?.message)
1065
+ await startShellSession()
1066
+ return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
1067
+ })
1068
+ })
1069
+ }
1070
+
1071
+ case 'run_step_by_step': {
1072
+ return await withLock(async () => {
1073
+ if (pausedController) {
1074
+ throw new Error('A previous run is still paused. Call "continue" first.')
1075
+ }
1076
+ const { test, timeout = 60000, grep, plugins } = args || {}
1077
+ await initCodecept(undefined, plugins)
1078
+ await endShellSession()
1079
+ applyMochaGrep(grep)
1080
+
1081
+ return await withSilencedIO(async () => {
1082
+ codecept.loadTests()
1083
+
1084
+ let testFiles = codecept.testFiles
1085
+ if (test) {
1086
+ const testName = normalizePath(test).toLowerCase()
1087
+ testFiles = codecept.testFiles.filter(f => {
1088
+ const filePath = normalizePath(f).toLowerCase()
1089
+ return filePath.includes(testName) || filePath.endsWith(testName)
1090
+ })
1091
+ }
1092
+
1093
+ if (!testFiles.length) throw new Error(`No tests found matching: ${test}`)
1094
+ const testFile = testFiles[0]
1095
+
1096
+ pendingRunResults = []
1097
+ pendingTestFile = testFile
1098
+ pendingStepInfo = null
1099
+ let stepIndex = 0
1100
+
1101
+ const onAfter = t => {
1102
+ const aiTrace = t.artifacts?.aiTrace
1103
+ pendingRunResults.push({
1104
+ title: t.title,
1105
+ file: t.file,
1106
+ status: t.err ? 'failed' : 'passed',
1107
+ error: t.err?.message,
1108
+ duration: t.duration,
1109
+ traceFile: aiTrace ? pathToFileURL(aiTrace).href : null,
1110
+ })
1111
+ }
1112
+ const onStepAfter = step => {
1113
+ stepIndex += 1
1114
+ const idx = stepIndex
1115
+ const name = (() => { try { return step.toString() } catch { return '' } })()
1116
+ recorder.add('mcp pause info', () => {
1117
+ pendingStepInfo = { index: idx, name, status: step.status }
1118
+ })
1119
+ pauseNow()
1120
+ }
1121
+ event.dispatcher.on(event.test.after, onAfter)
1122
+ event.dispatcher.on(event.step.after, onStepAfter)
1123
+ pendingRunCleanup = () => {
1124
+ try { event.dispatcher.removeListener(event.test.after, onAfter) } catch {}
1125
+ try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
1126
+ pendingRunCleanup = null
1127
+ }
1128
+
1129
+ let runError = null
1130
+ const runPromise = (async () => {
1131
+ try {
1132
+ await ensureBootstrap()
1133
+ await codecept.run(testFile)
1134
+ } catch (err) {
1135
+ runError = err
1136
+ throw err
1137
+ }
1138
+ })()
1139
+ pendingRunPromise = runPromise
1140
+
1141
+ const result = await waitForTestResult(runPromise, timeout)
1142
+ if (result.status === 'aborted') {
1143
+ await startShellSession()
1144
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: result.error }, null, 2) }] }
1145
+ }
1146
+
1147
+ if (result.status === 'paused') {
1148
+ const page = await gatherPageBrief()
1149
+ return {
1150
+ content: [{
1151
+ type: 'text',
1152
+ text: JSON.stringify({ ...pausedPayload(), page }, null, 2),
1153
+ }],
1154
+ }
1155
+ }
1156
+
1157
+ pendingRunPromise = null
1158
+ const final = collectRunCompletion(runError?.message)
1159
+ await startShellSession()
1160
+ return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
1161
+ })
1162
+ })
1163
+ }
1164
+
1165
+ default:
1166
+ throw new Error(`Unknown tool: ${name}`)
1167
+ }
1168
+ } catch (error) {
1169
+ return {
1170
+ content: [{ type: 'text', text: JSON.stringify({ error: error.message, stack: error.stack }, null, 2) }],
1171
+ isError: true,
1172
+ }
1173
+ }
1174
+ })
1175
+
1176
+ async function main() {
1177
+ installShutdownHooks()
1178
+ const transport = new StdioServerTransport()
1179
+ await server.connect(transport)
1180
+ }
1181
+
1182
+ main().catch((error) => {
1183
+ import('fs').then(fs => {
1184
+ const logFile = path.resolve(process.cwd(), 'mcp-server-error.log')
1185
+ fs.appendFileSync(logFile, `${new Date().toISOString()} - ${error.stack}\n`)
1186
+ })
1187
+ })