codeceptjs 4.0.0-rc.9 → 4.0.0

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 (314) hide show
  1. package/README.md +9 -10
  2. package/bin/codecept.js +15 -2
  3. package/bin/codeceptq.js +49 -0
  4. package/bin/mcp-server.js +751 -172
  5. package/docs/advanced.md +201 -0
  6. package/docs/agents.md +181 -0
  7. package/docs/ai.md +489 -0
  8. package/docs/aitrace.md +266 -0
  9. package/docs/api.md +332 -0
  10. package/docs/architecture.md +235 -0
  11. package/docs/assertions.md +415 -0
  12. package/docs/auth.md +318 -0
  13. package/docs/basics.md +424 -0
  14. package/docs/bdd.md +539 -0
  15. package/docs/best.md +240 -0
  16. package/docs/bootstrap.md +132 -0
  17. package/docs/commands.md +352 -0
  18. package/docs/community-helpers.md +63 -0
  19. package/docs/configuration.md +185 -0
  20. package/docs/continuous-integration.md +431 -0
  21. package/docs/custom-helpers.md +297 -0
  22. package/docs/data.md +448 -0
  23. package/docs/debugging.md +332 -0
  24. package/docs/detox.md +235 -0
  25. package/docs/docker.md +107 -0
  26. package/docs/effects.md +179 -0
  27. package/docs/element-based-testing.md +295 -0
  28. package/docs/element-selection.md +125 -0
  29. package/docs/els.md +328 -0
  30. package/docs/environment-variables.md +131 -0
  31. package/docs/examples.md +160 -0
  32. package/docs/heal.md +213 -0
  33. package/docs/helpers/ApiDataFactory.md +267 -0
  34. package/docs/helpers/Appium.md +1419 -0
  35. package/docs/helpers/Detox.md +665 -0
  36. package/docs/helpers/ExpectHelper.md +275 -0
  37. package/docs/helpers/FileSystem.md +152 -0
  38. package/docs/helpers/GraphQL.md +152 -0
  39. package/docs/helpers/GraphQLDataFactory.md +226 -0
  40. package/docs/helpers/JSONResponse.md +255 -0
  41. package/docs/helpers/MockRequest.md +377 -0
  42. package/docs/helpers/Playwright.md +2970 -0
  43. package/docs/helpers/Puppeteer-firefox.md +86 -0
  44. package/docs/helpers/Puppeteer.md +2583 -0
  45. package/docs/helpers/REST.md +289 -0
  46. package/docs/helpers/WebDriver.md +2639 -0
  47. package/docs/hooks.md +148 -0
  48. package/docs/index.md +111 -0
  49. package/docs/installation.md +121 -0
  50. package/docs/internal-test-server.md +89 -0
  51. package/docs/locators.md +355 -0
  52. package/docs/mcp.md +485 -0
  53. package/docs/migrate-from-cypress.md +98 -0
  54. package/docs/migrate-from-java.md +108 -0
  55. package/docs/migrate-from-protractor.md +101 -0
  56. package/docs/migrate-from-testcafe.md +99 -0
  57. package/docs/migration-4.md +743 -0
  58. package/docs/mobile.md +338 -0
  59. package/docs/pageobjects.md +399 -0
  60. package/docs/parallel.md +187 -0
  61. package/docs/playwright.md +714 -0
  62. package/docs/plugins/aiTrace.md +49 -0
  63. package/docs/plugins/analyze.md +66 -0
  64. package/docs/plugins/auth.md +241 -0
  65. package/docs/plugins/autoDelay.md +48 -0
  66. package/docs/plugins/browser.md +41 -0
  67. package/docs/plugins/coverage.md +39 -0
  68. package/docs/plugins/customLocator.md +119 -0
  69. package/docs/plugins/customReporter.md +16 -0
  70. package/docs/plugins/expose.md +75 -0
  71. package/docs/plugins/heal.md +44 -0
  72. package/docs/plugins/junitReporter.md +51 -0
  73. package/docs/plugins/pageInfo.md +34 -0
  74. package/docs/plugins/pause.md +43 -0
  75. package/docs/plugins/pauseOnFail.md +18 -0
  76. package/docs/plugins/retryFailedStep.md +75 -0
  77. package/docs/plugins/screencast.md +55 -0
  78. package/docs/plugins/screenshot.md +58 -0
  79. package/docs/plugins/screenshotOnFail.md +18 -0
  80. package/docs/plugins/stepTimeout.md +65 -0
  81. package/docs/plugins.md +87 -0
  82. package/docs/puppeteer.md +314 -0
  83. package/docs/quickstart.md +120 -0
  84. package/docs/reports.md +198 -0
  85. package/docs/retry.md +311 -0
  86. package/docs/secrets.md +150 -0
  87. package/docs/sessions.md +80 -0
  88. package/docs/shadow.md +68 -0
  89. package/docs/store.md +94 -0
  90. package/docs/test-structure.md +275 -0
  91. package/docs/timeouts.md +183 -0
  92. package/docs/translation.md +247 -0
  93. package/docs/tutorial.md +323 -0
  94. package/docs/typescript.md +159 -0
  95. package/docs/web-element.md +251 -0
  96. package/docs/webdriver.md +641 -0
  97. package/docs/within.md +55 -0
  98. package/lib/actor.js +1 -36
  99. package/lib/ai.js +3 -2
  100. package/lib/aria.js +260 -0
  101. package/lib/assertions.js +18 -0
  102. package/lib/codecept.js +7 -7
  103. package/lib/command/check.js +2 -1
  104. package/lib/command/dryRun.js +24 -5
  105. package/lib/command/generate.js +2 -0
  106. package/lib/command/gherkin/snippets.js +5 -4
  107. package/lib/command/init.js +248 -266
  108. package/lib/command/list.js +150 -10
  109. package/lib/command/query.js +218 -0
  110. package/lib/command/run-multiple.js +3 -2
  111. package/lib/command/run-workers.js +1 -14
  112. package/lib/command/run.js +3 -17
  113. package/lib/command/utils.js +14 -0
  114. package/lib/command/workers/runTests.js +11 -15
  115. package/lib/config.js +77 -4
  116. package/lib/container.js +97 -15
  117. package/lib/effects.js +17 -0
  118. package/lib/element/WebElement.js +194 -2
  119. package/lib/els.js +12 -6
  120. package/lib/globals.js +32 -19
  121. package/lib/heal.js +7 -4
  122. package/lib/helper/ApiDataFactory.js +2 -1
  123. package/lib/helper/FileSystem.js +3 -2
  124. package/lib/helper/GraphQLDataFactory.js +2 -1
  125. package/lib/helper/Playwright.js +63 -70
  126. package/lib/helper/Puppeteer.js +20 -109
  127. package/lib/helper/WebDriver.js +13 -30
  128. package/lib/helper/errors/NonFocusedType.js +8 -0
  129. package/lib/helper/extras/Download.js +45 -0
  130. package/lib/helper/extras/PlaywrightLocator.js +10 -0
  131. package/lib/helper/extras/elementSelection.js +10 -3
  132. package/lib/helper/extras/focusCheck.js +43 -0
  133. package/lib/helper/extras/richTextEditor.js +178 -0
  134. package/lib/history.js +3 -2
  135. package/lib/html.js +90 -16
  136. package/lib/index.js +9 -1
  137. package/lib/listener/config.js +6 -4
  138. package/lib/listener/emptyRun.js +2 -1
  139. package/lib/listener/helpers.js +4 -1
  140. package/lib/listener/mocha.js +2 -1
  141. package/lib/listener/pageobjects.js +43 -0
  142. package/lib/listener/result.js +3 -2
  143. package/lib/locator.js +126 -16
  144. package/lib/mocha/cli.js +4 -2
  145. package/lib/mocha/factory.js +7 -2
  146. package/lib/mocha/inject.js +1 -1
  147. package/lib/mocha/scenarioConfig.js +2 -1
  148. package/lib/mocha/ui.js +5 -6
  149. package/lib/parser.js +2 -2
  150. package/lib/pause.js +38 -4
  151. package/lib/plugin/aiTrace.js +96 -103
  152. package/lib/plugin/analyze.js +9 -9
  153. package/lib/plugin/auth.js +3 -3
  154. package/lib/plugin/browser.js +77 -0
  155. package/lib/plugin/expose.js +159 -0
  156. package/lib/plugin/heal.js +47 -3
  157. package/lib/plugin/junitReporter.js +303 -0
  158. package/lib/plugin/pageInfo.js +54 -52
  159. package/lib/plugin/pause.js +131 -0
  160. package/lib/plugin/pauseOnFail.js +11 -33
  161. package/lib/plugin/retryFailedStep.js +15 -13
  162. package/lib/plugin/screencast.js +289 -0
  163. package/lib/plugin/screenshot.js +558 -0
  164. package/lib/plugin/screenshotOnFail.js +9 -170
  165. package/lib/plugin/stepTimeout.js +3 -2
  166. package/lib/recorder.js +1 -1
  167. package/lib/rerun.js +2 -1
  168. package/lib/result.js +2 -1
  169. package/lib/step/base.js +10 -9
  170. package/lib/step/comment.js +2 -2
  171. package/lib/step/config.js +7 -0
  172. package/lib/step/helper.js +4 -4
  173. package/lib/step/meta.js +3 -3
  174. package/lib/step/record.js +5 -5
  175. package/lib/store.js +72 -3
  176. package/lib/translation.js +2 -1
  177. package/lib/utils/mask_data.js +2 -1
  178. package/lib/utils/pluginParser.js +151 -0
  179. package/lib/utils/trace.js +297 -0
  180. package/lib/utils.js +29 -3
  181. package/lib/workers.js +14 -22
  182. package/package.json +17 -14
  183. package/typings/index.d.ts +0 -5
  184. package/docs/webapi/amOnPage.mustache +0 -11
  185. package/docs/webapi/appendField.mustache +0 -16
  186. package/docs/webapi/attachFile.mustache +0 -24
  187. package/docs/webapi/blur.mustache +0 -18
  188. package/docs/webapi/checkOption.mustache +0 -13
  189. package/docs/webapi/clearCookie.mustache +0 -9
  190. package/docs/webapi/clearField.mustache +0 -14
  191. package/docs/webapi/click.mustache +0 -29
  192. package/docs/webapi/clickLink.mustache +0 -8
  193. package/docs/webapi/closeCurrentTab.mustache +0 -7
  194. package/docs/webapi/closeOtherTabs.mustache +0 -8
  195. package/docs/webapi/dontSee.mustache +0 -11
  196. package/docs/webapi/dontSeeCheckboxIsChecked.mustache +0 -10
  197. package/docs/webapi/dontSeeCookie.mustache +0 -8
  198. package/docs/webapi/dontSeeCurrentPathEquals.mustache +0 -10
  199. package/docs/webapi/dontSeeCurrentUrlEquals.mustache +0 -10
  200. package/docs/webapi/dontSeeElement.mustache +0 -12
  201. package/docs/webapi/dontSeeElementInDOM.mustache +0 -8
  202. package/docs/webapi/dontSeeInCurrentUrl.mustache +0 -4
  203. package/docs/webapi/dontSeeInField.mustache +0 -16
  204. package/docs/webapi/dontSeeInSource.mustache +0 -8
  205. package/docs/webapi/dontSeeInTitle.mustache +0 -8
  206. package/docs/webapi/dontSeeTraffic.mustache +0 -13
  207. package/docs/webapi/doubleClick.mustache +0 -13
  208. package/docs/webapi/downloadFile.mustache +0 -12
  209. package/docs/webapi/dragAndDrop.mustache +0 -9
  210. package/docs/webapi/dragSlider.mustache +0 -11
  211. package/docs/webapi/executeAsyncScript.mustache +0 -24
  212. package/docs/webapi/executeScript.mustache +0 -26
  213. package/docs/webapi/fillField.mustache +0 -21
  214. package/docs/webapi/flushNetworkTraffics.mustache +0 -5
  215. package/docs/webapi/focus.mustache +0 -13
  216. package/docs/webapi/forceClick.mustache +0 -28
  217. package/docs/webapi/forceRightClick.mustache +0 -18
  218. package/docs/webapi/grabAllWindowHandles.mustache +0 -7
  219. package/docs/webapi/grabAttributeFrom.mustache +0 -10
  220. package/docs/webapi/grabAttributeFromAll.mustache +0 -9
  221. package/docs/webapi/grabBrowserLogs.mustache +0 -9
  222. package/docs/webapi/grabCookie.mustache +0 -11
  223. package/docs/webapi/grabCssPropertyFrom.mustache +0 -11
  224. package/docs/webapi/grabCssPropertyFromAll.mustache +0 -10
  225. package/docs/webapi/grabCurrentUrl.mustache +0 -9
  226. package/docs/webapi/grabCurrentWindowHandle.mustache +0 -6
  227. package/docs/webapi/grabDataFromPerformanceTiming.mustache +0 -20
  228. package/docs/webapi/grabElementBoundingRect.mustache +0 -20
  229. package/docs/webapi/grabGeoLocation.mustache +0 -8
  230. package/docs/webapi/grabHTMLFrom.mustache +0 -10
  231. package/docs/webapi/grabHTMLFromAll.mustache +0 -9
  232. package/docs/webapi/grabNumberOfOpenTabs.mustache +0 -8
  233. package/docs/webapi/grabNumberOfVisibleElements.mustache +0 -9
  234. package/docs/webapi/grabPageScrollPosition.mustache +0 -8
  235. package/docs/webapi/grabPopupText.mustache +0 -5
  236. package/docs/webapi/grabRecordedNetworkTraffics.mustache +0 -10
  237. package/docs/webapi/grabSource.mustache +0 -8
  238. package/docs/webapi/grabTextFrom.mustache +0 -10
  239. package/docs/webapi/grabTextFromAll.mustache +0 -9
  240. package/docs/webapi/grabTitle.mustache +0 -8
  241. package/docs/webapi/grabValueFrom.mustache +0 -9
  242. package/docs/webapi/grabValueFromAll.mustache +0 -8
  243. package/docs/webapi/grabWebElement.mustache +0 -9
  244. package/docs/webapi/grabWebElements.mustache +0 -9
  245. package/docs/webapi/moveCursorTo.mustache +0 -16
  246. package/docs/webapi/openNewTab.mustache +0 -7
  247. package/docs/webapi/pressKey.mustache +0 -12
  248. package/docs/webapi/pressKeyDown.mustache +0 -12
  249. package/docs/webapi/pressKeyUp.mustache +0 -12
  250. package/docs/webapi/pressKeyWithKeyNormalization.mustache +0 -60
  251. package/docs/webapi/refreshPage.mustache +0 -6
  252. package/docs/webapi/resizeWindow.mustache +0 -6
  253. package/docs/webapi/rightClick.mustache +0 -14
  254. package/docs/webapi/saveElementScreenshot.mustache +0 -10
  255. package/docs/webapi/saveScreenshot.mustache +0 -12
  256. package/docs/webapi/say.mustache +0 -10
  257. package/docs/webapi/scrollIntoView.mustache +0 -11
  258. package/docs/webapi/scrollPageToBottom.mustache +0 -6
  259. package/docs/webapi/scrollPageToTop.mustache +0 -6
  260. package/docs/webapi/scrollTo.mustache +0 -12
  261. package/docs/webapi/see.mustache +0 -11
  262. package/docs/webapi/seeAttributesOnElements.mustache +0 -9
  263. package/docs/webapi/seeCheckboxIsChecked.mustache +0 -10
  264. package/docs/webapi/seeCookie.mustache +0 -8
  265. package/docs/webapi/seeCssPropertiesOnElements.mustache +0 -9
  266. package/docs/webapi/seeCurrentPathEquals.mustache +0 -10
  267. package/docs/webapi/seeCurrentUrlEquals.mustache +0 -11
  268. package/docs/webapi/seeElement.mustache +0 -12
  269. package/docs/webapi/seeElementInDOM.mustache +0 -8
  270. package/docs/webapi/seeInCurrentUrl.mustache +0 -8
  271. package/docs/webapi/seeInField.mustache +0 -17
  272. package/docs/webapi/seeInPopup.mustache +0 -8
  273. package/docs/webapi/seeInSource.mustache +0 -7
  274. package/docs/webapi/seeInTitle.mustache +0 -8
  275. package/docs/webapi/seeNumberOfElements.mustache +0 -11
  276. package/docs/webapi/seeNumberOfVisibleElements.mustache +0 -10
  277. package/docs/webapi/seeTextEquals.mustache +0 -9
  278. package/docs/webapi/seeTitleEquals.mustache +0 -8
  279. package/docs/webapi/seeTraffic.mustache +0 -36
  280. package/docs/webapi/selectOption.mustache +0 -26
  281. package/docs/webapi/setCookie.mustache +0 -16
  282. package/docs/webapi/setGeoLocation.mustache +0 -12
  283. package/docs/webapi/startRecordingTraffic.mustache +0 -8
  284. package/docs/webapi/startRecordingWebSocketMessages.mustache +0 -8
  285. package/docs/webapi/stopRecordingTraffic.mustache +0 -5
  286. package/docs/webapi/stopRecordingWebSocketMessages.mustache +0 -7
  287. package/docs/webapi/switchTo.mustache +0 -9
  288. package/docs/webapi/switchToNextTab.mustache +0 -10
  289. package/docs/webapi/switchToPreviousTab.mustache +0 -10
  290. package/docs/webapi/type.mustache +0 -21
  291. package/docs/webapi/uncheckOption.mustache +0 -13
  292. package/docs/webapi/wait.mustache +0 -8
  293. package/docs/webapi/waitForClickable.mustache +0 -11
  294. package/docs/webapi/waitForCookie.mustache +0 -9
  295. package/docs/webapi/waitForDetached.mustache +0 -10
  296. package/docs/webapi/waitForDisabled.mustache +0 -6
  297. package/docs/webapi/waitForElement.mustache +0 -11
  298. package/docs/webapi/waitForEnabled.mustache +0 -6
  299. package/docs/webapi/waitForFunction.mustache +0 -17
  300. package/docs/webapi/waitForInvisible.mustache +0 -10
  301. package/docs/webapi/waitForNumberOfTabs.mustache +0 -9
  302. package/docs/webapi/waitForText.mustache +0 -13
  303. package/docs/webapi/waitForValue.mustache +0 -10
  304. package/docs/webapi/waitForVisible.mustache +0 -10
  305. package/docs/webapi/waitInUrl.mustache +0 -9
  306. package/docs/webapi/waitNumberOfVisibleElements.mustache +0 -10
  307. package/docs/webapi/waitToHide.mustache +0 -10
  308. package/docs/webapi/waitUrlEquals.mustache +0 -10
  309. package/lib/helper/AI.js +0 -214
  310. package/lib/helper/Mochawesome.js +0 -96
  311. package/lib/helper/extras/PlaywrightReactVueLocator.js +0 -52
  312. package/lib/helper/extras/React.js +0 -65
  313. package/lib/plugin/stepByStepReport.js +0 -431
  314. package/lib/plugin/subtitles.js +0 -89
