Skip to content

Commit 4dd1ad2

Browse files
author
DavertMik
committed
Fixed MCP live interaction
1 parent 25e772c commit 4dd1ad2

2 files changed

Lines changed: 97 additions & 35 deletions

File tree

bin/mcp-server.js

Lines changed: 93 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,20 @@ function aiTraceHint() {
6060
}
6161

6262
function applyMochaGrep(grep) {
63-
if (grep && typeof container.mocha?.grep === 'function') container.mocha.grep(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
6477
}
6578

6679
async function ensureBootstrap() {
@@ -124,12 +137,7 @@ function pluginsSignature(plugins) {
124137
async function teardownContainer() {
125138
if (!containerInitialized) return
126139
try {
127-
await endShellSession()
128-
const helpers = container.helpers()
129-
for (const helperName in helpers) {
130-
const helper = helpers[helperName]
131-
try { if (helper._finish) await helper._finish() } catch {}
132-
}
140+
await closeBrowser()
133141
try { if (codecept?.teardown) await codecept.teardown() } catch {}
134142
} finally {
135143
containerInitialized = false
@@ -365,15 +373,17 @@ function outputBaseDir() {
365373
// pause(), the handler registered via setPauseHandler resolves a "paused"
366374
// promise that run_test is racing against test completion. The "pause" tool
367375
// then drives the REPL by mutating next/abort and resolving the controller.
368-
let pausedController = null // { resolveContinue, registeredVariables }
369-
let pendingRunPromise = null // run_test's run() promise while paused
370-
let pendingRunResults = null // results array being collected while paused
371-
let pendingRunCleanup = null // cleanup callback to detach test.after / step.after listeners
372-
let pendingTestFile = null // file path of the test currently running
373-
let pendingStepInfo = null // { index, name, status } of the last step that fired step.after
376+
let pausedController = null
377+
let pendingRunPromise = null
378+
let pendingRunResults = null
379+
let pendingRunCleanup = null
380+
let pendingTestFile = null
381+
let pendingStepInfo = null
382+
let abortRun = false
374383
const pauseEvents = new EventEmitter()
375384

376385
setPauseHandler(({ registeredVariables }) => {
386+
if (abortRun) return Promise.reject(new Error('MCP session aborted'))
377387
return new Promise(resolve => {
378388
pausedController = {
379389
registeredVariables,
@@ -386,6 +396,33 @@ setPauseHandler(({ registeredVariables }) => {
386396
})
387397
})
388398

399+
async function cancelRun() {
400+
if (!pendingRunPromise && !pausedController) return false
401+
abortRun = true
402+
if (typeof pendingRunCleanup === 'function') { try { pendingRunCleanup() } catch {} }
403+
if (pausedController) { try { pausedController.resolveContinue() } catch {} ; pausedController = null }
404+
if (pendingRunPromise) {
405+
try { await Promise.race([pendingRunPromise.catch(() => {}), new Promise(r => setTimeout(r, 5000))]) } catch {}
406+
}
407+
pendingRunPromise = null
408+
pendingRunResults = null
409+
pendingTestFile = null
410+
pendingStepInfo = null
411+
abortRun = false
412+
return true
413+
}
414+
415+
async function closeBrowser() {
416+
if (!containerInitialized) return
417+
await cancelRun()
418+
await endShellSession()
419+
for (const helper of Object.values(container.helpers() || {})) {
420+
try { if (helper._cleanup) await helper._cleanup() } catch {}
421+
try { if (helper._finishTest) await helper._finishTest() } catch {}
422+
}
423+
browserStarted = false
424+
}
425+
389426
async function captureLiveArtifacts(prefix = 'pause') {
390427
const helper = pickActingHelper(container.helpers())
391428
if (!helper) return {}
@@ -558,7 +595,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
558595
test: { type: 'string' },
559596
timeout: { type: 'number' },
560597
grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
561-
pauseAt: { type: 'number', description: '1-based step index. Test will pause after the Nth step completes. Useful as a programmatic breakpoint without editing the test.' },
598+
pauseAt: {
599+
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".',
600+
oneOf: [{ type: 'number' }, { type: 'string' }],
601+
},
562602
plugins: PLUGINS_PROP,
563603
},
564604
required: ['test'],
@@ -619,6 +659,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
619659
},
620660
},
621661
},
662+
{
663+
name: 'cancel',
664+
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.',
665+
inputSchema: { type: 'object', properties: {} },
666+
},
622667
],
623668
}))
624669

@@ -676,6 +721,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
676721
return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session already active', plugins: plugins ?? null }, null, 2) }] }
677722
}
678723
await initCodecept(configPath, plugins)
724+
if (containerInitialized && !browserStarted) {
725+
for (const helper of Object.values(container.helpers() || {})) {
726+
try { if (helper._beforeSuite) await helper._beforeSuite() } catch {}
727+
}
728+
browserStarted = true
729+
}
679730
await startShellSession()
680731
return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session started — run_code and snapshot are now available', plugins: plugins ?? null }, null, 2) }] }
681732
}
@@ -684,8 +735,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
684735
if (!containerInitialized) {
685736
return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser not initialized' }, null, 2) }] }
686737
}
687-
await teardownContainer()
688-
return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped successfully' }, null, 2) }] }
738+
await closeBrowser()
739+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped — Mocha and config preserved; call start_browser to reopen' }, null, 2) }] }
689740
}
690741

