@@ -13,6 +13,8 @@ import {
1313 snapshotDirFor ,
1414 artifactsToFileUrls ,
1515 writeTraceMarkdown ,
16+ TraceReader ,
17+ ariaDiff ,
1618} from '../lib/utils/trace.js'
1719import event from '../lib/event.js'
1820import recorder from '../lib/recorder.js'
@@ -39,8 +41,19 @@ const __dirname = dirname(__filename)
3941let codecept = null
4042let containerInitialized = false
4143let 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
4248let aiTraceEnabled = false // tracked across the session so tool responses can surface a hint when off
4349
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+
4457function aiTraceHint ( ) {
4558 if ( aiTraceEnabled ) return undefined
4659 return 'aiTrace plugin is disabled — re-run start_browser with plugins={ aiTrace: { enabled: true } } to capture per-step DOM/ARIA/console traces for debugging.'
@@ -50,19 +63,81 @@ function applyMochaGrep(grep) {
5063 if ( grep && typeof container . mocha ?. grep === 'function' ) container . mocha . grep ( grep )
5164}
5265
66+ async function ensureBootstrap ( ) {
67+ if ( bootstrapDone ) return
68+ await codecept . bootstrap ( )
69+ bootstrapDone = true
70+ }
71+
72+ async function startShellSession ( ) {
73+ if ( shellSessionActive ) return
74+ await ensureBootstrap ( )
75+ recorder . start ( )
76+ event . emit ( event . suite . before , {
77+ fullTitle : ( ) => 'MCP Session' ,
78+ tests : [ ] ,
79+ retries : ( ) => { } ,
80+ } )
81+ event . emit ( event . test . before , {
82+ title : 'MCP Session' ,
83+ artifacts : { } ,
84+ retries : ( ) => { } ,
85+ } )
86+ shellSessionActive = true
87+ }
88+
89+ async function endShellSession ( ) {
90+ if ( ! shellSessionActive ) return
91+ try { event . emit ( event . test . after , { } ) } catch { }
92+ try { event . emit ( event . suite . after , { } ) } catch { }
93+ try { event . emit ( event . all . result , { } ) } catch { }
94+ shellSessionActive = false
95+ }
96+
97+ async function ensureSession ( ) {
98+ if ( shellSessionActive || pausedController ) return
99+ await startShellSession ( )
100+ }
101+
102+ function normalizePluginOverrides ( plugins ) {
103+ if ( ! plugins || typeof plugins !== 'object' ) return { }
104+ const out = { }
105+ for ( const [ name , opts ] of Object . entries ( plugins ) ) {
106+ if ( opts === false ) continue
107+ out [ name ] = ( opts === true || opts == null ) ? { } : opts
108+ }
109+ return out
110+ }
111+
112+ function applyPluginOverrides ( config , plugins ) {
113+ config . plugins = config . plugins || { }
114+ for ( const [ name , opts ] of Object . entries ( plugins ) ) {
115+ config . plugins [ name ] = { ...( config . plugins [ name ] || { } ) , ...opts , enabled : true }
116+ }
117+ }
118+
119+ function pluginsSignature ( plugins ) {
120+ const keys = Object . keys ( plugins ) . sort ( )
121+ return JSON . stringify ( keys . map ( k => [ k , plugins [ k ] ] ) )
122+ }
123+
53124async function teardownContainer ( ) {
54125 if ( ! containerInitialized ) return
55126 try {
56- await closeSession ( )
57- for ( const helper of Object . values ( container . helpers ( ) || { } ) ) {
58- if ( helper . _cleanup ) await helper . _cleanup ( )
59- else if ( helper . _finishTest ) await helper . _finishTest ( )
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 { }
60132 }
61- if ( codecept ?. teardown ) await codecept . teardown ( )
133+ try { if ( codecept ?. teardown ) await codecept . teardown ( ) } catch { }
62134 } finally {
63135 containerInitialized = false
64136 browserStarted = false
137+ bootstrapDone = false
65138 aiTraceEnabled = false
139+ codecept = null
140+ currentPluginsSig = ''
66141 }
67142}
68143
@@ -372,10 +447,13 @@ function pausedPayload() {
372447 }
373448}
374449
375- async function initCodecept ( configPath , { plugins } = { } ) {
450+ async function initCodecept ( configPath , pluginOverrides ) {
451+ const plugins = normalizePluginOverrides ( pluginOverrides )
452+ const sig = pluginsSignature ( plugins )
453+
376454 if ( containerInitialized ) {
377- if ( plugins ) throw new Error ( 'Container is already running. Call stop_browser before passing plugins again.' )
378- return
455+ if ( ! Object . keys ( plugins ) . length || sig === currentPluginsSig ) return
456+ await teardownContainer ( )
379457 }
380458
381459 const testRoot = process . env . CODECEPTJS_PROJECT_DIR || process . cwd ( )
@@ -401,15 +479,10 @@ async function initCodecept(configPath, { plugins } = {}) {
401479 const { getConfig } = await import ( '../lib/command/utils.js' )
402480 const config = await getConfig ( configPath )
403481
404- config . plugins ??= { }
405- config . plugins . aiTrace = { on : 'step' , ...config . plugins . aiTrace , enabled : true }
406- config . plugins . browser = { show : false , ...config . plugins . browser , enabled : true }
407-
408- if ( plugins ) {
409- for ( const [ name , pluginConfig ] of Object . entries ( plugins ) ) {
410- config . plugins [ name ] = { ...config . plugins [ name ] , enabled : true , ...pluginConfig }
411- }
412- }
482+ // aiTrace is the canonical per-step ARIA/HTML/screenshot capture for MCP.
483+ // Always on so run_code / continue can read the latest snapshot from disk
484+ // instead of double-capturing through grabAriaSnapshot etc.
485+ applyPluginOverrides ( config , { aiTrace : { on : 'step' } , browser : { show : false } , ...plugins } )
413486
414487 codecept = new Codecept ( config , { } )
415488 await codecept . init ( testRoot )
@@ -418,27 +491,7 @@ async function initCodecept(configPath, { plugins } = {}) {
418491 containerInitialized = true
419492 browserStarted = true
420493 aiTraceEnabled = config . plugins ?. aiTrace ?. enabled === true
421-
422- await establishSession ( )
423- }
424-
425- async function establishSession ( ) {
426- if ( recorder . isRunning ( ) ) return
427- recorder . start ( )
428- const syntheticTest = { title : 'mcp_session' , artifacts : { } , opts : { } }
429- for ( const helper of Object . values ( container . helpers ( ) || { } ) ) {
430- if ( typeof helper . _beforeSuite === 'function' ) {
431- try { await helper . _beforeSuite ( ) } catch { }
432- }
433- }
434- event . emit ( event . suite . before , { fullTitle : ( ) => 'MCP Session' , tests : [ ] , retries : undefined } )
435- event . emit ( event . test . before , syntheticTest )
436- for ( const helper of Object . values ( container . helpers ( ) || { } ) ) {
437- if ( typeof helper . _before === 'function' ) {
438- try { await helper . _before ( syntheticTest ) } catch { }
439- }
440- }
441- await recorder . promise ( )
494+ currentPluginsSig = sig
442495}
443496
444497async function formatReturnValue ( value ) {
@@ -449,13 +502,6 @@ async function formatReturnValue(value) {
449502 return value
450503}
451504
452- async function closeSession ( ) {
453- if ( ! recorder . isRunning ( ) ) return
454- event . emit ( event . test . after , { title : 'mcp_session' , artifacts : { } } )
455- event . emit ( event . suite . after , { title : 'MCP Session' } )
456- try { await recorder . promise ( ) } catch { }
457- }
458-
459505const server = new Server (
460506 { name : 'codeceptjs-mcp-server' , version : '1.0.0' } ,
461507 { capabilities : { tools : { } } }
@@ -513,6 +559,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
513559 timeout : { type : 'number' } ,
514560 grep : { type : 'string' , description : 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' } ,
515561 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.' } ,
562+ plugins : PLUGINS_PROP ,
516563 } ,
517564 required : [ 'test' ] ,
518565 } ,
@@ -526,6 +573,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
526573 test : { type : 'string' } ,
527574 timeout : { type : 'number' } ,
528575 grep : { type : 'string' , description : 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' } ,
576+ plugins : PLUGINS_PROP ,
529577 } ,
530578 required : [ 'test' ] ,
531579 } ,
@@ -624,11 +672,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
624672
625673 case 'start_browser' : {
626674 const { config : configPath , plugins } = args || { }
627- if ( browserStarted ) {
628- return { content : [ { type : 'text' , text : JSON . stringify ( { status : 'Browser already started ' , plugins : plugins ?? null } , null , 2 ) } ] }
675+ if ( browserStarted && shellSessionActive ) {
676+ return { content : [ { type : 'text' , text : JSON . stringify ( { status : 'Session already active ' , plugins : plugins ?? null } , null , 2 ) } ] }
629677 }
630- await initCodecept ( configPath , { plugins } )
631- return { content : [ { type : 'text' , text : JSON . stringify ( { status : 'Browser started successfully' , plugins : plugins ?? null } , null , 2 ) } ] }
678+ await initCodecept ( configPath , plugins )
679+ await startShellSession ( )
680+ return { content : [ { type : 'text' , text : JSON . stringify ( { status : 'Session started — run_code and snapshot are now available' , plugins : plugins ?? null } , null , 2 ) } ] }
632681 }
633682
634683 case 'stop_browser' : {
@@ -642,6 +691,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
642691 case 'snapshot' : {
643692 const { fullPage = false , settleMs = 300 } = args || { }
644693 await initCodecept ( )
694+ await ensureSession ( )
645695
646696 const helper = pickActingHelper ( container . helpers ( ) )
647697 if ( ! helper ) throw new Error ( 'No supported acting helper available (Playwright, Puppeteer, WebDriver).' )
@@ -708,6 +758,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
708758 case 'run_code' : {
709759 const { code, timeout = 60000 , saveArtifacts = true , settleMs = 300 } = args
710760 await initCodecept ( )
761+ await ensureSession ( )
711762
712763 const support = container . supportObjects ( ) || { }
713764 if ( ! support . I ) throw new Error ( 'I object not available. Make sure helpers are configured.' )
@@ -729,6 +780,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
729780 mkdirp . sync ( traceDir )
730781 const startedAt = Date . now ( )
731782
783+ // Pin the latest aiTrace ARIA file before running the code, so we
784+ // can diff after. aiTrace owns per-step capture; we just read it.
785+ const reader = new TraceReader ( currentAiTraceDir )
786+ const ariaBefore = reader . last ( 'aria' )
787+
732788 const MAX_LOG_ENTRIES = 100
733789 const MAX_LOG_MSG_BYTES = 2000
734790 const MAX_RETURN_BYTES = 20000
@@ -809,6 +865,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
809865 }
810866 }
811867
868+ // Diff against the latest aiTrace ARIA file produced by the steps
869+ // that just ran inside this run_code call.
870+ const ariaAfter = reader . last ( 'aria' )
871+ if ( ariaBefore && ariaAfter && ariaBefore !== ariaAfter ) {
872+ const diff = ariaDiff ( ariaBefore , ariaAfter )
873+ if ( diff ) result . ariaDiff = diff
874+ }
875+
812876 const traceFile = writeTraceMarkdown ( {
813877 dir : traceDir ,
814878 title : 'run_code' ,
@@ -830,8 +894,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
830894 if ( pausedController ) {
831895 throw new Error ( 'A previous run_test is still paused. Call "continue" first.' )
832896 }
833- const { test, timeout = 60000 , pauseAt, grep } = args || { }
834- await initCodecept ( )
897+ const { test, timeout = 60000 , pauseAt, grep, plugins } = args || { }
898+ await initCodecept ( undefined , plugins )
899+ await endShellSession ( )
835900 applyMochaGrep ( grep )
836901
837902 return await withSilencedIO ( async ( ) => {
@@ -887,7 +952,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
887952 let runError = null
888953 const runPromise = ( async ( ) => {
889954 try {
890- await codecept . bootstrap ( )
955+ await ensureBootstrap ( )
891956 await codecept . run ( testFile )
892957 } catch ( err ) {
893958 runError = err
@@ -916,7 +981,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
916981 }
917982
918983 const final = collectRunCompletion ( runError ?. message )
919- await establishSession ( )
984+ await startShellSession ( )
920985 return { content : [ { type : 'text' , text : JSON . stringify ( { ...final , file : testFile } , null , 2 ) } ] }
921986 } )
922987 } )
@@ -927,8 +992,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
927992 if ( pausedController ) {
928993 throw new Error ( 'A previous run is still paused. Call "continue" first.' )
929994 }
930- const { test, timeout = 60000 , grep } = args || { }
931- await initCodecept ( )
995+ const { test, timeout = 60000 , grep, plugins } = args || { }
996+ await initCodecept ( undefined , plugins )
997+ await endShellSession ( )
932998 applyMochaGrep ( grep )
933999
9341000 return await withSilencedIO ( async ( ) => {
@@ -982,7 +1048,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
9821048 let runError = null
9831049 const runPromise = ( async ( ) => {
9841050 try {
985- await codecept . bootstrap ( )
1051+ await ensureBootstrap ( )
9861052 await codecept . run ( testFile )
9871053 } catch ( err ) {
9881054 runError = err
@@ -1011,7 +1077,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
10111077 }
10121078
10131079 const final = collectRunCompletion ( runError ?. message )
1014- await establishSession ( )
1080+ await startShellSession ( )
10151081 return { content : [ { type : 'text' , text : JSON . stringify ( { ...final , file : testFile } , null , 2 ) } ] }
10161082 } )
10171083 } )
0 commit comments