package/bin/mcp-server.js CHANGED
@@ -1,18 +1,37 @@
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
33
  import { existsSync, readdirSync } from 'fs'
34
+ import { mkdirp } from 'mkdirp'
16
35
 
17
36
  const require = createRequire(import.meta.url)
18
37
 
@@ -22,6 +41,127 @@ const __dirname = dirname(__filename)
22
41
  let codecept = null
23
42
  let containerInitialized = false
24
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) container.mocha().grep(grep)
64
+ }
65
+
66
+ function pauseAtMatcher(pauseAt) {
67
+ if (pauseAt == null) return () => false
68
+ if (typeof pauseAt === 'number') return (idx) => idx === pauseAt
69
+ if (typeof pauseAt === 'string') {
70
+ const m = pauseAt.match(/^\/(.+)\/([gimsuy]*)$/)
71
+ const re = m ? new RegExp(m[1], m[2]) : new RegExp(pauseAt.replace(/[.+?^${}()|[\]\\]/g, '\\$&'), 'i')
72
+ return (_idx, name) => re.test(name)
73
+ }
74
+ return () => false
75
+ }
76
+
77
+ async function ensureBootstrap() {
78
+ if (bootstrapDone) return
79
+ await codecept.bootstrap()
80
+ bootstrapDone = true
81
+ }
82
+
83
+ async function startShellSession() {
84
+ if (shellSessionActive) return
85
+ await ensureBootstrap()
86
+ recorder.start()
87
+ event.emit(event.suite.before, {
88
+ fullTitle: () => 'MCP Session',
89
+ tests: [],
90
+ retries: () => {},
91
+ })
92
+ event.emit(event.test.before, {
93
+ title: 'MCP Session',
94
+ artifacts: {},
95
+ retries: () => {},
96
+ })
97
+ shellSessionActive = true
98
+ }
99
+
100
+ async function endShellSession() {
101
+ if (!shellSessionActive) return
102
+ try { event.emit(event.test.after, {}) } catch {}
103
+ try { event.emit(event.suite.after, {}) } catch {}
104
+ try { event.emit(event.all.result, {}) } catch {}
105
+ shellSessionActive = false
106
+ }
107
+
108
+ async function ensureSession() {
109
+ if (shellSessionActive || pausedController) return
110
+ await startShellSession()
111
+ }
112
+
113
+ function normalizePluginOverrides(plugins) {
114
+ if (!plugins || typeof plugins !== 'object') return {}
115
+ const out = {}
116
+ for (const [name, opts] of Object.entries(plugins)) {
117
+ if (opts === false) continue
118
+ out[name] = (opts === true || opts == null) ? {} : opts
119
+ }
120
+ return out
121
+ }
122
+
123
+ function applyPluginOverrides(config, plugins) {
124
+ config.plugins = config.plugins || {}
125
+ for (const [name, opts] of Object.entries(plugins)) {
126
+ config.plugins[name] = { ...(config.plugins[name] || {}), ...opts, enabled: true }
127
+ }
128
+ }
129
+
130
+ function pluginsSignature(plugins) {
131
+ const keys = Object.keys(plugins).sort()
132
+ return JSON.stringify(keys.map(k => [k, plugins[k]]))
133
+ }
134
+
135
+ async function teardownContainer() {
136
+ if (!containerInitialized) return
137
+ try {
138
+ await closeBrowser()
139
+ try { if (codecept?.teardown) await codecept.teardown() } catch {}
140
+ } finally {
141
+ containerInitialized = false
142
+ browserStarted = false
143
+ bootstrapDone = false
144
+ aiTraceEnabled = false
145
+ codecept = null
146
+ currentPluginsSig = ''
147
+ }
148
+ }
149
+
150
+ let shutdownStarted = false
151
+ function installShutdownHooks() {
152
+ const onSignal = (signal) => {
153
+ if (shutdownStarted) return
154
+ shutdownStarted = true
155
+ teardownContainer().finally(() => process.exit(signal === 'SIGINT' ? 130 : 0))
156
+ }
157
+ process.on('SIGTERM', () => onSignal('SIGTERM'))
158
+ process.on('SIGINT', () => onSignal('SIGINT'))
159
+ process.on('beforeExit', () => {
160
+ if (shutdownStarted) return
161
+ shutdownStarted = true
162
+ teardownContainer().catch(() => {})
163
+ })
164
+ }
25
165
 