691742
case 'snapshot': {
@@ -755,6 +806,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
755806
})
756807
}
757808

809+
case 'cancel': {
810+
const cancelled = await cancelRun()
811+
await ensureSession()
812+
return { content: [{ type: 'text', text: JSON.stringify({ status: cancelled ? 'Run cancelled — browser kept open' : 'No run in progress' }, null, 2) }] }
813+
}
814+
758815
case 'run_code': {
759816
const { code, timeout = 60000, saveArtifacts = true, settleMs = 300 } = args
760817
await initCodecept()
@@ -814,6 +871,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
814871
const paramNames = ['I', ...Object.keys(scope).filter(k => k !== 'I').sort()]
815872
const paramValues = paramNames.map(k => scope[k])
816873

874+
const wasPaused = !!pausedController
875+
if (wasPaused) recorder.session.start('mcp_run_code')
876+
817877
let returnValue
818878
try {
819879
const asyncFn = new Function(...paramNames, `return (async () => { ${code} })()`)
@@ -833,7 +893,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
833893
for (const m of consoleMethods) console[m] = origConsoleMethods[m]
834894
try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
835895
try { event.dispatcher.removeListener(event.step.passed, onStepPassed) } catch {}
836-
try { recorder.reset() } catch {}
896+
if (wasPaused) {
897+
try { recorder.session.restore('mcp_run_code') } catch {}
898+
} else {
899+
try { recorder.reset() } catch {}
900+
}
837901
}
838902

839903
result.commands = commands
@@ -918,6 +982,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
918982
pendingTestFile = testFile
919983
pendingStepInfo = null
920984
let stepIndex = 0
985+
const matchPauseAt = pauseAtMatcher(pauseAt)
921986

922987
const onAfter = t => {
923988
const aiTrace = t.artifacts?.aiTrace
@@ -932,14 +997,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
932997
}
933998
const onStepAfter = step => {
934999
stepIndex += 1
935-
try {
936-
pendingStepInfo = { index: stepIndex, name: step.toString(), status: step.status }
937-
} catch {
938-
pendingStepInfo = { index: stepIndex }
939-
}
940-
if (typeof pauseAt === 'number' && stepIndex === pauseAt) {
941-
pauseNow()
942-
}
1000+
const idx = stepIndex
1001+
const name = (() => { try { return step.toString() } catch { return '' } })()
1002+
recorder.add('mcp pause info', () => {
1003+
pendingStepInfo = { index: idx, name, status: step.status }
1004+
})
1005+
if (matchPauseAt(idx, name)) pauseNow()
9431006
}
9441007
event.dispatcher.on(event.test.after, onAfter)
9451008
event.dispatcher.on(event.step.after, onStepAfter)
@@ -1030,11 +1093,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
10301093
}
10311094
const onStepAfter = step => {
10321095
stepIndex += 1
1033-
try {
1034-
pendingStepInfo = { index: stepIndex, name: step.toString(), status: step.status }
1035-
} catch {
1036-
pendingStepInfo = { index: stepIndex }
1037-
}
1096+
const idx = stepIndex
1097+
const name = (() => { try { return step.toString() } catch { return '' } })()
1098+
recorder.add('mcp pause info', () => {
1099+
pendingStepInfo = { index: idx, name, status: step.status }
1100+
})
10381101
pauseNow()
10391102
}
10401103
event.dispatcher.on(event.test.after, onAfter)

lib/mocha/factory.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,11 @@ let mocha
1717

1818
class MochaFactory {
1919
static create(config, opts) {
20-
// cleanReferencesAfterRun defaults to true in Mocha 10+, which disposes the
21-
// instance after the first .run(). The MCP server reuses one container
22-
// across many tool calls, so disposal breaks every subsequent run_test /
23-
// run_step_by_step. Allow callers to override; default to keeping refs.
24-
const merged = Object.assign({ cleanReferencesAfterRun: false }, config, opts)
20+
const merged = Object.assign({}, config, opts)
2521
mocha = new Mocha(merged)
22+
if (merged.cleanReferencesAfterRun !== true) {
23+
mocha.cleanReferencesAfterRun(false)
24+
}
2625
output.process(opts.child)
2726
mocha.ui(scenarioUiFunction)
2827

0 commit comments

Comments
 (0)