Skip to content

Commit 633f846

Browse files
committed
Merge branch 'claude/studio-logs-mcp-server-D5WJm' of github.com:ion-ash/mcp-mux into claude/studio-logs-mcp-server-D5WJm
2 parents d3daa7c + 042e325 commit 633f846

3 files changed

Lines changed: 134 additions & 27 deletions

File tree

.github/workflows/e2e-desktop.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ jobs:
5454
- uses: pnpm/action-setup@v4
5555
- uses: actions/setup-node@v4
5656
with:
57-
node-version: 20
57+
node-version: 22
5858
cache: 'pnpm'
5959

6060
# Cache pnpm store for faster installs
@@ -162,6 +162,20 @@ jobs:
162162
pattern: e2e-desktop-results-*
163163
path: test-results
164164

165+
# Remove malformed/empty JUnit XMLs that crash dorny/test-reporter.
166+
# This can happen when tauri-driver crashes mid-spec and the JUnit
167+
# reporter writes incomplete XML (missing testsuites or test names).
168+
- name: Sanitize JUnit XML reports
169+
if: always()
170+
run: |
171+
for f in test-results/e2e-desktop-results-*/reports/*.xml; do
172+
[ -f "$f" ] || continue
173+
if ! grep -q '<testsuites' "$f" 2>/dev/null; then
174+
echo "Removing malformed JUnit XML: $f"
175+
rm "$f"
176+
fi
177+
done
178+
165179
- name: 'Report: E2E Desktop Tests (Linux)'
166180
uses: dorny/test-reporter@v2
167181
if: always()

tests/e2e/specs/settings.wdio.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
* Uses data-testid only (ADR-003).
44
*/
55

6-
import { byTestId } from '../helpers/selectors';
6+
import { byTestId, TIMEOUT } from '../helpers/selectors';
77

88
describe('Settings Page', () => {
99
before(async () => {
10+
// Wait for app to fully load (on CI, app startup can be slower after many prior specs)
11+
await browser.pause(3000);
12+
const sidebar = await byTestId('sidebar');
13+
await sidebar.waitForDisplayed({ timeout: TIMEOUT.veryLong });
14+
1015
const settingsBtn = await byTestId('nav-settings');
16+
await settingsBtn.waitForClickable({ timeout: TIMEOUT.medium });
1117
await settingsBtn.click();
1218
await browser.pause(2000);
1319
});

tests/e2e/wdio.conf.ts

Lines changed: 112 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ let mockBundleApi: ChildProcess | null = null;
2828
let stubMcpHttp: ChildProcess | null = null;
2929
let stubMcpOauth: ChildProcess | null = null;
3030
let 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

202203
function 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 {
229256
function 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
272300
onShutdown(() => {
273301
closeTauriDriver();
302+
stopMockServers();
274303
});
275304

276305
export 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

Comments
 (0)