26
166
  let runLock = Promise.resolve()
27
167
  async function withLock(fn) {
@@ -223,19 +363,155 @@ async function resolveTestToFile({ cli, root, configPath, test }) {
223
363
  return fsFound ? normalizePath(fsFound) : null
224
364
  }
225
365
 
226
- function clearString(str) {
227
- return str.replace(/[^a-zA-Z0-9]/g, '_')
366
+ function outputBaseDir() {
367
+ return global.output_dir || resolvePath(process.cwd(), 'output')
368
+ }
369
+
370
+ // In-process pause coordination. When a test running through run_test calls
371
+ // pause(), the handler registered via setPauseHandler resolves a "paused"
372
+ // promise that run_test is racing against test completion. The "pause" tool
373
+ // then drives the REPL by mutating next/abort and resolving the controller.
374
+ let pausedController = null
375
+ let pendingRunPromise = null
376
+ let pendingRunResults = null
377
+ let pendingRunCleanup = null
378
+ let pendingTestFile = null
379
+ let pendingStepInfo = null
380
+ let abortRun = false
381
+ const pauseEvents = new EventEmitter()
382
+
383
+ setPauseHandler(({ registeredVariables }) => {
384
+ if (abortRun) return Promise.reject(new Error('MCP session aborted'))
385
+ return new Promise(resolve => {
386
+ pausedController = {
387
+ registeredVariables,
388
+ resolveContinue: () => {
389
+ pausedController = null
390
+ resolve()
391
+ },
392
+ }
393
+ pauseEvents.emit('paused')
394
+ })
395
+ })
396
+
397
+ async function cancelRun() {
398
+ if (!pendingRunPromise && !pausedController) return false
399
+ abortRun = true
400
+ if (typeof pendingRunCleanup === 'function') { try { pendingRunCleanup() } catch {} }
401
+ if (pausedController) { try { pausedController.resolveContinue() } catch {} ; pausedController = null }
402
+
403
+ try { container.mocha().runner?.abort() } catch {}
404
+
405
+ if (pendingRunPromise) {
406
+ try { await pendingRunPromise.catch(() => {}) } catch {}
407
+ }
408
+ pendingRunPromise = null
409
+ pendingRunResults = null
410
+ pendingTestFile = null
411
+ pendingStepInfo = null
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
228
441
  }
229
442
 
230
- function getTraceDir(testTitle, testFile) {
231
- const hash = crypto.createHash('sha256').update(testFile + testTitle).digest('hex').slice(0, 8)
232
- const cleanTitle = clearString(testTitle).slice(0, 200)
233
- const outputDir = global.output_dir || resolvePath(process.cwd(), 'output')
234
- return resolvePath(outputDir, `trace_${cleanTitle}_${hash}`)
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)
235
450
  }
236
451
 
237
- async function initCodecept(configPath) {
238
- if (containerInitialized) return
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
+ }
239
515
 
240
516
  const testRoot = process.env.CODECEPTJS_PROJECT_DIR || process.cwd()
241
517
 
@@ -260,13 +536,27 @@ async function initCodecept(configPath) {
260
536
  const { getConfig } = await import('../lib/command/utils.js')
261
537
  const config = await getConfig(configPath)
262
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
+
263
544
  codecept = new Codecept(config, {})
264
545
  await codecept.init(testRoot)
265
- await container.create(config, {})
266
546
  await container.started()
267
547
 
268
548
  containerInitialized = true
269
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
270
560
  }
271
561
 
272
562
  const server = new Server(
@@ -274,66 +564,124 @@ const server = new Server(
274
564
  { capabilities: { tools: {} } }
275
565
  )
276
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
+
277
583
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
278
584
  tools: [
279
585
  {
280
586
  name: 'list_tests',
281
- description: 'List all tests in the CodeceptJS project',
282
- inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
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: {} },
283
589
  },
284
590
  {
285
591
  name: 'list_actions',
286
- description: 'List all available CodeceptJS actions (I.* methods)',
287
- inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
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: {} },
288
594
  },
289
595
  {
290
596
  name: 'run_code',
291
- description: 'Run arbitrary CodeceptJS code.',
597
+ description: 'Run arbitrary CodeceptJS code. Response includes `availableObjects` listing every symbol in scope (I, helpers, container, step, tryTo, within, etc.).',
292
598
  inputSchema: {
293
599
  type: 'object',
294
600
  properties: {
295
601
  code: { type: 'string' },
296
602
  timeout: { type: 'number' },
297
- config: { type: 'string' },
298
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.' },
299
605
  },
300
606
  required: ['code'],
301
607
  },
302
608
  },
303
609
  {
304
610
  name: 'run_test',
305
- description: 'Run a specific 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).',
306
612
  inputSchema: {
307
613
  type: 'object',
308
614
  properties: {
309
615
  test: { type: 'string' },
310
616
  timeout: { type: 'number' },
311
- config: { type: 'string' },
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,
312
623
  },
313
624
  required: ['test'],
314
625
  },
315
626
  },
316
627
  {
317
628
  name: 'run_step_by_step',
318
- description: 'Run a test step by step with pauses between steps.',
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.',
319
630
  inputSchema: {
320
631
  type: 'object',
321
632
  properties: {
322
633
  test: { type: 'string' },
323
634
  timeout: { type: 'number' },
324
- config: { type: 'string' },
635
+ grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
636
+ plugins: PLUGINS_PROP,
325
637
  },
326
638
  required: ['test'],
327
639
  },
328
640
  },
329
641
  {
330
642
  name: 'start_browser',
331
- description: 'Start the browser session.',
332
- inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
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
+ },
333
655
  },
