@@ -28,6 +28,7 @@ let mockBundleApi: ChildProcess | null = null;
2828let stubMcpHttp : ChildProcess | null = null ;
2929let stubMcpOauth : ChildProcess | null = null ;
3030let shouldExit = false ;
31+ let tauriDriverCrashed = false ;
3132
3233// Mock server ports
3334// Use 8787 for bundle API because that's the app's default MCPMUX_REGISTRY_URL
@@ -201,8 +202,34 @@ function stopMockServers(): void {
201202
202203function closeTauriDriver ( ) {
203204 shouldExit = true ;
204- tauriDriver ?. kill ( ) ;
205- stopMockServers ( ) ;
205+ if ( tauriDriver ) {
206+ tauriDriver . kill ( ) ;
207+ tauriDriver = null ;
208+ }
209+ // NOTE: Do NOT pkill tauri-driver or stop mock servers here.
210+ // This function is called during afterSession while WebdriverIO's own
211+ // deleteSession is still in-flight. Killing tauri-driver at this point
212+ // causes connection errors that cascade to all subsequent workers.
213+ // Aggressive cleanup is done in beforeSession instead, before spawning
214+ // a fresh tauri-driver.
215+ }
216+
217+ // Wait for tauri-driver to accept WebDriver connections on port 4444.
218+ // Polls GET /status until tauri-driver responds (any HTTP response means it's ready).
219+ async function waitForTauriDriverReady ( timeout = 30000 ) : Promise < boolean > {
220+ const start = Date . now ( ) ;
221+ while ( Date . now ( ) - start < timeout ) {
222+ try {
223+ await fetch ( 'http://localhost:4444/status' ) ;
224+ console . log ( '[e2e] tauri-driver is ready on port 4444' ) ;
225+ return true ;
226+ } catch {
227+ // Not ready yet (ECONNREFUSED)
228+ }
229+ await new Promise ( ( resolve ) => setTimeout ( resolve , 500 ) ) ;
230+ }
231+ console . error ( `[e2e] tauri-driver not ready after ${ timeout } ms` ) ;
232+ return false ;
206233}
207234
208235// Kill any processes listening on our mock server ports (leftover from previous runs)
@@ -229,11 +256,12 @@ function killPortProcesses(): void {
229256function killMcpmuxProcesses ( ) : void {
230257 try {
231258 if ( process . platform === 'win32' ) {
232- // On Windows, use taskkill
259+ // On Windows, use taskkill (matches exact process name)
233260 spawnSync ( 'taskkill' , [ '/F' , '/IM' , 'mcpmux.exe' ] , { stdio : 'ignore' } ) ;
234261 } else {
235- // On Linux, use pkill
236- spawnSync ( 'pkill' , [ '-9' , '-f' , 'mcpmux' ] , { stdio : 'ignore' } ) ;
262+ // On Linux, kill only the exact mcpmux binary (not anything with "mcpmux" in its cmdline).
263+ // pkill without -f matches the process name only, which is safer than -f (full cmdline).
264+ spawnSync ( 'pkill' , [ '-9' , 'mcpmux' ] , { stdio : 'ignore' } ) ;
237265 }
238266 console . log ( '[e2e] Killed any existing mcpmux processes' ) ;
239267 } catch ( error ) {
@@ -268,9 +296,10 @@ function onShutdown(fn: () => void) {
268296 process . on ( 'SIGTERM' , cleanup ) ;
269297}
270298
271- // Ensure tauri-driver is closed when test process exits
299+ // Ensure tauri-driver and mock servers are closed when test process exits
272300onShutdown ( ( ) => {
273301 closeTauriDriver ( ) ;
302+ stopMockServers ( ) ;
274303} ) ;
275304
276305export const config : Options . Testrunner = {
@@ -315,11 +344,17 @@ export const config: Options.Testrunner = {
315344 return `wdio-junit-${ options . cid } .xml` ;
316345 } ,
317346 } ] ,
318- [ video , {
319- saveAllVideos : process . env . SAVE_ALL_VIDEOS === 'true' , // Save all videos when env var is set
320- videoSlowdownMultiplier : 1 , // Normal speed
321- outputDir : './tests/e2e/videos/' ,
322- } ] ,
347+ // Only enable video reporter when explicitly requested via SAVE_ALL_VIDEOS=true.
348+ // The video reporter takes a screenshot after every WebDriver command, including
349+ // during session teardown. This races with deleteSession killing the Tauri app,
350+ // causing UND_ERR_SOCKET errors that mark passing specs as FAILED.
351+ ...( process . env . SAVE_ALL_VIDEOS === 'true'
352+ ? [ [ video , {
353+ saveAllVideos : true ,
354+ videoSlowdownMultiplier : 1 ,
355+ outputDir : './tests/e2e/videos/' ,
356+ } ] ]
357+ : [ ] ) ,
323358 ] ,
324359
325360 mochaOpts : {
@@ -334,8 +369,15 @@ export const config: Options.Testrunner = {
334369 // Sanitize test title: replace invalid filename chars (NTFS: " : < > | * ? \r \n) and spaces
335370 const safeTitle = test . title . replace ( / [ " : * ? < > | \r \n \\ \/ ] + / g, '-' ) . replace ( / \s + / g, '_' ) ;
336371 const filename = `./tests/e2e/screenshots/FAIL-${ safeTitle } -${ timestamp } .png` ;
337- await browser . saveScreenshot ( filename ) ;
338- console . log ( `[e2e] Screenshot saved: ${ filename } ` ) ;
372+ try {
373+ await browser . saveScreenshot ( filename ) ;
374+ console . log ( `[e2e] Screenshot saved: ${ filename } ` ) ;
375+ } catch {
376+ // Screenshot may fail if tauri-driver crashed
377+ if ( tauriDriverCrashed ) {
378+ console . error ( `[e2e] Cannot save screenshot - tauri-driver crashed` ) ;
379+ }
380+ }
339381 }
340382 } ,
341383
@@ -360,10 +402,13 @@ export const config: Options.Testrunner = {
360402 // Verify app is built
361403 checkAppBuilt ( ) ;
362404
363- // Kill any leftover mcpmux processes and clear all app data BEFORE
405+ // Kill any leftover mcpmux and tauri-driver processes and clear all app data BEFORE
364406 // tauri-driver starts the app. This avoids EBUSY errors from trying
365407 // to delete the SQLite DB while the app still holds a lock on it.
366408 killMcpmuxProcesses ( ) ;
409+ if ( process . platform !== 'win32' ) {
410+ spawnSync ( 'pkill' , [ '-9' , 'tauri-driver' ] , { stdio : 'ignore' } ) ;
411+ }
367412 // Brief pause to let processes fully exit
368413 await new Promise ( ( resolve ) => setTimeout ( resolve , 2000 ) ) ;
369414 clearSingleInstanceLock ( ) ;
@@ -380,8 +425,32 @@ export const config: Options.Testrunner = {
380425 await startMockServers ( ) ;
381426 } ,
382427
383- // Start tauri-driver before the session starts
384- beforeSession : function ( ) {
428+ // Start tauri-driver before the session starts.
429+ // Performs aggressive cleanup of leftover processes from the previous spec
430+ // before spawning a fresh tauri-driver, then waits for it to be ready.
431+ beforeSession : async function ( ) {
432+ shouldExit = false ;
433+ tauriDriverCrashed = false ;
434+
435+ // --- Aggressive cleanup from previous spec ---
436+ // Kill any leftover tauri-driver processes (may remain if previous spec crashed).
437+ // This is safe to do here because no tauri-driver should be running between specs.
438+ if ( process . platform !== 'win32' ) {
439+ spawnSync ( 'pkill' , [ '-9' , 'tauri-driver' ] , { stdio : 'ignore' } ) ;
440+ }
441+ // Kill any leftover mcpmux app processes and clear single-instance lock
442+ killMcpmuxProcesses ( ) ;
443+ clearSingleInstanceLock ( ) ;
444+
445+ // Free the gateway port (45818) in case mcpmux didn't release it
446+ if ( process . platform !== 'win32' ) {
447+ spawnSync ( 'fuser' , [ '-k' , '-9' , '45818/tcp' ] , { stdio : 'ignore' } ) ;
448+ }
449+
450+ // Wait for OS to fully reclaim process resources (ports, file locks, etc.)
451+ await new Promise ( ( resolve ) => setTimeout ( resolve , 2000 ) ) ;
452+
453+ // --- Spawn fresh tauri-driver ---
385454 const tauriDriverPath = path . resolve (
386455 os . homedir ( ) ,
387456 '.cargo' ,
@@ -400,24 +469,42 @@ export const config: Options.Testrunner = {
400469
401470 tauriDriver . on ( 'error' , ( error ) => {
402471 console . error ( '[tauri-driver] Error:' , error ) ;
403- process . exit ( 1 ) ;
472+ // Don't call process.exit(1) - it kills the worker before JUnit XML
473+ // reports are finalized, resulting in malformed/empty XML files.
474+ // Let WebdriverIO handle the failure naturally via connection errors.
475+ tauriDriverCrashed = true ;
404476 } ) ;
405477
406478 tauriDriver . on ( 'exit' , ( code ) => {
407479 if ( ! shouldExit ) {
408- console . error ( '[tauri-driver] Exited with code:' , code ) ;
409- process . exit ( 1 ) ;
480+ console . error ( '[tauri-driver] Exited unexpectedly with code:' , code ) ;
481+ // Don't call process.exit(1) - let the test fail gracefully so that
482+ // JUnit XML reports are properly written. WebdriverIO will detect
483+ // the broken connection and fail the affected tests.
484+ tauriDriverCrashed = true ;
410485 }
411486 } ) ;
487+
488+ // Wait for tauri-driver to be ready before letting WebdriverIO create a session.
489+ // Without this, WebdriverIO may send POST /session before tauri-driver is listening,
490+ // causing a 2-minute timeout (connectionRetryTimeout) and cascading failures.
491+ await waitForTauriDriverReady ( 30000 ) ;
412492 } ,
413493
414- // Stop tauri-driver after the session
415- afterSession : function ( ) {
494+ // Stop tauri-driver after the session.
495+ // Uses graceful SIGTERM only — aggressive cleanup (pkill -9) is deferred to
496+ // the next spec's beforeSession to avoid racing with WebdriverIO's own
497+ // deleteSession call, which would cause cascading failures in subsequent specs.
498+ afterSession : async function ( ) {
499+ // Mark as crashed to prevent afterTest screenshot attempts against a dying session.
500+ // On Linux, WebKitGTK tears down the process synchronously on deleteSession,
501+ // so any pending screenshot requests will hit a dead socket.
502+ tauriDriverCrashed = true ;
503+
416504 closeTauriDriver ( ) ;
417-
418- // Kill the mcpmux app process and clear lock to prevent conflicts between test workers
419- killMcpmuxProcesses ( ) ;
420- clearSingleInstanceLock ( ) ;
505+
506+ // Brief pause to let tauri-driver/mcpmux handle SIGTERM gracefully
507+ await new Promise ( ( resolve ) => setTimeout ( resolve , 1000 ) ) ;
421508 } ,
422509
423510 // Clean up mock servers after all tests complete
0 commit comments