334
656
  {
335
657
  name: 'stop_browser',
336
- description: 'Stop the browser session.',
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.',
337
685
  inputSchema: { type: 'object', properties: {} },
338
686
  },
339
687
  ],
@@ -345,8 +693,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
345
693
  try {
346
694
  switch (name) {
347
695
  case 'list_tests': {
348
- const configPath = args?.config
349
- await initCodecept(configPath)
696
+ await initCodecept()
350
697
 
351
698
  codecept.loadTests()
352
699
  const tests = codecept.testFiles.map(testFile => {
@@ -361,8 +708,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
361
708
  }
362
709
 
363
710
  case 'list_actions': {
364
- const configPath = args?.config
365
- await initCodecept(configPath)
711
+ await initCodecept()
366
712
 
367
713
  const helpers = container.helpers()
368
714
  const supportI = container.support('I')
@@ -390,199 +736,431 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
390
736
  }
391
737
 
392
738
  case 'start_browser': {
393
- const configPath = args?.config
394
- if (browserStarted) {
395
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser already started' }, null, 2) }] }
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) }] }
396
742
  }
397
- await initCodecept(configPath)
398
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser started successfully' }, null, 2) }] }
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) }] }
399
752
  }
400
753
 
401
754
  case 'stop_browser': {
402
755
  if (!containerInitialized) {
403
756
  return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser not initialized' }, null, 2) }] }
404
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
+ }
405
761
 
406
- const helpers = container.helpers()
407
- for (const helperName in helpers) {
408
- const helper = helpers[helperName]
409
- try { if (helper._finish) await helper._finish() } catch {}
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
+ }],
410
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
+ }
411
806
 
412
- browserStarted = false
413
- containerInitialized = false
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
+ }
414
828
 
415
- return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped successfully' }, null, 2) }] }
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) }] }
416
833
  }
417
834
 
418
835
  case 'run_code': {
419
- const { code, timeout = 60000, config: configPath, saveArtifacts = true } = args
420
- await initCodecept(configPath)
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.')
421
842
 
422
- const I = container.support('I')
423
- if (!I) throw new Error('I object not available. Make sure helpers are configured.')
843
+ const result = { status: 'unknown', output: '', error: null, commands: [], artifacts: {} }
424
844
 
425
- const result = { status: 'unknown', output: '', error: null, artifacts: {} }
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
+ }
426
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
427
898
  try {
428
- const asyncFn = new Function('I', `return (async () => { ${code} })()`)
429
- await Promise.race([
430
- asyncFn(I),
899
+ const asyncFn = new Function(...paramNames, `return (async () => { ${code} })()`)
900
+ returnValue = await Promise.race([
901
+ asyncFn(...paramValues),
431
902
  new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
432
903
  ])
904
+ await recorder.promise()
433
905
 
434
906
  result.status = 'success'
435
907
  result.output = 'Code executed successfully'
436
-
437
- if (saveArtifacts) {
438
- const helpers = container.helpers()
439
- const helper = Object.values(helpers)[0]
440
- if (helper) {
441
- try {
442
- if (helper.grabAriaSnapshot) result.artifacts.aria = await helper.grabAriaSnapshot()
443
- if (helper.grabCurrentUrl) result.artifacts.url = await helper.grabCurrentUrl()
444
- if (helper.grabBrowserLogs) result.artifacts.consoleLogs = (await helper.grabBrowserLogs()) || []
445
- if (helper.grabSource) {
446
- const html = await helper.grabSource()
447
- result.artifacts.html = html.substring(0, 10000) + '...'
448
- }
449
- } catch (e) {
450
- result.output += ` (Warning: ${e.message})`
451
- }
452
- }
453
- }
454
908
  } catch (error) {
455
909
  result.status = 'failed'
456
910
  result.error = error.message
457
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
+ }
458
921
  }
459
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
+
460
973
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
461
974
  }
462
975
 
463
976
  case 'run_test': {
464
977
  return await withLock(async () => {
465
- const { test, timeout = 60000, config: configPathArg } = args || {}
466
- const { configPath, configDir } = resolveConfigPath(configPathArg)
467
-
468
- const { cli, root } = findCodeceptCliUpwards(configDir)
469
- const isNodeScript = cli.endsWith('.js')
470
-
471
- const resolvedFile = await resolveTestToFile({ cli, root, configPath, test })
472
- const runArgs = ['run', '--config', configPath, '--reporter', 'json']
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
+ }
473
997
 
474
- if (resolvedFile) runArgs.push(resolvedFile)
475
- else if (looksLikePath(test)) runArgs.push(test)
476
- else runArgs.push('--grep', String(test))
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
+ }
477
1034
 
478
- const res = isNodeScript
479
- ? await runCmd(process.execPath, [cli, ...runArgs], { cwd: root, timeout })
480
- : await runCmd(cli, runArgs, { cwd: root, timeout })
1035
+ abortRun = false
1036
+ let runError = null
1037
+ const runPromise = (async () => {
1038
+ try {
1039
+ await ensureBootstrap()
1040
+ await codecept.run(testFile)
1041
+ } catch (err) {
1042
+ runError = err
1043
+ throw err
1044
+ }
1045
+ })()
1046
+ pendingRunPromise = runPromise
481
1047
 
482
- const { code, out, err } = res
1048
+ const result = await waitForTestResult(runPromise, timeout)
1049
+ if (result.status === 'aborted') {
1050
+ await startShellSession()
1051
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: result.error }, null, 2) }] }
1052
+ }
483
1053
 
484
- let parsed = null
485
- const jsonStart = out.indexOf('{')
486
- const jsonEnd = out.lastIndexOf('}')
487
- if (jsonStart !== -1 && jsonEnd !== -1 && jsonEnd > jsonStart) {
488
- try { parsed = JSON.parse(out.slice(jsonStart, jsonEnd + 1)) } catch {}
489
- }
1054
+ if (result.status === 'paused') {
1055
+ const page = await gatherPageBrief()
1056
+ return {
1057
+ content: [{
1058
+ type: 'text',
1059
+ text: JSON.stringify({ ...pausedPayload(), page }, null, 2),
1060
+ }],
1061
+ }
1062
+ }
490
1063
 
491
- return {
492
- content: [{
493
- type: 'text',
494
- text: JSON.stringify({
495
- meta: { exitCode: code, cli, root, configPath, args: runArgs, resolvedFile: resolvedFile || null },
496
- reporterJson: parsed,
497
- stderr: err ? err.slice(0, 20000) : '',
498
- rawStdout: parsed ? '' : out.slice(0, 20000),
499
- }, null, 2),
500
- }],
501
- }
1064
+ pendingRunPromise = null
1065
+ const final = collectRunCompletion(runError?.message)
1066
+ await startShellSession()
1067
+ return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
1068
+ })
502
1069
  })
503
1070
  }
504
1071
 
505
1072
  case 'run_step_by_step': {
506
- const { test, timeout = 60000, config: configPath } = args
507
- await initCodecept(configPath)
508
-
509
- return await withSilencedIO(async () => {
510
- codecept.loadTests()
511
-
512
- let testFiles = codecept.testFiles
513
- if (test) {
514
- const testName = normalizePath(test).toLowerCase()
515
- testFiles = codecept.testFiles.filter(f => {
516
- const filePath = normalizePath(f).toLowerCase()
517
- return filePath.includes(testName) || filePath.endsWith(testName)
518
- })
519
- }
520
-
521
- if (!testFiles.length) throw new Error(`No tests found matching: ${test}`)
522
-
523
- const results = []
524
- const currentSteps = {}
525
- let currentTestTitle = null
526
- const testFile = testFiles[0]
527
-
528
- const onBefore = (t) => {
529
- const traceDir = getTraceDir(t.title, t.file)
530
- currentTestTitle = t.title
531
- currentSteps[t.title] = []
532
- results.push({
533
- test: t.title,
534
- file: t.file,
535
- traceFile: `file://${resolvePath(traceDir, 'trace.md')}`,
536
- status: 'running',
537
- steps: [],
538
- })
1073
+ return await withLock(async () => {
1074
+ if (pausedController) {
1075
+ throw new Error('A previous run is still paused. Call "continue" first.')
539
1076
  }
1077
+ const { test, timeout = 60000, grep, plugins } = args || {}
1078
+ await initCodecept(undefined, plugins)
1079
+ await endShellSession()
1080
+ applyMochaGrep(grep)
1081
+
1082
+ return await withSilencedIO(async () => {
1083
+ codecept.loadTests()
1084
+
1085
+ let testFiles = codecept.testFiles
1086
+ if (test) {
1087
+ const testName = normalizePath(test).toLowerCase()
1088
+ testFiles = codecept.testFiles.filter(f => {
1089
+ const filePath = normalizePath(f).toLowerCase()
1090
+ return filePath.includes(testName) || filePath.endsWith(testName)
1091
+ })
1092
+ }
540
1093
 
541
- const onAfter = (t) => {
542
- const r = results.find(x => x.test === t.title)
543
- if (r) {
544
- r.status = t.err ? 'failed' : 'completed'
545
- if (t.err) r.error = t.err.message
1094
+ if (!testFiles.length) throw new Error(`No tests found matching: ${test}`)
1095
+ const testFile = testFiles[0]
1096
+
1097
+ pendingRunResults = []
1098
+ pendingTestFile = testFile
1099
+ pendingStepInfo = null
1100
+ let stepIndex = 0
1101
+
1102
+ const onAfter = t => {
1103
+ const aiTrace = t.artifacts?.aiTrace
1104
+ pendingRunResults.push({
1105
+ title: t.title,
1106
+ file: t.file,
1107
+ status: t.err ? 'failed' : 'passed',
1108
+ error: t.err?.message,
1109
+ duration: t.duration,
1110
+ traceFile: aiTrace ? pathToFileURL(aiTrace).href : null,
1111
+ })
1112
+ }
1113
+ const onStepAfter = step => {
1114
+ stepIndex += 1
1115
+ const idx = stepIndex
1116
+ const name = (() => { try { return step.toString() } catch { return '' } })()
1117
+ recorder.add('mcp pause info', () => {
1118
+ pendingStepInfo = { index: idx, name, status: step.status }
1119
+ })
1120
+ pauseNow()
1121
+ }
1122
+ event.dispatcher.on(event.test.after, onAfter)
1123
+ event.dispatcher.on(event.step.after, onStepAfter)
1124
+ pendingRunCleanup = () => {
1125
+ try { event.dispatcher.removeListener(event.test.after, onAfter) } catch {}
1126
+ try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
1127
+ pendingRunCleanup = null
546
1128
  }
547
- currentTestTitle = null
548
- }
549
1129
 
550
- const onStepAfter = (step) => {
551
- if (!currentTestTitle || !currentSteps[currentTestTitle]) return
552
- currentSteps[currentTestTitle].push({
553
- step: step.toString(),
554
- status: step.status,
555
- time: step.endTime - step.startTime,
556
- })
557
- const r = results.find(x => x.test === currentTestTitle)
558
- if (r) r.steps = [...currentSteps[currentTestTitle]]
559
- }
1130
+ abortRun = false
1131
+ let runError = null
1132
+ const runPromise = (async () => {
1133
+ try {
1134
+ await ensureBootstrap()
1135
+ await codecept.run(testFile)
1136
+ } catch (err) {
1137
+ runError = err
1138
+ throw err
1139
+ }
1140
+ })()
1141
+ pendingRunPromise = runPromise
560
1142
 
561
- event.dispatcher.on(event.test.before, onBefore)
562
- event.dispatcher.on(event.test.after, onAfter)
563
- event.dispatcher.on(event.step.after, onStepAfter)
1143
+ const result = await waitForTestResult(runPromise, timeout)
1144
+ if (result.status === 'aborted') {
1145
+ await startShellSession()
1146
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'failed', file: testFile, error: result.error }, null, 2) }] }
1147
+ }
564
1148
 
565
- try {
566
- await Promise.race([
567
- (async () => {
568
- await codecept.bootstrap()
569
- await codecept.run(testFile)
570
- })(),
571
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
572
- ])
573
- } catch (error) {
574
- const lastRunning = results.filter(r => r.status === 'running').pop()
575
- if (lastRunning) {
576
- lastRunning.status = 'failed'
577
- lastRunning.error = error.message
1149
+ if (result.status === 'paused') {
1150
+ const page = await gatherPageBrief()
1151
+ return {
1152
+ content: [{
1153
+ type: 'text',
1154
+ text: JSON.stringify({ ...pausedPayload(), page }, null, 2),
1155
+ }],
1156
+ }
578
1157
  }
579
- } finally {
580
- try { event.dispatcher.removeListener(event.test.before, onBefore) } catch {}
581
- try { event.dispatcher.removeListener(event.test.after, onAfter) } catch {}
582
- try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
583
- }
584
1158
 
585
- return { content: [{ type: 'text', text: JSON.stringify({ results, stepByStep: true }, null, 2) }] }
1159
+ pendingRunPromise = null
1160
+ const final = collectRunCompletion(runError?.message)
1161
+ await startShellSession()
1162
+ return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
1163
+ })
586
1164
  })
587
1165
  }
588
1166
 
@@ -598,6 +1176,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
598
1176
  })
599
1177
 
600
1178
  async function main() {
1179
+ installShutdownHooks()
601
1180
  const transport = new StdioServerTransport()
602
1181
  await server.connect(transport)
603
1182
  }