From e89b4ae42c77f6bf7f95bef9e61e9d3302b11fcb Mon Sep 17 00:00:00 2001 From: Vince Graics Date: Thu, 4 Jun 2026 18:32:56 +0200 Subject: [PATCH 1/5] feat(lambdatest): Integrate TestMU hub connection with tunnel, mirroring SauceLabs/BrowserStack - Adding LambdaTunnel declaration to types - Intorducing resources for tunnel setup --- .gitignore | 5 +- package.json | 1 + pnpm-lock.yaml | 72 +++++++ src/providers/cloud/testmu.provider.ts | 176 ++++++++++++++++ src/providers/registry.ts | 2 + src/recording/code-generator.ts | 85 +++++++- src/resources/index.ts | 1 + src/resources/testmu-local.resource.ts | 75 +++++++ src/server.ts | 2 + src/session/state.ts | 2 +- src/tools/cloud-provider.tool.ts | 83 ++++++-- src/tools/session.tool.ts | 16 +- src/types/lambdatest-node-tunnel.d.ts | 17 ++ tests/providers/registry.test.ts | 13 ++ tests/providers/testmu.provider.test.ts | 269 ++++++++++++++++++++++++ tests/tools/cloud-provider.tool.test.ts | 128 +++++++++++ 16 files changed, 922 insertions(+), 25 deletions(-) create mode 100644 src/providers/cloud/testmu.provider.ts create mode 100644 src/resources/testmu-local.resource.ts create mode 100644 src/types/lambdatest-node-tunnel.d.ts create mode 100644 tests/providers/testmu.provider.test.ts diff --git a/.gitignore b/.gitignore index 24f876a..f26798e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,7 @@ settings.local.json *.tgz .mcp.json -.env \ No newline at end of file +.env + +.lambdatest +*.apk \ No newline at end of file diff --git a/package.json b/package.json index dd57eaa..cfa8be3 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "test": "vitest run" }, "dependencies": { + "@lambdatest/node-tunnel": "^4.0.11", "@modelcontextprotocol/sdk": "^1.27.1", "@toon-format/toon": "^2.1.0", "@wdio/protocols": "^9.27.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bcecb0..8e30a9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: .: dependencies: + '@lambdatest/node-tunnel': + specifier: ^4.0.11 + version: 4.0.11 '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.29.0(zod@4.4.3) @@ -808,6 +811,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lambdatest/node-tunnel@4.0.11': + resolution: {integrity: sha512-IPCBUECSLHOsUhhaopMGkcBV3sRrt6w+y+Lfykm3rR9I0xNgjHNOQ73hxawDuN/188aneouWoDxnrOMEDeFpiA==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -1416,6 +1422,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1496,6 +1506,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -2330,6 +2343,15 @@ packages: flushwritable@1.0.0: resolution: {integrity: sha512-3VELfuWCLVzt5d2Gblk8qcqFro6nuwvxwMzHaENVDHI7rxcBRtMCwTk/E9FXcgh+82DSpavPNDueA9+RxXJoFg==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2378,6 +2400,11 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-port@1.0.0: + resolution: {integrity: sha512-vg59F3kcXBOtcIijwtdAyCxFocyv/fVkGQvw1kVGrxFO1U4SSGkGjrbASg5DN3TVekVle/jltwOjYRnZWc1YdA==} + engines: {node: '>=0.10.0'} + hasBin: true + get-port@7.2.0: resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} engines: {node: '>=16'} @@ -3255,6 +3282,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3590,6 +3621,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + split@1.0.1: + resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3696,6 +3730,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4629,6 +4666,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lambdatest/node-tunnel@4.0.11': + dependencies: + adm-zip: 0.5.17 + axios: 1.17.0 + get-port: 1.0.0 + https-proxy-agent: 5.0.1 + split: 1.0.1 + transitivePeerDependencies: + - debug + - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.18) @@ -5230,6 +5278,8 @@ snapshots: acorn@8.16.0: {} + adm-zip@0.5.17: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -5316,6 +5366,16 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axios@1.17.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + b4a@1.8.1: {} balanced-match@1.0.2: {} @@ -6296,6 +6356,8 @@ snapshots: flushwritable@1.0.0: {} + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -6352,6 +6414,8 @@ snapshots: hasown: 2.0.3 math-intrinsics: 1.1.0 + get-port@1.0.0: {} + get-port@7.2.0: {} get-proto@1.0.1: @@ -7165,6 +7229,8 @@ snapshots: proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -7634,6 +7700,10 @@ snapshots: split2@4.2.0: {} + split@1.0.1: + dependencies: + through: 2.3.8 + stackback@0.0.2: {} statuses@2.0.2: {} @@ -7768,6 +7838,8 @@ snapshots: dependencies: any-promise: 1.3.0 + through@2.3.8: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} diff --git a/src/providers/cloud/testmu.provider.ts b/src/providers/cloud/testmu.provider.ts new file mode 100644 index 0000000..cb05c9e --- /dev/null +++ b/src/providers/cloud/testmu.provider.ts @@ -0,0 +1,176 @@ +import { basicAuth } from '../../utils/auth'; +import type { ConnectionConfig, SessionProvider, SessionResult } from '../types'; +import type { Browser as WdioBrowser } from 'webdriverio'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export class TestMuProvider implements SessionProvider { + name = 'testmu'; + + getConnectionConfig(options: Record): ConnectionConfig { + const platform = options.platform as string; + const isBrowser = platform === 'browser'; + return { + protocol: 'https', + hostname: isBrowser ? 'hub.lambdatest.com' : 'mobile-hub.lambdatest.com', + port: 443, + path: '/wd/hub', + user: process.env.TESTMU_USERNAME, + key: process.env.TESTMU_ACCESS_KEY, + }; + } + + buildCapabilities(options: Record): Record { + const platform = options.platform as string; + const userCapabilities = (options.capabilities as Record | undefined) ?? {}; + const tunnel = (options.tunnel ?? options.testmuLocal) as boolean | string | undefined; + const reporting = options.reporting as { project?: string; build?: string; session?: string } | undefined; + + const ltOptions: Record = { w3c: true }; + + if (process.env.TESTMU_USERNAME) ltOptions.username = process.env.TESTMU_USERNAME; + if (process.env.TESTMU_ACCESS_KEY) ltOptions.accessKey = process.env.TESTMU_ACCESS_KEY; + if (reporting?.project) ltOptions.project = reporting.project; + if (reporting?.build) ltOptions.build = reporting.build; + if (reporting?.session) ltOptions.name = reporting.session; + else if (reporting?.project) ltOptions.name = reporting.project; + + if (tunnel) { + ltOptions.tunnel = true; + if (options.tunnelName) ltOptions.tunnelName = options.tunnelName; + } + + if (platform === 'browser') { + return { + browserName: (options.browser as string | undefined) ?? 'chrome', + browserVersion: (options.browserVersion as string | undefined) ?? 'latest', + platformName: (options.os as string | undefined) ?? 'Linux', + 'lt:options': ltOptions, + ...userCapabilities, + }; + } + + // Mobile (ios / android) + const mobileBrowser = options.browser as string | undefined; + + // Mobile browser/emulator mode (e.g. Chrome on Android emulator) + if (mobileBrowser) { + ltOptions.appiumVersion = '2.11.0'; + ltOptions.isRealMobile = false; + if (options.deviceOrientation) ltOptions.deviceOrientation = options.deviceOrientation; + + const caps: Record = { + platformName: platform, + browserName: mobileBrowser, + 'appium:deviceName': options.deviceName, + 'appium:platformVersion': options.platformVersion, + 'appium:automationName': (options.automationName as string | undefined) ?? (platform === 'ios' ? 'XCUITest' : 'UiAutomator2'), + 'appium:newCommandTimeout': (options.newCommandTimeout as number | undefined) ?? 300, + 'lt:options': ltOptions, + }; + return { ...caps, ...userCapabilities }; + } + + // Mobile native app mode + ltOptions.appiumVersion = 'latest'; + ltOptions.isRealMobile = true; + + const autoAcceptAlerts = options.autoAcceptAlerts as boolean | undefined; + const autoDismissAlerts = options.autoDismissAlerts as boolean | undefined; + + const caps: Record = { + platformName: platform, + 'appium:app': options.app, + 'appium:deviceName': options.deviceName, + 'appium:platformVersion': options.platformVersion, + 'appium:automationName': (options.automationName as string | undefined) ?? (platform === 'ios' ? 'XCUITest' : 'UiAutomator2'), + 'appium:autoGrantPermissions': (options.autoGrantPermissions as boolean | undefined) ?? true, + 'appium:autoAcceptAlerts': autoDismissAlerts ? undefined : (autoAcceptAlerts ?? true), + 'appium:autoDismissAlerts': autoDismissAlerts, + 'appium:newCommandTimeout': (options.newCommandTimeout as number | undefined) ?? 300, + 'lt:options': ltOptions, + }; + + return { ...caps, ...userCapabilities }; + } + + getSessionType(options: Record): 'browser' | 'ios' | 'android' { + const platform = options.platform as string; + if (platform === 'browser') return 'browser'; + return platform as 'ios' | 'android'; + } + + shouldAutoDetach(_options: Record): boolean { + return false; + } + + async startTunnel(options: Record): Promise { + const tunnelName = (options.tunnelName as string | undefined) ?? `wdio-mcp-testmu-${Date.now()}`; + const logFile = join(tmpdir(), 'testmu-tunnel.log'); + console.error(`[TestMu] Starting tunnel "${tunnelName}"`); + try { + const { default: LambdaTunnel } = await import('@lambdatest/node-tunnel'); + const tunnel = new LambdaTunnel(); + await tunnel.start({ + user: process.env.TESTMU_USERNAME ?? '', + key: process.env.TESTMU_ACCESS_KEY ?? '', + tunnelName, + logFile, + }); + console.error(`[TestMu] Tunnel started: "${tunnelName}"`); + return tunnel; + } catch (e: unknown) { + const msg = (e !== null && typeof e === 'object' ? (e as { message?: string }).message : undefined) ?? String(e); + if (msg.includes('already running') || msg.includes('another tunnel') || msg.includes('already in use')) { + console.error('[TestMu] Tunnel already running — reusing existing tunnel'); + return null; + } + throw e; + } + } + + async onSessionClose( + sessionId: string, + _sessionType: 'browser' | 'ios' | 'android', + result: SessionResult, + _tunnelHandle?: unknown, + _browser?: WdioBrowser, + _region?: string, + ): Promise { + const user = process.env.TESTMU_USERNAME; + const key = process.env.TESTMU_ACCESS_KEY; + if (user && key) { + try { + const auth = basicAuth(user, key); + const body = { status_ind: result.status === 'passed' ? 'passed' : 'failed' }; + const apiUrl = `https://api.lambdatest.com/automation/api/v1/sessions/${sessionId}`; + console.error(`[TestMu] Setting session status for ${sessionId}: ${body.status_ind}`); + const res = await fetch(apiUrl, { + method: 'PATCH', + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (res.ok) { + console.error('[TestMu] Session status set successfully via REST API'); + } else { + const resBody = await res.text(); + console.error(`[TestMu] Failed to set session status: HTTP ${res.status} — ${resBody}`); + } + } catch (e) { + console.error('[TestMu] Failed to set session status via REST API:', e); + } + } + } + + async stopTunnel(tunnelHandle?: unknown): Promise { + if (tunnelHandle) { + const tunnel = tunnelHandle as { stop: () => Promise }; + await tunnel.stop(); + } + } +} + +export const testMuProvider = new TestMuProvider(); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 6f88856..1a797a4 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -3,10 +3,12 @@ import { localBrowserProvider } from './local-browser.provider'; import { localAppiumProvider } from './local-appium.provider'; import { browserStackProvider } from './cloud/browserstack.provider'; import { sauceLabsProvider } from './cloud/saucelabs.provider'; +import { testMuProvider } from './cloud/testmu.provider'; const providers = new Map([ ['browserstack', browserStackProvider], ['saucelabs', sauceLabsProvider], + ['testmu', testMuProvider], ]); function getDefaultProvider(platform: string): SessionProvider { diff --git a/src/recording/code-generator.ts b/src/recording/code-generator.ts index 1fe4059..9751520 100644 --- a/src/recording/code-generator.ts +++ b/src/recording/code-generator.ts @@ -35,6 +35,7 @@ function generateStep(step: RecordedStep, history: SessionHistory): string { const platform = p.platform as string; const isBrowserStack = 'bstack:options' in history.capabilities; const isSauceLabs = 'sauce:options' in history.capabilities; + const isLambdaTest = 'lt:options' in history.capabilities; const capJson = indentJson(history.capabilities) .split('\n') .map((line, i) => (i > 0 ? ` ${line}` : line)) @@ -77,6 +78,26 @@ function generateStep(step: RecordedStep, history: SessionHistory): string { ].join('\n'); } + if (isLambdaTest) { + const isBrowser = platform === 'browser'; + const hostname = isBrowser ? 'hub.lambdatest.com' : 'mobile-hub.lambdatest.com'; + const nav = + platform === 'browser' && p.navigationUrl + ? `\nawait browser.url('${escapeStr(p.navigationUrl)}');` + : ''; + return [ + 'const browser = await remote({', + " protocol: 'https',", + ` hostname: '${hostname}',`, + ' port: 443,', + " path: '/wd/hub',", + ' user: process.env.TESTMU_USERNAME,', + ' key: process.env.TESTMU_ACCESS_KEY,', + ` capabilities: ${capJson}`, + `});${nav}`, + ].join('\n'); + } + if (platform === 'browser') { const nav = p.navigationUrl ? `\nawait browser.url('${escapeStr(p.navigationUrl)}');` : ''; return `const browser = await remote({\n capabilities: ${indentJson(history.capabilities)}\n});${nav}`; @@ -142,9 +163,13 @@ function bsStatusUpdateLines(sessionType: 'browser' | 'ios' | 'android'): string export function generateCode(history: SessionHistory): string { const bstackOptions = history.capabilities['bstack:options'] as Record | undefined; const sauceOptions = history.capabilities['sauce:options'] as Record | undefined; + const ltOptions = history.capabilities['lt:options'] as Record | undefined; const isBrowserStack = bstackOptions !== undefined; const isSauceLabs = sauceOptions !== undefined; - const usesLocalTunnel = isBrowserStack ? bstackOptions?.local === true : (sauceOptions?.tunnelName !== undefined); + const isLambdaTest = ltOptions !== undefined; + const usesLocalTunnel = isBrowserStack ? bstackOptions?.local === true + : isSauceLabs ? (sauceOptions?.tunnelName !== undefined) + : (ltOptions?.tunnel === true); const steps = history.steps .map(step => generateStep(step, history)) @@ -261,5 +286,63 @@ export function generateCode(history: SessionHistory): string { ].join('\n'); } + if (isLambdaTest) { + const ltSteps = steps.replace(/const browser = await remote\(/g, 'browser = await remote('); + const preamble = 'let browser;\nlet ltStatus = \'passed\';'; + const catchBlock = '} catch (e) {\n ltStatus = \'failed\';\n throw e;'; + const statusUpdate = [ + " const ltAuth = Buffer.from(`${process.env.TESTMU_USERNAME}:${process.env.TESTMU_ACCESS_KEY}`).toString('base64');", + " await fetch('https://api.lambdatest.com/automation/api/v1/sessions/' + browser.sessionId, {", + " method: 'PATCH',", + " headers: { Authorization: 'Basic ' + ltAuth, 'Content-Type': 'application/json' },", + ' body: JSON.stringify({ status_ind: ltStatus })', + ' });', + ].join('\n'); + const finallyLines = [ + ' if (browser) {', + statusUpdate, + ' await browser.deleteSession();', + ' }', + ]; + + if (usesLocalTunnel) { + const tunnelName = (ltOptions?.tunnelName as string) ?? 'wdio-mcp-tunnel'; + const tunnelSetup = [ + '', + "import LambdaTunnel from '@lambdatest/node-tunnel';", + '', + 'const tunnel = new LambdaTunnel();', + `await tunnel.start({ user: process.env.TESTMU_USERNAME, key: process.env.TESTMU_ACCESS_KEY, tunnelName: '${escapeStr(tunnelName)}' });`, + 'const stopTunnel = () => tunnel.stop();', + '', + ].join('\n'); + + return [ + "import { remote } from 'webdriverio';", + tunnelSetup, + preamble, + 'try {', + ltSteps, + catchBlock, + '} finally {', + ...finallyLines, + ' await stopTunnel();', + '}', + ].join('\n'); + } + + return [ + "import { remote } from 'webdriverio';", + '', + preamble, + 'try {', + ltSteps, + catchBlock, + '} finally {', + ...finallyLines, + '}', + ].join('\n'); + } + return `import { remote } from 'webdriverio';\n\ntry {\n${steps}\n} finally {\n await browser.deleteSession();\n}`; } diff --git a/src/resources/index.ts b/src/resources/index.ts index b5499a6..877f683 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -1,5 +1,6 @@ export * from './browserstack-local.resource'; export * from './saucelabs-local.resource'; +export * from './testmu-local.resource'; export * from './sessions.resource'; export * from './capabilities.resource'; export * from './elements.resource'; diff --git a/src/resources/testmu-local.resource.ts b/src/resources/testmu-local.resource.ts new file mode 100644 index 0000000..94b37b6 --- /dev/null +++ b/src/resources/testmu-local.resource.ts @@ -0,0 +1,75 @@ +import type { ResourceDefinition } from '../types/resource'; + +function getLocalBinaryInfo(): { + downloadUrl: string; + platform: string; + arch: string; + binaryName: string; +} { + const platform = process.platform; + const arch = process.arch; + + if (platform === 'darwin') { + return { + downloadUrl: 'https://downloads.lambdatest.com/tunnel/v4/darwin/64bit/LT_Darwin.zip', + platform: 'macOS', + arch: arch === 'arm64' ? 'Apple Silicon (via Rosetta 2)' : 'Intel x64', + binaryName: 'LT', + }; + } + + if (platform === 'win32') { + return { + downloadUrl: 'https://downloads.lambdatest.com/tunnel/v4/win/64bit/LT_Windows.zip', + platform: 'Windows', + arch: 'x86/x64', + binaryName: 'LT.exe', + }; + } + + // Linux + return { + downloadUrl: 'https://downloads.lambdatest.com/tunnel/v4/linux/64bit/LT_Linux.zip', + platform: 'Linux', + arch: arch === 'arm64' ? 'ARM64' : 'x64', + binaryName: 'LT', + }; +} + +export const testmuLocalBinaryResource: ResourceDefinition = { + name: 'testmu-local-binary', + uri: 'wdio://testmu/local-binary', + description: 'TestMu Tunnel binary download URL and daemon setup instructions for the current platform. MUST be read and followed before using tunnel: true in start_session with provider: testmu.', + handler: async () => { + const info = getLocalBinaryInfo(); + const username = process.env.TESTMU_USERNAME ?? ''; + const accessKey = process.env.TESTMU_ACCESS_KEY ?? ''; + + const content = { + requirement: 'MUST start the TestMu Tunnel daemon BEFORE calling start_session with tunnel: true. Without it, all navigation to local/internal URLs will fail.', + platform: info.platform, + arch: info.arch, + downloadUrl: info.downloadUrl, + setup: [ + `1. Download: curl -O ${info.downloadUrl}`, + `2. Unzip: unzip ${info.downloadUrl.split('/').pop()}`, + `3. Make executable (macOS/Linux): chmod +x ${info.binaryName}`, + `4. Start daemon: ./${info.binaryName} --user ${username} --key ${accessKey}`, + ], + commands: { + start: `./${info.binaryName} --user ${username} --key ${accessKey}`, + stop: `./${info.binaryName} --user ${username} --key ${accessKey} --stop`, + status: `./${info.binaryName} --status`, + }, + afterDaemonIsRunning: 'Call start_session with tunnel: true and provider: testmu to route TestMu traffic through the tunnel.', + }; + + return { + contents: [{ + uri: 'wdio://testmu/local-binary', + mimeType: 'application/json', + text: JSON.stringify(content, null, 2), + }], + }; + }, +}; diff --git a/src/server.ts b/src/server.ts index 6445851..9eb58ec 100644 --- a/src/server.ts +++ b/src/server.ts @@ -47,6 +47,7 @@ import { appStateResource, browserstackLocalBinaryResource, saucelabsLocalBinaryResource, + testmuLocalBinaryResource, capabilitiesResource, contextResource, contextsResource, @@ -173,6 +174,7 @@ function createServer(): McpServer { registerResource(browserstackLocalBinaryResource); registerResource(saucelabsLocalBinaryResource); + registerResource(testmuLocalBinaryResource); registerResource(capabilitiesResource); registerResource(elementsResource); registerResource(accessibilityResource); diff --git a/src/session/state.ts b/src/session/state.ts index d318d8b..83d87d9 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -4,7 +4,7 @@ export interface SessionMetadata { type: 'browser' | 'ios' | 'android'; capabilities: Record; isAttached: boolean; - provider?: 'local' | 'browserstack' | 'saucelabs'; + provider?: 'local' | 'browserstack' | 'saucelabs' | 'testmu'; region?: string; tunnelName?: string; tunnelHandle?: unknown; diff --git a/src/tools/cloud-provider.tool.ts b/src/tools/cloud-provider.tool.ts index 9cf2553..ff50339 100644 --- a/src/tools/cloud-provider.tool.ts +++ b/src/tools/cloud-provider.tool.ts @@ -90,6 +90,33 @@ const PROVIDER_CONFIGS: Record = { return { appRef: `storage:filename=${name}`, appName: name }; }, }, + testmu: { + name: 'TestMu', + apiBase: 'https://manual-api.lambdatest.com', + credsEnvNames: ['TESTMU_USERNAME', 'TESTMU_ACCESS_KEY'], + listPath: '/app/data', + supportsOrgWide: false, + parseListResponse: (raw) => { + // LambdaTest returns { data: [...], metaData: {...} } + if (raw === null || raw === undefined || typeof raw !== 'object') return []; + + const data = raw as { data?: Record[] }; + const apps = data.data ?? (Array.isArray(raw) ? raw as Record[] : []); + return apps.map((a) => ({ + name: (a.name ?? a.app_name ?? 'unknown') as string, + ref: a.app_id ? `lt://${a.app_id}` : `lt://${a.custom_id ?? 'unknown'}`, + uploadedAt: a.updated_at as string | undefined, + customId: a.custom_id as string | undefined, + })); + }, + uploadPath: '/app/upload/realDevice', + uploadField: 'appFile', + parseUploadResponse: (raw, fileName) => { + const data = raw as { app_id?: string; app_url?: string; name?: string }; + const ref = data.app_id ? `lt://${data.app_id}` : (data.app_url ?? 'unknown'); + return { appRef: ref, appName: data.name ?? fileName }; + }, + }, }; function getProviderConfig(provider: string, region?: string): { config: ProviderApiConfig; auth: string } | { error: string } { @@ -113,10 +140,10 @@ function getProviderConfig(provider: string, region?: string): { config: Provide export const listAppsToolDefinition: ToolDefinition = { name: 'list_apps', - description: 'List apps uploaded to a cloud provider (BrowserStack App Automate or Sauce Labs App Storage). Reads provider-specific credentials from environment.', + description: 'List apps uploaded to a cloud provider (BrowserStack App Automate, Sauce Labs App Storage, or TestMu Real Device Cloud). Reads provider-specific credentials from environment.', annotations: { title: 'List Cloud Provider Apps', readOnlyHint: true, idempotentHint: true }, inputSchema: { - provider: z.enum(['browserstack', 'saucelabs']).describe('Cloud provider'), + provider: z.enum(['browserstack', 'saucelabs', 'testmu']).describe('Cloud provider'), sortBy: z.enum(['app_name', 'uploaded_at']).optional().default('uploaded_at').describe('Sort order for results'), organizationWide: coerceBoolean.optional().default(false).describe('(BrowserStack only) List apps uploaded by all users in the organization. Defaults to false (own uploads only).'), limit: z.number().int().min(1).optional().default(20).describe('Maximum number of apps to return (only applies when organizationWide is true, default 20)'), @@ -125,7 +152,7 @@ export const listAppsToolDefinition: ToolDefinition = { }; type ListAppsArgs = { - provider: 'browserstack' | 'saucelabs'; + provider: 'browserstack' | 'saucelabs' | 'testmu'; sortBy?: 'app_name' | 'uploaded_at'; organizationWide?: boolean; limit?: number; @@ -146,17 +173,43 @@ export const listAppsTool: ToolCallback = async (args: ListAppsArgs) => { url = `${config.apiBase}/app-automate/recent_group_apps?limit=${limit}`; } - const res = await fetch(url, { - headers: { Authorization: `Basic ${auth}` }, - }); + // TestMu requires ?type= param — fetch both platforms and merge + let apps: NormalizedApp[] = []; + if (provider === 'testmu') { + const errors: string[] = []; + for (const platform of ['android', 'ios']) { + try { + const res = await fetch(`${url}?type=${platform}`, { + headers: { Authorization: `Basic ${auth}` }, + }); + if (res.ok) { + const raw = await res.json(); + apps.push(...config.parseListResponse(raw)); + } else { + const body = await res.text(); + errors.push(`${config.name} API error ${res.status} (${platform}): ${body}`); + } + } catch (e) { + errors.push(String(e)); + } + } + if (apps.length === 0 && errors.length > 0) { + const message = errors.length === 1 ? errors[0] : `All platform fetches failed:\n${errors.map(e => ` - ${e}`).join('\n')}`; + return { isError: true as const, content: [{ type: 'text' as const, text: `Error listing apps: ${message}` }] }; + } + } else { + const res = await fetch(url, { + headers: { Authorization: `Basic ${auth}` }, + }); - if (!res.ok) { - const body = await res.text(); - return { isError: true as const, content: [{ type: 'text' as const, text: `${config.name} API error ${res.status}: ${body}` }] }; - } + if (!res.ok) { + const body = await res.text(); + return { isError: true as const, content: [{ type: 'text' as const, text: `${config.name} API error ${res.status}: ${body}` }] }; + } - const raw = await res.json(); - let apps = config.parseListResponse(raw); + const raw = await res.json(); + apps = config.parseListResponse(raw); + } apps = sortBy === 'app_name' ? apps.sort((a, b) => a.name.localeCompare(b.name)) : apps.sort((a, b) => (b.uploadedAt ?? '').localeCompare(a.uploadedAt ?? '')); @@ -170,10 +223,10 @@ export const listAppsTool: ToolCallback = async (args: ListAppsArgs) => { export const uploadAppToolDefinition: ToolDefinition = { name: 'upload_app', - description: 'Upload a local .apk or .ipa to a cloud provider (BrowserStack App Automate or Sauce Labs App Storage). Returns the app URL for use in start_session.', + description: 'Upload a local .apk or .ipa to a cloud provider (BrowserStack, Sauce Labs, or TestMu). Returns the app URL for use in start_session.', annotations: { title: 'Upload App to Cloud Provider', destructiveHint: false }, inputSchema: { - provider: z.enum(['browserstack', 'saucelabs']).describe('Cloud provider'), + provider: z.enum(['browserstack', 'saucelabs', 'testmu']).describe('Cloud provider'), path: z.string().describe('Absolute path to the .apk or .ipa file'), customId: z.string().optional().describe('Optional custom ID for the app (used to reference it later)'), region: z.enum(['us-west-1', 'eu-central-1', 'apac-southeast-1']).optional().default('eu-central-1').describe('Sauce Labs region (default: eu-central-1)'), @@ -181,7 +234,7 @@ export const uploadAppToolDefinition: ToolDefinition = { }; type UploadAppArgs = { - provider: 'browserstack' | 'saucelabs'; + provider: 'browserstack' | 'saucelabs' | 'testmu'; path: string; customId?: string; region?: 'us-west-1' | 'eu-central-1' | 'apac-southeast-1'; diff --git a/src/tools/session.tool.ts b/src/tools/session.tool.ts index f07ca99..aca7910 100644 --- a/src/tools/session.tool.ts +++ b/src/tools/session.tool.ts @@ -19,13 +19,13 @@ export const startSessionToolDefinition: ToolDefinition = { description: 'Starts a browser or mobile automation session. Only one active session at a time — starting a new one closes the existing session first. Use platform "browser" with a browser name, or "ios"/"android" with deviceName. Set attach: true to connect to a running Chrome via CDP instead of launching a new browser.', annotations: { title: 'Start Session', destructiveHint: false }, inputSchema: { - provider: z.enum(['local', 'browserstack', 'saucelabs']).optional().default('local').describe('Session provider (default: local)'), + provider: z.enum(['local', 'browserstack', 'saucelabs', 'testmu']).optional().default('local').describe('Session provider (default: local)'), platform: platformEnum.describe('Session platform type'), browser: browserEnum.optional().describe('Browser to launch (required for browser platform)'), browserVersion: z.string().optional().describe('Browser version (cloud providers only, default: latest)'), os: z.string().optional().describe('Operating system (cloud providers only, e.g. "Windows", "OS X")'), osVersion: z.string().optional().describe('OS version (cloud providers only, e.g. "11", "Sequoia")'), - app: z.string().optional().describe('App URL (bs://...) for BrowserStack or storage:filename= for Sauce Labs mobile sessions'), + app: z.string().optional().describe('App URL (bs://... for BrowserStack, storage:filename= for Sauce Labs, lt://... for TestMu mobile sessions)'), reporting: z.object({ project: z.string().optional(), build: z.string().optional(), @@ -63,13 +63,14 @@ export const startSessionToolDefinition: ToolDefinition = { tunnelName: z.string().optional().describe('Tunnel identifier name. With tunnel: "external" this must match the running tunnel. With tunnel: true a unique name is auto-generated if not provided.'), browserstackLocal: z.union([z.literal('external'), coerceBoolean]).optional().describe('Deprecated: use "tunnel" instead. Enable BrowserStack Local tunnel routing.'), saucelabsLocal: z.union([z.literal('external'), coerceBoolean]).optional().describe('Deprecated: use "tunnel" instead. Enable Sauce Connect tunnel routing.'), + testmuLocal: z.union([z.literal('external'), coerceBoolean]).optional().describe('Deprecated: use "tunnel" instead. Enable TestMu Tunnel routing.'), navigationUrl: z.string().optional().describe('URL to navigate to after starting'), capabilities: z.record(z.string(), z.unknown()).optional().describe('Additional capabilities to merge'), }, }; type StartSessionArgs = { - provider?: 'local' | 'browserstack' | 'saucelabs'; + provider?: 'local' | 'browserstack' | 'saucelabs' | 'testmu'; platform: 'browser' | 'ios' | 'android'; browser?: 'chrome' | 'firefox' | 'edge' | 'safari'; browserVersion?: string; @@ -101,6 +102,7 @@ type StartSessionArgs = { tunnelName?: string; browserstackLocal?: boolean | 'external'; saucelabsLocal?: boolean | 'external'; + testmuLocal?: boolean | 'external'; navigationUrl?: string; capabilities?: Record; }; @@ -192,9 +194,9 @@ async function startBrowserSession(args: StartSessionArgs): Promise); - // Normalize tunnel flag — support legacy browserstackLocal/saucelabsLocal params + // Normalize tunnel flag — support legacy browserstackLocal/saucelabsLocal/testmuLocal params // MUST compute tunnelName BEFORE buildCapabilities so it is available in the capabilities - const effectiveTunnel = args.tunnel ?? args.browserstackLocal ?? args.saucelabsLocal ?? false; + const effectiveTunnel = args.tunnel ?? args.browserstackLocal ?? args.saucelabsLocal ?? args.testmuLocal ?? false; const tunnelEnabled = effectiveTunnel === true; const tunnelName = tunnelEnabled && !args.tunnelName ? `wdio-mcp-${Date.now()}` : args.tunnelName; @@ -281,9 +283,9 @@ async function startMobileSession(args: StartSessionArgs): Promise); - // Normalize tunnel flag — support legacy browserstackLocal/saucelabsLocal params + // Normalize tunnel flag — support legacy browserstackLocal/saucelabsLocal/testmuLocal params // MUST compute tunnelName BEFORE buildCapabilities so it is available in the capabilities - const effectiveTunnel = args.tunnel ?? args.browserstackLocal ?? args.saucelabsLocal ?? false; + const effectiveTunnel = args.tunnel ?? args.browserstackLocal ?? args.saucelabsLocal ?? args.testmuLocal ?? false; const tunnelEnabled = effectiveTunnel === true; const tunnelName = tunnelEnabled && !args.tunnelName ? `wdio-mcp-${Date.now()}` : args.tunnelName; diff --git a/src/types/lambdatest-node-tunnel.d.ts b/src/types/lambdatest-node-tunnel.d.ts new file mode 100644 index 0000000..9003933 --- /dev/null +++ b/src/types/lambdatest-node-tunnel.d.ts @@ -0,0 +1,17 @@ +declare module '@lambdatest/node-tunnel' { + interface Options { + user: string; + key: string; + tunnelName?: string; + logFile?: string; + [key: string]: string | boolean | undefined; + } + + class LambdaTunnel { + start(options: Partial): Promise; + stop(): Promise; + isRunning(): boolean; + } + + export default LambdaTunnel; +} diff --git a/tests/providers/registry.test.ts b/tests/providers/registry.test.ts index 38980f9..18b9584 100644 --- a/tests/providers/registry.test.ts +++ b/tests/providers/registry.test.ts @@ -3,6 +3,7 @@ import { getProvider } from '../../src/providers/registry'; import { LocalBrowserProvider } from '../../src/providers/local-browser.provider'; import { LocalAppiumProvider } from '../../src/providers/local-appium.provider'; import { BrowserStackProvider } from '../../src/providers/cloud/browserstack.provider'; +import { TestMuProvider } from '../../src/providers/cloud/testmu.provider'; describe('getProvider', () => { it('returns LocalBrowserProvider for local browser', () => { @@ -29,6 +30,18 @@ describe('getProvider', () => { expect(getProvider('browserstack', 'ios')).toBeInstanceOf(BrowserStackProvider); }); + it('returns TestMuProvider for testmu browser', () => { + expect(getProvider('testmu', 'browser')).toBeInstanceOf(TestMuProvider); + }); + + it('returns TestMuProvider for testmu android', () => { + expect(getProvider('testmu', 'android')).toBeInstanceOf(TestMuProvider); + }); + + it('returns TestMuProvider for testmu ios', () => { + expect(getProvider('testmu', 'ios')).toBeInstanceOf(TestMuProvider); + }); + it('defaults to local when provider is undefined', () => { expect(getProvider(undefined as unknown as string, 'browser')).toBeInstanceOf(LocalBrowserProvider); }); diff --git a/tests/providers/testmu.provider.test.ts b/tests/providers/testmu.provider.test.ts new file mode 100644 index 0000000..ec71b39 --- /dev/null +++ b/tests/providers/testmu.provider.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestMuProvider } from '../../src/providers/cloud/testmu.provider'; + +describe('TestMuProvider', () => { + let provider: TestMuProvider; + + beforeEach(() => { + provider = new TestMuProvider(); + vi.stubEnv('TESTMU_USERNAME', 'testuser'); + vi.stubEnv('TESTMU_ACCESS_KEY', 'testkey'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + describe('getConnectionConfig', () => { + it('returns hub.lambdatest.com for browser platform', () => { + const config = provider.getConnectionConfig({ platform: 'browser' }); + expect(config.hostname).toBe('hub.lambdatest.com'); + expect(config.protocol).toBe('https'); + expect(config.port).toBe(443); + expect(config.path).toBe('/wd/hub'); + }); + + it('returns mobile-hub.lambdatest.com for android platform', () => { + const config = provider.getConnectionConfig({ platform: 'android' }); + expect(config.hostname).toBe('mobile-hub.lambdatest.com'); + }); + + it('returns mobile-hub.lambdatest.com for ios platform', () => { + const config = provider.getConnectionConfig({ platform: 'ios' }); + expect(config.hostname).toBe('mobile-hub.lambdatest.com'); + }); + + it('reads credentials from environment variables', () => { + const config = provider.getConnectionConfig({}); + expect(config.user).toBe('testuser'); + expect(config.key).toBe('testkey'); + }); + }); + + describe('buildCapabilities — browser platform', () => { + it('sets browserName and lt:options for browser platform', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + expect(caps.browserName).toBe('chrome'); + expect(caps['lt:options']).toBeDefined(); + }); + + it('defaults browserVersion to latest', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'firefox' }); + expect(caps.browserVersion).toBe('latest'); + }); + + it('defaults platformName to Linux', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + expect(caps.platformName).toBe('Linux'); + }); + + it('passes reporting labels to lt:options', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'firefox', + reporting: { project: 'MyProject', build: 'build-1', session: 'login test' }, + }); + const lt = caps['lt:options'] as Record; + expect(lt.project).toBe('MyProject'); + expect(lt.build).toBe('build-1'); + expect(lt.name).toBe('login test'); + }); + + it('uses project as name when session is not provided', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'chrome', + reporting: { project: 'MyProject' }, + }); + const lt = caps['lt:options'] as Record; + expect(lt.name).toBe('MyProject'); + }); + + it('sets w3c: true in lt:options', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + const lt = caps['lt:options'] as Record; + expect(lt.w3c).toBe(true); + }); + + it('includes credentials in lt:options', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + const lt = caps['lt:options'] as Record; + expect(lt.username).toBe('testuser'); + expect(lt.accessKey).toBe('testkey'); + }); + + it('sets tunnel: true when tunnel is enabled', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'chrome', + tunnel: true, + tunnelName: 'my-tunnel', + }); + const lt = caps['lt:options'] as Record; + expect(lt.tunnel).toBe(true); + expect(lt.tunnelName).toBe('my-tunnel'); + }); + + it('merges user capabilities at top level', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'chrome', + capabilities: { 'goog:chromeOptions': { args: ['--custom-flag'] } }, + }); + expect((caps['goog:chromeOptions'] as any)?.args).toContain('--custom-flag'); + }); + }); + + describe('buildCapabilities — mobile platform', () => { + it('sets platformName and appium:app for android native app', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + platformVersion: '13', + app: 'lt://abc123', + }); + expect(caps.platformName).toBe('android'); + expect(caps['appium:app']).toBe('lt://abc123'); + }); + + it('sets isRealMobile: true for native app mode', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'lt://abc', + }); + const lt = caps['lt:options'] as Record; + expect(lt.isRealMobile).toBe(true); + expect(lt.appiumVersion).toBe('latest'); + }); + + it('sets isRealMobile: false for mobile browser mode', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + platformVersion: '13', + browser: 'chrome', + }); + const lt = caps['lt:options'] as Record; + expect(lt.isRealMobile).toBe(false); + expect(lt.appiumVersion).toBe('2.11.0'); + }); + + it('defaults autoGrantPermissions and autoAcceptAlerts to true', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'lt://abc', + }); + expect(caps['appium:autoGrantPermissions']).toBe(true); + expect(caps['appium:autoAcceptAlerts']).toBe(true); + }); + + it('clears autoAcceptAlerts when autoDismissAlerts is set', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'lt://abc', + autoDismissAlerts: true, + }); + expect(caps['appium:autoDismissAlerts']).toBe(true); + expect(caps['appium:autoAcceptAlerts']).toBeUndefined(); + }); + + it('defaults newCommandTimeout to 300', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'lt://abc', + }); + expect(caps['appium:newCommandTimeout']).toBe(300); + }); + + it('defaults automationName for iOS', () => { + const caps = provider.buildCapabilities({ + platform: 'ios', + deviceName: 'iPhone 15', + app: 'lt://xyz', + }); + expect(caps['appium:automationName']).toBe('XCUITest'); + }); + + it('defaults automationName for Android', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'lt://abc', + }); + expect(caps['appium:automationName']).toBe('UiAutomator2'); + }); + }); + + describe('getSessionType', () => { + it('returns browser for browser platform', () => { + expect(provider.getSessionType({ platform: 'browser' })).toBe('browser'); + }); + + it('returns ios for ios platform', () => { + expect(provider.getSessionType({ platform: 'ios' })).toBe('ios'); + }); + + it('returns android for android platform', () => { + expect(provider.getSessionType({ platform: 'android' })).toBe('android'); + }); + }); + + describe('shouldAutoDetach', () => { + it('always returns false', () => { + expect(provider.shouldAutoDetach({})).toBe(false); + }); + }); + + describe('onSessionClose', () => { + it('sends PATCH with status_ind passed to the LambdaTest API', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); + + await provider.onSessionClose('session-123', 'browser', { status: 'passed' }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.lambdatest.com/automation/api/v1/sessions/session-123', + expect.objectContaining({ + method: 'PATCH', + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ status_ind: 'passed' }), + }), + ); + }); + + it('sends status_ind failed when status is failed', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); + + await provider.onSessionClose('session-456', 'android', { status: 'failed' }); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ status_ind: 'failed' }), + }), + ); + }); + + it('does not throw when fetch fails', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error')); + + await expect( + provider.onSessionClose('session-789', 'browser', { status: 'passed' }), + ).resolves.toBeUndefined(); + }); + + it('skips API call when credentials are missing', async () => { + vi.stubEnv('TESTMU_USERNAME', ''); + vi.stubEnv('TESTMU_ACCESS_KEY', ''); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); + + await provider.onSessionClose('session-123', 'browser', { status: 'passed' }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/tools/cloud-provider.tool.test.ts b/tests/tools/cloud-provider.tool.test.ts index b5af822..72b5fff 100644 --- a/tests/tools/cloud-provider.tool.test.ts +++ b/tests/tools/cloud-provider.tool.test.ts @@ -162,6 +162,134 @@ describe('upload_app tool (BrowserStack)', () => { }); }); +// ─── LambdaTest/TestMu tests ──────────────────────────────────────────────── + +describe('list_apps tool (TestMu)', () => { + beforeEach(() => { + vi.stubEnv('TESTMU_USERNAME', 'testuser'); + vi.stubEnv('TESTMU_ACCESS_KEY', 'testkey'); + vi.spyOn(global, 'fetch'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('calls manual-api.lambdatest.com /app/data?type=android endpoint', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => [{ app_name: 'TestApp.apk', app_id: 'lt-app-1', updated_at: '2026-01-01T00:00:00Z' }], + } as Response); + + await callList({ provider: 'testmu' }); + + const [url, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://manual-api.lambdatest.com/app/data?type=android'); + expect((options?.headers as Record)?.Authorization).toMatch(/^Basic /); + }); + + it('fetches both android and ios platforms', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => [{ app_name: 'TestApp.apk', app_id: 'lt-app-1', updated_at: '2026-01-01T00:00:00Z' }], + } as Response); + + await callList({ provider: 'testmu' }); + + const urls = vi.mocked(fetch).mock.calls.map(c => c[0] as string); + expect(urls).toHaveLength(2); + expect(urls[0]).toContain('?type=android'); + expect(urls[1]).toContain('?type=ios'); + }); + + it('returns formatted app list with lt:// format', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => [{ app_name: 'MyApp.apk', app_id: 'lt-app-1', updated_at: '2026-03-01T10:00:00.000Z', custom_id: 'MyApp_GB' }], + } as Response); + + const result = await callList({ provider: 'testmu' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('MyApp.apk'); + expect(result.content[0].text).toContain('lt://lt-app-1'); + }); + + it('handles non-array response gracefully (both platforms)', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => null, + } as Response); + + const result = await callList({ provider: 'testmu' }); + // Both android+ios fetches return null, parsed as empty arrays → "No apps found." + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toBe('No apps found.'); + }); + + it('returns isError true when credentials are missing', async () => { + vi.unstubAllEnvs(); + const result = await callList({ provider: 'testmu' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('TESTMU_USERNAME'); + }); + + it('returns isError true when fetch fails', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('network error')); + const result = await callList({ provider: 'testmu' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('network error'); + }); +}); + +describe('upload_app tool (TestMu)', () => { + beforeEach(() => { + vi.stubEnv('TESTMU_USERNAME', 'testuser'); + vi.stubEnv('TESTMU_ACCESS_KEY', 'testkey'); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('mock-file-content')); + vi.spyOn(global, 'fetch'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('returns isError true when credentials are missing', async () => { + vi.unstubAllEnvs(); + const result = await callUpload({ provider: 'testmu', path: '/some/app.apk' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('TESTMU_USERNAME'); + }); + + it('calls upload endpoint and returns lt:// url', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ app_id: 'lt-newapp456', name: 'myapp.apk' }), + } as Response); + + const result = await callUpload({ provider: 'testmu', path: '/local/myapp.apk' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('lt://lt-newapp456'); + + const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://manual-api.lambdatest.com/app/upload/realDevice'); + }); + + it('returns isError true when API returns error', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + } as Response); + + const result = await callUpload({ provider: 'testmu', path: '/local/myapp.apk' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('401'); + }); +}); + // ─── Sauce Labs tests ───────────────────────────────────────────────────────── describe('list_apps tool (Sauce Labs)', () => { From c76e4da9d7840f4b4c24eb9e2a38ad623781060f Mon Sep 17 00:00:00 2001 From: Vince Graics Date: Fri, 5 Jun 2026 10:29:21 +0200 Subject: [PATCH 2/5] docs(provider): Update README documentation with new Providers --- CLAUDE.md | 32 ++++++--- README.md | 198 +++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 183 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d4ca10d..69d1c27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,10 +5,13 @@ Context for Claude Code when working with this repository. ## Commands ```bash -npm run bundle # Build: clean + tsup + make executable + create .tgz -npm test # Run unit tests (vitest + happy-dom) -npm run dev # Development server (tsx, no build) -npm start # Run built server from lib/server.js +npm run bundle # Build: clean + tsup + make executable + create .tgz +npm run lint # ESLint + TypeScript type-checking (tsc --noEmit) +npm test # Run unit tests (vitest + happy-dom) +npm run dev # Development server (tsx --watch) — picks up code changes automatically; rebundle only needed for npm package changes +npm run dev:http # Dev server with HTTP transport (for browser-based MCP clients) +npm start # Run built server from lib/server.js +npm run start:http # Built server with HTTP transport (for browser-based MCP clients) ``` ## Architecture @@ -22,7 +25,10 @@ src/ │ ├── local-browser.provider.ts # Chrome/Firefox/Edge/Safari │ ├── local-appium.provider.ts # iOS/Android via Appium │ └── cloud/ -│ └── browserstack.provider.ts # BrowserStack (browser + App Automate) +│ ├── browserstack.provider.ts # BrowserStack (browser + App Automate) +│ ├── saucelabs.provider.ts # Sauce Labs (browser + App Storage) +│ └── testmu.provider.ts # TestMu / LambdaTest (browser + mobile) +├── trace/ # Playwright-compatible trace recording (recorder.ts, tool-mapping.ts, zip-writer.ts) ├── tools/ # One file per MCP tool (see Tool Pattern below) ├── resources/ # One file per MCP resource (see Recording below) ├── recording/ # step-recorder.ts (withRecording HOF) + code-generator.ts @@ -45,7 +51,7 @@ export interface SessionMetadata { type: 'browser' | 'ios' | 'android'; capabilities: Record; isAttached: boolean; - provider?: 'local' | 'browserstack'; // set at session start; used by lifecycle to call provider hooks + provider?: 'local' | 'browserstack' | 'saucelabs' | 'testmu'; // set at session start; used by lifecycle to call provider hooks tunnelHandle?: unknown; // opaque handle returned by provider.startTunnel(), passed back to onSessionClose() } ``` @@ -139,6 +145,10 @@ MCP resources expose live session data — all at fixed URIs discoverable via Li ## Gotchas +### Dev Reload vs Reconnect + +`npm run dev` runs `tsx --watch` — code changes reload in-process. Only tool/resource **schema changes** (Zod definitions, new tools, parameter additions) require an MCP client reconnect to re-advertise capabilities. No need to rebundle or restart the dev server for implementation-only changes. + ### Console Output All console methods redirect to stderr via `console.error`. Chrome writes to stdout which corrupts MCP stdio protocol. @@ -207,11 +217,17 @@ catch (e) { |----------|-------------| | `BROWSERSTACK_USERNAME` | BrowserStack sessions + tools | | `BROWSERSTACK_ACCESS_KEY` | BrowserStack sessions + tools | +| `SAUCE_USERNAME` | Sauce Labs sessions + App Storage tools | +| `SAUCE_ACCESS_KEY` | Sauce Labs sessions + App Storage tools | +| `TESTMU_USERNAME` | TestMu / LambdaTest sessions + tools | +| `TESTMU_ACCESS_KEY` | TestMu / LambdaTest sessions + tools | ## Planned Improvements See `docs/architecture/` for proposals: -- `session-configuration-proposal.md` — Cloud provider pattern (SauceLabs etc.) — BrowserStack already implemented; `providers/registry.ts` + `providers/cloud/` is the extension point +- `session-configuration-proposal.md` — Cloud provider pattern — BrowserStack, SauceLabs, and TestMu implemented; `providers/registry.ts` + `providers/cloud/` is the extension point for new providers - `multi-session-proposal.md` — Parallel sessions for sub-agent coordination -- `interaction-sequencing-proposal.md` — Sequencing model for tool interactions \ No newline at end of file +- `interaction-sequencing-proposal.md` — Sequencing model for tool interactions +- `trace-recording-and-replay.md` — Playwright-compatible trace recording (implemented in `src/trace/`) +- `trace-extraction-proposal.md` — Trace data extraction and analysis \ No newline at end of file diff --git a/README.md b/README.md index 6ceed70..37e220c 100644 --- a/README.md +++ b/README.md @@ -263,31 +263,30 @@ appium # Server runs at http://127.0.0.1:4723 by default ``` -## BrowserStack +## Cloud Providers -Run browser and mobile app tests on [BrowserStack](https://www.browserstack.com/) real devices and browsers without any -local setup. +Run browser and mobile app tests on cloud real devices and browsers without any local setup. Currently supports +[BrowserStack](https://www.browserstack.com/), [Sauce Labs](https://saucelabs.com/), and +[LambdaTest](https://www.lambdatest.com/). ### Prerequisites -Set your credentials as environment variables: +Set your provider credentials as environment variables or in your MCP client config: + +
+BrowserStack ```bash export BROWSERSTACK_USERNAME=your_username export BROWSERSTACK_ACCESS_KEY=your_access_key ``` -Or add them to your MCP client config: - ```json { "mcpServers": { "wdio-mcp": { "command": "npx", - "args": [ - "-y", - "@wdio/mcp@latest" - ], + "args": ["-y", "@wdio/mcp@latest"], "env": { "BROWSERSTACK_USERNAME": "your_username", "BROWSERSTACK_ACCESS_KEY": "your_access_key" @@ -297,11 +296,72 @@ Or add them to your MCP client config: } ``` +
+ +
+Sauce Labs + +```bash +export SAUCE_USERNAME=your_username +export SAUCE_ACCESS_KEY=your_access_key +``` + +```json +{ + "mcpServers": { + "wdio-mcp": { + "command": "npx", + "args": ["-y", "@wdio/mcp@latest"], + "env": { + "SAUCE_USERNAME": "your_username", + "SAUCE_ACCESS_KEY": "your_access_key" + } + } + } +} +``` + +| `SAUCE_USERNAME` | Sauce Labs username (required) | +| `SAUCE_ACCESS_KEY` | Sauce Labs access key (required) | + +The data center is set per-session via the `region` parameter in `start_session` (defaults to `eu-central-1`). + +
+ +
+LambdaTest (TestMu) + +```bash +export TESTMU_USERNAME=your_username +export TESTMU_ACCESS_KEY=your_access_key +``` + +```json +{ + "mcpServers": { + "wdio-mcp": { + "command": "npx", + "args": ["-y", "@wdio/mcp@latest"], + "env": { + "TESTMU_USERNAME": "your_username", + "TESTMU_ACCESS_KEY": "your_access_key" + } + } + } +} +``` + +| `TESTMU_USERNAME` | LambdaTest username (required) | +| `TESTMU_ACCESS_KEY` | LambdaTest access key (required) | + +
+ ### Browser Sessions Run a browser on a specific OS/version combination: ```javascript +// BrowserStack start_session({ provider: 'browserstack', platform: 'browser', @@ -315,56 +375,112 @@ start_session({ session: 'Login flow' } }) + +// Sauce Labs +start_session({ + provider: 'saucelabs', + platform: 'browser', + browser: 'chrome', + region: 'eu-central-1', // default: eu-central-1 + reporting: { + build: 'v1.2.0', + session: 'Login flow' + } +}) + +// LambdaTest +start_session({ + provider: 'testmu', + platform: 'browser', + browser: 'chrome', + os: 'Linux', // default: Linux + osVersion: '11', + reporting: { + project: 'My Project', + build: 'v1.2.0', + session: 'Login flow' + } +}) ``` ### Mobile App Sessions -Test on BrowserStack real devices. First upload your app (or use an existing `bs://` URL): +Test on cloud real devices. First upload your app (or use an existing app URL): ```javascript -// Upload a local .apk or .ipa (returns a bs:// URL) -upload_app({path: '/path/to/app.apk'}) +// BrowserStack: returns bs:// URL +upload_app({ provider: 'browserstack', path: '/path/to/app.apk' }) + +// Sauce Labs: returns storage:filename= reference +upload_app({ provider: 'saucelabs', path: '/path/to/app.apk' }) -// Start a session with the returned URL +// LambdaTest: returns lt:// URL +upload_app({ provider: 'testmu', path: '/path/to/app.apk' }) + +// Start a session start_session({ provider: 'browserstack', - platform: 'android', // android | ios - app: 'bs://abc123...', // bs:// URL or custom_id from upload + platform: 'android', + app: 'bs://abc123...', deviceName: 'Samsung Galaxy S23', - platformVersion: '13.0', - reporting: { - project: 'My Project', - build: 'v1.2.0', - session: 'Checkout flow' - } + platformVersion: '13.0' +}) + +// Sauce Labs native app +start_session({ + provider: 'saucelabs', + platform: 'android', + app: 'storage:filename=myapp.apk', + deviceName: 'Samsung.*', + platformVersion: '16' +}) + +// LambdaTest native app +start_session({ + provider: 'testmu', + platform: 'android', + app: 'lt://abc123...', + deviceName: 'Pixel 7', + platformVersion: '13' }) ``` Use `list_apps` to see previously uploaded apps: ```javascript -list_apps() // own uploads, sorted by date -list_apps({sortBy: 'app_name'}) -list_apps({organizationWide: true}) // all uploads in your org +list_apps({ provider: 'browserstack' }) +list_apps({ provider: 'saucelabs', sortBy: 'app_name' }) +list_apps({ provider: 'testmu' }) +list_apps({ provider: 'browserstack', organizationWide: true }) ``` -### BrowserStack Local +### Local Tunnel -To test against URLs that are only accessible on your local machine or internal network, enable the BrowserStack Local -tunnel: +To test against URLs that are only accessible on your local machine or internal network, enable a local tunnel: ```javascript +// Auto-start tunnel (provider manages lifecycle) start_session({ - provider: 'browserstack', + provider: 'saucelabs', platform: 'browser', - browser: 'chrome', - browserstackLocal: true // starts tunnel automatically + tunnel: true // auto-starts tunnel before session +}) + +// Use an already-running tunnel +start_session({ + provider: 'saucelabs', + platform: 'browser', + tunnel: 'external' // uses existing tunnel }) ``` +The `tunnel` parameter replaces the deprecated `browserstackLocal`, `saucelabsLocal`, and `testmuLocal` params. Set it to `true` to auto-start the tunnel (stopped automatically after the session), or `'external'` to use a tunnel already running on your machine. + +> **Note**: Sauce Connect and TestMu Tunnel require their respective binaries. The `wdio://saucelabs/local-binary` and `wdio://testmu/local-binary` resources provide platform-specific download URLs and setup instructions. + ### Reporting Labels -All session types support `reporting` labels that appear in the BrowserStack Automate dashboard: +All session types support `reporting` labels that appear in the provider dashboard: | Field | Description | |---------------------|-----------------------------------------| @@ -372,12 +488,14 @@ All session types support `reporting` labels that appear in the BrowserStack Aut | `reporting.build` | Tag sessions with a build/version label | | `reporting.session` | Name for the individual test session | -### BrowserStack Tools +### Cloud Provider Tools + +| Tool | Description | +|--------------|-------------------------------------------------------------------------------| +| `upload_app` | Upload a local `.apk` or `.ipa` to the provider; returns an app URL/reference | +| `list_apps` | List apps previously uploaded to the provider's app storage | -| Tool | Description | -|--------------|------------------------------------------------------------------------| -| `upload_app` | Upload a local `.apk` or `.ipa` to BrowserStack; returns a `bs://` URL | -| `list_apps` | List apps previously uploaded to your BrowserStack account | +Both tools require a `provider` parameter (`'browserstack'`, `'saucelabs'`, or `'testmu'`). ## Features @@ -487,6 +605,8 @@ All session types support `reporting` labels that appear in the BrowserStack Aut | `wdio://session/current/geolocation` | Device geolocation | | `wdio://session/current/capabilities` | Resolved WebDriver capabilities for the active session | | `wdio://browserstack/local-binary` | BrowserStack Local binary download URL and start command | +| `wdio://saucelabs/local-binary` | Sauce Connect binary download URL and start command | +| `wdio://testmu/local-binary` | TestMu Tunnel binary download URL and start command | ## Usage Examples @@ -794,8 +914,8 @@ MCP resources — no extra tool calls needed: - `wdio://session/{sessionId}/code` — generated JS for any past session by ID The generated script reconstructs the full session — including capabilities, navigation, clicks, and inputs — as a -standalone `import { remote } from 'webdriverio'` file. For BrowserStack sessions it includes the full try/catch/finally -with automatic session result marking. +standalone `import { remote } from 'webdriverio'` file. For cloud provider sessions it includes the full try/catch/finally +with automatic session result marking via the provider's REST API. ### Trace Recording From 0c994a8e9c6fba3cece3d1235ec98150a0dd6172 Mon Sep 17 00:00:00 2001 From: Vince Graics Date: Sat, 6 Jun 2026 11:18:04 +0200 Subject: [PATCH 3/5] fix(testmu): Address code review findings for appiumVersion, mobile status, credential deduplication --- src/providers/cloud/saucelabs.provider.ts | 2 +- src/providers/cloud/testmu.provider.ts | 63 +++++++++++++---------- src/recording/code-generator.ts | 19 ++++--- tests/providers/testmu.provider.test.ts | 35 ++++++------- 4 files changed, 65 insertions(+), 54 deletions(-) diff --git a/src/providers/cloud/saucelabs.provider.ts b/src/providers/cloud/saucelabs.provider.ts index 1d27583..28a687b 100644 --- a/src/providers/cloud/saucelabs.provider.ts +++ b/src/providers/cloud/saucelabs.provider.ts @@ -2,6 +2,7 @@ import { basicAuth } from '../../utils/auth'; import type { ConnectionConfig, SessionProvider, SessionResult } from '../types'; import type { Browser as WdioBrowser } from 'webdriverio'; import type { SauceLabsOptions } from 'saucelabs'; +import SauceLabs from 'saucelabs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -110,7 +111,6 @@ export class SauceLabsProvider implements SessionProvider { const logFile = join(tmpdir(), 'sauce-connect.log'); console.error(`[SauceLabs] Starting tunnel "${tunnelName}" (region: ${region})`); try { - const { default: SauceLabs } = await import('saucelabs'); const api = new SauceLabs({ user: process.env.SAUCE_USERNAME ?? '', key: process.env.SAUCE_ACCESS_KEY ?? '', region } satisfies SauceLabsOptions); return api.startSauceConnect({ tunnelName, diff --git a/src/providers/cloud/testmu.provider.ts b/src/providers/cloud/testmu.provider.ts index cb05c9e..b77d4ff 100644 --- a/src/providers/cloud/testmu.provider.ts +++ b/src/providers/cloud/testmu.provider.ts @@ -1,6 +1,7 @@ import { basicAuth } from '../../utils/auth'; import type { ConnectionConfig, SessionProvider, SessionResult } from '../types'; import type { Browser as WdioBrowser } from 'webdriverio'; +import LambdaTunnel from '@lambdatest/node-tunnel'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -28,8 +29,6 @@ export class TestMuProvider implements SessionProvider { const ltOptions: Record = { w3c: true }; - if (process.env.TESTMU_USERNAME) ltOptions.username = process.env.TESTMU_USERNAME; - if (process.env.TESTMU_ACCESS_KEY) ltOptions.accessKey = process.env.TESTMU_ACCESS_KEY; if (reporting?.project) ltOptions.project = reporting.project; if (reporting?.build) ltOptions.build = reporting.build; if (reporting?.session) ltOptions.name = reporting.session; @@ -55,7 +54,6 @@ export class TestMuProvider implements SessionProvider { // Mobile browser/emulator mode (e.g. Chrome on Android emulator) if (mobileBrowser) { - ltOptions.appiumVersion = '2.11.0'; ltOptions.isRealMobile = false; if (options.deviceOrientation) ltOptions.deviceOrientation = options.deviceOrientation; @@ -109,7 +107,6 @@ export class TestMuProvider implements SessionProvider { const logFile = join(tmpdir(), 'testmu-tunnel.log'); console.error(`[TestMu] Starting tunnel "${tunnelName}"`); try { - const { default: LambdaTunnel } = await import('@lambdatest/node-tunnel'); const tunnel = new LambdaTunnel(); await tunnel.start({ user: process.env.TESTMU_USERNAME ?? '', @@ -131,37 +128,51 @@ export class TestMuProvider implements SessionProvider { async onSessionClose( sessionId: string, - _sessionType: 'browser' | 'ios' | 'android', + sessionType: 'browser' | 'ios' | 'android', result: SessionResult, _tunnelHandle?: unknown, - _browser?: WdioBrowser, + browser?: WdioBrowser, _region?: string, ): Promise { const user = process.env.TESTMU_USERNAME; const key = process.env.TESTMU_ACCESS_KEY; - if (user && key) { + if (!user || !key) return; + + const status = result.status === 'passed' ? 'passed' : 'failed'; + + // Mobile sessions use browser.execute(); web sessions use REST API + if (sessionType !== 'browser') { try { - const auth = basicAuth(user, key); - const body = { status_ind: result.status === 'passed' ? 'passed' : 'failed' }; - const apiUrl = `https://api.lambdatest.com/automation/api/v1/sessions/${sessionId}`; - console.error(`[TestMu] Setting session status for ${sessionId}: ${body.status_ind}`); - const res = await fetch(apiUrl, { - method: 'PATCH', - headers: { - Authorization: `Basic ${auth}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - if (res.ok) { - console.error('[TestMu] Session status set successfully via REST API'); - } else { - const resBody = await res.text(); - console.error(`[TestMu] Failed to set session status: HTTP ${res.status} — ${resBody}`); - } + console.error(`[TestMu] Setting mobile session status for ${sessionId}: ${status}`); + await browser?.execute('lambda-status=' + status); + console.error('[TestMu] Mobile session status set successfully via execute'); } catch (e) { - console.error('[TestMu] Failed to set session status via REST API:', e); + console.error('[TestMu] Failed to set mobile session status via execute:', e); + } + return; + } + + try { + const auth = basicAuth(user, key); + const body = { status_ind: status }; + const apiUrl = `https://api.lambdatest.com/automation/api/v1/sessions/${sessionId}`; + console.error(`[TestMu] Setting session status for ${sessionId}: ${body.status_ind}`); + const res = await fetch(apiUrl, { + method: 'PATCH', + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (res.ok) { + console.error('[TestMu] Session status set successfully via REST API'); + } else { + const resBody = await res.text(); + console.error(`[TestMu] Failed to set session status: HTTP ${res.status} — ${resBody}`); } + } catch (e) { + console.error('[TestMu] Failed to set session status via REST API:', e); } } diff --git a/src/recording/code-generator.ts b/src/recording/code-generator.ts index 9751520..92ac846 100644 --- a/src/recording/code-generator.ts +++ b/src/recording/code-generator.ts @@ -290,14 +290,17 @@ export function generateCode(history: SessionHistory): string { const ltSteps = steps.replace(/const browser = await remote\(/g, 'browser = await remote('); const preamble = 'let browser;\nlet ltStatus = \'passed\';'; const catchBlock = '} catch (e) {\n ltStatus = \'failed\';\n throw e;'; - const statusUpdate = [ - " const ltAuth = Buffer.from(`${process.env.TESTMU_USERNAME}:${process.env.TESTMU_ACCESS_KEY}`).toString('base64');", - " await fetch('https://api.lambdatest.com/automation/api/v1/sessions/' + browser.sessionId, {", - " method: 'PATCH',", - " headers: { Authorization: 'Basic ' + ltAuth, 'Content-Type': 'application/json' },", - ' body: JSON.stringify({ status_ind: ltStatus })', - ' });', - ].join('\n'); + const isMobile = history.type !== 'browser'; + const statusUpdate = isMobile + ? " await browser.execute('lambda-status=' + ltStatus);" + : [ + " const ltAuth = Buffer.from(`${process.env.TESTMU_USERNAME}:${process.env.TESTMU_ACCESS_KEY}`).toString('base64');", + " await fetch('https://api.lambdatest.com/automation/api/v1/sessions/' + browser.sessionId, {", + " method: 'PATCH',", + " headers: { Authorization: 'Basic ' + ltAuth, 'Content-Type': 'application/json' },", + ' body: JSON.stringify({ status_ind: ltStatus })', + ' });', + ].join('\n'); const finallyLines = [ ' if (browser) {', statusUpdate, diff --git a/tests/providers/testmu.provider.test.ts b/tests/providers/testmu.provider.test.ts index ec71b39..0c32bf0 100644 --- a/tests/providers/testmu.provider.test.ts +++ b/tests/providers/testmu.provider.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Browser } from 'webdriverio'; import { TestMuProvider } from '../../src/providers/cloud/testmu.provider'; describe('TestMuProvider', () => { @@ -86,13 +87,6 @@ describe('TestMuProvider', () => { expect(lt.w3c).toBe(true); }); - it('includes credentials in lt:options', () => { - const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); - const lt = caps['lt:options'] as Record; - expect(lt.username).toBe('testuser'); - expect(lt.accessKey).toBe('testkey'); - }); - it('sets tunnel: true when tunnel is enabled', () => { const caps = provider.buildCapabilities({ platform: 'browser', @@ -147,7 +141,6 @@ describe('TestMuProvider', () => { }); const lt = caps['lt:options'] as Record; expect(lt.isRealMobile).toBe(false); - expect(lt.appiumVersion).toBe('2.11.0'); }); it('defaults autoGrantPermissions and autoAcceptAlerts to true', () => { @@ -220,7 +213,7 @@ describe('TestMuProvider', () => { }); describe('onSessionClose', () => { - it('sends PATCH with status_ind passed to the LambdaTest API', async () => { + it('sends REST PATCH for browser sessions', async () => { const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); await provider.onSessionClose('session-123', 'browser', { status: 'passed' }); @@ -235,20 +228,24 @@ describe('TestMuProvider', () => { ); }); - it('sends status_ind failed when status is failed', async () => { - const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); + it('uses browser.execute for mobile sessions', async () => { + const executeSpy = vi.fn().mockResolvedValue(undefined); + const mockBrowser = { execute: executeSpy } as unknown as Browser; - await provider.onSessionClose('session-456', 'android', { status: 'failed' }); + await provider.onSessionClose('session-456', 'android', { status: 'failed' }, undefined, mockBrowser); - expect(fetchSpy).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: JSON.stringify({ status_ind: 'failed' }), - }), - ); + expect(executeSpy).toHaveBeenCalledWith('lambda-status=failed'); + }); + + it('does not throw when browser.execute fails for mobile', async () => { + const mockBrowser = { execute: vi.fn().mockRejectedValue(new Error('session gone')) } as unknown as Browser; + + await expect( + provider.onSessionClose('session-789', 'ios', { status: 'passed' }, undefined, mockBrowser), + ).resolves.toBeUndefined(); }); - it('does not throw when fetch fails', async () => { + it('does not throw when REST PATCH fails for browser', async () => { vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error')); await expect( From f3d940496da52b243848a88f867fbe46a60a542c Mon Sep 17 00:00:00 2001 From: Vince Graics Date: Sun, 7 Jun 2026 21:47:47 +0200 Subject: [PATCH 4/5] fix(os): Compute platformName from os + osVersion for browser sessions in case of Testmu and SauceLabs providers --- README.md | 10 +- src/providers/cloud/browserstack.provider.ts | 4 +- src/providers/cloud/saucelabs.provider.ts | 8 +- src/providers/cloud/testmu.provider.ts | 8 +- src/tools/session.tool.ts | 6 +- tests/providers/saucelabs.provider.test.ts | 301 +++++++++++++++++++ tests/providers/testmu.provider.test.ts | 26 ++ 7 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 tests/providers/saucelabs.provider.test.ts diff --git a/README.md b/README.md index 37e220c..5f47ae4 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,8 @@ start_session({ provider: 'saucelabs', platform: 'browser', browser: 'chrome', + os: 'Windows', // combined with osVersion → platformName + osVersion: '11', // e.g. "11", "Sequoia" (optional) region: 'eu-central-1', // default: eu-central-1 reporting: { build: 'v1.2.0', @@ -393,8 +395,8 @@ start_session({ provider: 'testmu', platform: 'browser', browser: 'chrome', - os: 'Linux', // default: Linux - osVersion: '11', + os: 'Windows', // combined with osVersion → platformName + osVersion: '11', // e.g. "11", "Sequoia" (optional) reporting: { project: 'My Project', build: 'v1.2.0', @@ -403,6 +405,10 @@ start_session({ }) ``` +> **Provider-specific `os` / `osVersion` behavior:** +> - **BrowserStack** — `os` and `osVersion` map to separate `bstack:options.os` / `bstack:options.osVersion` fields. +> - **Sauce Labs / LambdaTest** — `os` and `osVersion` are combined into the W3C `platformName` capability (e.g., `os: 'Windows'` + `osVersion: '11'` → `platformName: 'Windows 11'`). Both providers use `platformName` values like `"Windows 11"`, `"MacOS Sequoia"`, or `"Linux"`. + ### Mobile App Sessions Test on cloud real devices. First upload your app (or use an existing app URL): diff --git a/src/providers/cloud/browserstack.provider.ts b/src/providers/cloud/browserstack.provider.ts index 8b663e3..6fa8192 100644 --- a/src/providers/cloud/browserstack.provider.ts +++ b/src/providers/cloud/browserstack.provider.ts @@ -42,9 +42,9 @@ export class BrowserStackProvider implements SessionProvider { if (reporting?.session) bstackOptions.sessionName = reporting.session; return { + ...userCapabilities, browserName: (options.browser as string | undefined) ?? 'chrome', 'bstack:options': bstackOptions, - ...userCapabilities, }; } @@ -66,6 +66,7 @@ export class BrowserStackProvider implements SessionProvider { const autoDismissAlerts = options.autoDismissAlerts as boolean | undefined; return { + ...userCapabilities, platformName: platform, 'appium:app': options.app, 'appium:autoGrantPermissions': (options.autoGrantPermissions as boolean | undefined) ?? true, @@ -73,7 +74,6 @@ export class BrowserStackProvider implements SessionProvider { 'appium:autoDismissAlerts': autoDismissAlerts, 'appium:newCommandTimeout': (options.newCommandTimeout as number | undefined) ?? 300, 'bstack:options': bstackOptions, - ...userCapabilities, }; } diff --git a/src/providers/cloud/saucelabs.provider.ts b/src/providers/cloud/saucelabs.provider.ts index 28a687b..6c0e7b5 100644 --- a/src/providers/cloud/saucelabs.provider.ts +++ b/src/providers/cloud/saucelabs.provider.ts @@ -46,11 +46,11 @@ export class SauceLabsProvider implements SessionProvider { if (platform === 'browser') { return { + ...userCapabilities, browserName: (options.browser as string | undefined) ?? 'chrome', browserVersion: (options.browserVersion as string | undefined) ?? 'latest', - platformName: (options.os as string | undefined) ?? 'Linux', + platformName: options.os ? [options.os as string, options.osVersion as string | undefined].filter(Boolean).join(' ') : 'Linux', 'sauce:options': sauceOptions, - ...userCapabilities, }; } @@ -71,7 +71,7 @@ export class SauceLabsProvider implements SessionProvider { 'appium:newCommandTimeout': (options.newCommandTimeout as number | undefined) ?? 300, 'sauce:options': sauceOptions, }; - return { ...caps, ...userCapabilities }; + return { ...userCapabilities, ...caps }; } // Mobile native app mode @@ -81,6 +81,7 @@ export class SauceLabsProvider implements SessionProvider { const autoDismissAlerts = options.autoDismissAlerts as boolean | undefined; return { + ...userCapabilities, platformName: platform, 'appium:app': options.app, 'appium:deviceName': options.deviceName, @@ -91,7 +92,6 @@ export class SauceLabsProvider implements SessionProvider { 'appium:autoDismissAlerts': autoDismissAlerts, 'appium:newCommandTimeout': (options.newCommandTimeout as number | undefined) ?? 300, 'sauce:options': sauceOptions, - ...userCapabilities, }; } diff --git a/src/providers/cloud/testmu.provider.ts b/src/providers/cloud/testmu.provider.ts index b77d4ff..38a39e9 100644 --- a/src/providers/cloud/testmu.provider.ts +++ b/src/providers/cloud/testmu.provider.ts @@ -41,11 +41,11 @@ export class TestMuProvider implements SessionProvider { if (platform === 'browser') { return { + ...userCapabilities, browserName: (options.browser as string | undefined) ?? 'chrome', browserVersion: (options.browserVersion as string | undefined) ?? 'latest', - platformName: (options.os as string | undefined) ?? 'Linux', + platformName: options.os ? [options.os as string, options.osVersion as string | undefined].filter(Boolean).join(' ') : 'Linux', 'lt:options': ltOptions, - ...userCapabilities, }; } @@ -66,7 +66,7 @@ export class TestMuProvider implements SessionProvider { 'appium:newCommandTimeout': (options.newCommandTimeout as number | undefined) ?? 300, 'lt:options': ltOptions, }; - return { ...caps, ...userCapabilities }; + return { ...userCapabilities, ...caps }; } // Mobile native app mode @@ -89,7 +89,7 @@ export class TestMuProvider implements SessionProvider { 'lt:options': ltOptions, }; - return { ...caps, ...userCapabilities }; + return { ...userCapabilities, ...caps }; } getSessionType(options: Record): 'browser' | 'ios' | 'android' { diff --git a/src/tools/session.tool.ts b/src/tools/session.tool.ts index aca7910..de6acac 100644 --- a/src/tools/session.tool.ts +++ b/src/tools/session.tool.ts @@ -23,8 +23,8 @@ export const startSessionToolDefinition: ToolDefinition = { platform: platformEnum.describe('Session platform type'), browser: browserEnum.optional().describe('Browser to launch (required for browser platform)'), browserVersion: z.string().optional().describe('Browser version (cloud providers only, default: latest)'), - os: z.string().optional().describe('Operating system (cloud providers only, e.g. "Windows", "OS X")'), - osVersion: z.string().optional().describe('OS version (cloud providers only, e.g. "11", "Sequoia")'), + os: z.string().optional().describe('Operating system for cloud provider browser sessions (e.g. "Windows", "Mac", "macOS", "Linux"). BrowserStack: sets bstack:options.os separately. TestMu/Sauce Labs: combined with osVersion into W3C platformName. Browser platform only.'), + osVersion: z.string().optional().describe('OS version for cloud provider browser sessions (e.g. "11", "15", "Monterey"). BrowserStack: sets bstack:options.osVersion separately. TestMu/Sauce Labs: combined with os into W3C platformName. Browser platform only.'), app: z.string().optional().describe('App URL (bs://... for BrowserStack, storage:filename= for Sauce Labs, lt://... for TestMu mobile sessions)'), reporting: z.object({ project: z.string().optional(), @@ -35,7 +35,7 @@ export const startSessionToolDefinition: ToolDefinition = { windowWidth: z.number().min(400).max(3840).optional().default(1920).describe('Browser window width'), windowHeight: z.number().min(400).max(2160).optional().default(1080).describe('Browser window height'), deviceName: z.string().optional().describe('Mobile device/emulator/simulator name (required for ios/android)'), - platformVersion: z.string().optional().describe('OS version (e.g., "17.0", "14")'), + platformVersion: z.string().optional().describe('OS version for mobile sessions (e.g., "17.0", "14"). Mobile (ios/android) only.'), appPath: z.string().optional().describe('Path to app file (.app/.apk/.ipa)'), automationName: automationEnum.optional().describe('Automation driver'), autoGrantPermissions: coerceBoolean.optional().describe('Auto-grant app permissions (default: true)'), diff --git a/tests/providers/saucelabs.provider.test.ts b/tests/providers/saucelabs.provider.test.ts new file mode 100644 index 0000000..9d0449c --- /dev/null +++ b/tests/providers/saucelabs.provider.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SauceLabsProvider } from '../../src/providers/cloud/saucelabs.provider'; + +describe('SauceLabsProvider', () => { + let provider: SauceLabsProvider; + + beforeEach(() => { + provider = new SauceLabsProvider(); + vi.stubEnv('SAUCE_USERNAME', 'testuser'); + vi.stubEnv('SAUCE_ACCESS_KEY', 'testkey'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + describe('getConnectionConfig', () => { + it('returns region-specific hostname for browser platform', () => { + const config = provider.getConnectionConfig({ platform: 'browser' }); + expect(config.hostname).toBe('ondemand.eu-central-1.saucelabs.com'); + expect(config.protocol).toBe('https'); + expect(config.port).toBe(443); + expect(config.path).toBe('/wd/hub'); + }); + + it('uses specified region in hostname', () => { + const config = provider.getConnectionConfig({ platform: 'browser', region: 'us-west-1' }); + expect(config.hostname).toBe('ondemand.us-west-1.saucelabs.com'); + }); + + it('returns same hostname pattern for mobile platforms', () => { + const config = provider.getConnectionConfig({ platform: 'android' }); + expect(config.hostname).toBe('ondemand.eu-central-1.saucelabs.com'); + }); + + it('reads credentials from environment variables', () => { + const config = provider.getConnectionConfig({}); + expect(config.user).toBe('testuser'); + expect(config.key).toBe('testkey'); + }); + }); + + describe('buildCapabilities — browser platform', () => { + it('sets browserName and sauce:options for browser platform', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + expect(caps.browserName).toBe('chrome'); + expect(caps['sauce:options']).toBeDefined(); + }); + + it('defaults browserVersion to latest', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'firefox' }); + expect(caps.browserVersion).toBe('latest'); + }); + + it('defaults platformName to Linux', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + expect(caps.platformName).toBe('Linux'); + }); + + it('combines os and osVersion into platformName', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', os: 'Windows', osVersion: '11' }); + expect(caps.platformName).toBe('Windows 11'); + }); + + it('combines os and osVersion for Mac numbered name', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', os: 'Mac', osVersion: '15' }); + expect(caps.platformName).toBe('Mac 15'); + }); + + it('uses os alone as platformName when osVersion is not provided', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', os: 'Linux' }); + expect(caps.platformName).toBe('Linux'); + }); + + it('uses os alone as platformName for ChromiumOS', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', os: 'ChromiumOS' }); + expect(caps.platformName).toBe('ChromiumOS'); + }); + + it('passes reporting labels to sauce:options', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'firefox', + reporting: { project: 'MyProject', build: 'build-1', session: 'login test' }, + }); + const sauce = caps['sauce:options'] as Record; + expect(sauce.build).toBe('build-1'); + expect(sauce.name).toBe('login test'); + }); + + it('uses project as name when session is not provided', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'chrome', + reporting: { project: 'MyProject' }, + }); + const sauce = caps['sauce:options'] as Record; + expect(sauce.name).toBe('MyProject'); + }); + + it('includes region in sauce:options', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + const sauce = caps['sauce:options'] as Record; + expect(sauce.region).toBeDefined(); + }); + + it('sets tunnelName in sauce:options when tunnel is enabled', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'chrome', + tunnel: true, + tunnelName: 'my-tunnel', + }); + const sauce = caps['sauce:options'] as Record; + expect(sauce.tunnelName).toBe('my-tunnel'); + }); + + it('supports legacy saucelabsLocal alias', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'chrome', + saucelabsLocal: true, + tunnelName: 'legacy-tunnel', + }); + const sauce = caps['sauce:options'] as Record; + expect(sauce.tunnelName).toBe('legacy-tunnel'); + }); + + it('merges user capabilities at top level', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'chrome', + capabilities: { 'goog:chromeOptions': { args: ['--custom-flag'] } }, + }); + expect((caps['goog:chromeOptions'] as any)?.args).toContain('--custom-flag'); + }); + + it('ignores platformName from user capabilities (os/osVersion are the API)', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'chrome', + os: 'Windows', + osVersion: '11', + capabilities: { platformName: 'Mac 15' }, + }); + expect(caps.platformName).toBe('Windows 11'); + }); + }); + + describe('buildCapabilities — mobile platform', () => { + it('sets platformName and appium:app for android native app', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + platformVersion: '13', + app: 'storage:filename=myapp.apk', + }); + expect(caps.platformName).toBe('android'); + expect(caps['appium:app']).toBe('storage:filename=myapp.apk'); + }); + + it('sets appiumVersion to latest for native app mode', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'storage:filename=app.apk', + }); + const sauce = caps['sauce:options'] as Record; + expect(sauce.appiumVersion).toBe('latest'); + }); + + it('sets appiumVersion to 2.11.0 for mobile browser mode', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + platformVersion: '13', + browser: 'chrome', + }); + const sauce = caps['sauce:options'] as Record; + expect(sauce.appiumVersion).toBe('2.11.0'); + }); + + it('defaults autoGrantPermissions and autoAcceptAlerts to true', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'storage:filename=app.apk', + }); + expect(caps['appium:autoGrantPermissions']).toBe(true); + expect(caps['appium:autoAcceptAlerts']).toBe(true); + }); + + it('clears autoAcceptAlerts when autoDismissAlerts is set', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'storage:filename=app.apk', + autoDismissAlerts: true, + }); + expect(caps['appium:autoDismissAlerts']).toBe(true); + expect(caps['appium:autoAcceptAlerts']).toBeUndefined(); + }); + + it('defaults newCommandTimeout to 300', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'storage:filename=app.apk', + }); + expect(caps['appium:newCommandTimeout']).toBe(300); + }); + + it('defaults automationName for iOS', () => { + const caps = provider.buildCapabilities({ + platform: 'ios', + deviceName: 'iPhone 15', + app: 'storage:filename=app.ipa', + }); + expect(caps['appium:automationName']).toBe('XCUITest'); + }); + + it('defaults automationName for Android', () => { + const caps = provider.buildCapabilities({ + platform: 'android', + deviceName: 'Pixel 7', + app: 'storage:filename=app.apk', + }); + expect(caps['appium:automationName']).toBe('UiAutomator2'); + }); + }); + + describe('getSessionType', () => { + it('returns browser for browser platform', () => { + expect(provider.getSessionType({ platform: 'browser' })).toBe('browser'); + }); + + it('returns ios for ios platform', () => { + expect(provider.getSessionType({ platform: 'ios' })).toBe('ios'); + }); + + it('returns android for android platform', () => { + expect(provider.getSessionType({ platform: 'android' })).toBe('android'); + }); + }); + + describe('shouldAutoDetach', () => { + it('always returns false', () => { + expect(provider.shouldAutoDetach({})).toBe(false); + }); + }); + + describe('onSessionClose', () => { + it('sends REST PUT for browser sessions', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); + + await provider.onSessionClose('session-123', 'browser', { status: 'passed' }); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/rest/v1/testuser/jobs/session-123'), + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ passed: true }), + }), + ); + }); + + it('sends REST PUT for mobile sessions', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); + + await provider.onSessionClose('session-456', 'android', { status: 'failed' }); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/rest/v1/testuser/jobs/session-456'), + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ passed: false }), + }), + ); + }); + + it('does not throw when REST PUT fails', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error')); + + await expect( + provider.onSessionClose('session-789', 'browser', { status: 'passed' }), + ).resolves.toBeUndefined(); + }); + + it('skips API call when credentials are missing', async () => { + vi.stubEnv('SAUCE_USERNAME', ''); + vi.stubEnv('SAUCE_ACCESS_KEY', ''); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response); + + await provider.onSessionClose('session-123', 'browser', { status: 'passed' }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/providers/testmu.provider.test.ts b/tests/providers/testmu.provider.test.ts index 0c32bf0..4bf8128 100644 --- a/tests/providers/testmu.provider.test.ts +++ b/tests/providers/testmu.provider.test.ts @@ -59,6 +59,21 @@ describe('TestMuProvider', () => { expect(caps.platformName).toBe('Linux'); }); + it('combines os and osVersion into platformName', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', os: 'Windows', osVersion: '11' }); + expect(caps.platformName).toBe('Windows 11'); + }); + + it('combines os and osVersion for macOS release name', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', os: 'macOS', osVersion: 'Monterey' }); + expect(caps.platformName).toBe('macOS Monterey'); + }); + + it('uses os alone as platformName when osVersion is not provided', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', os: 'Linux' }); + expect(caps.platformName).toBe('Linux'); + }); + it('passes reporting labels to lt:options', () => { const caps = provider.buildCapabilities({ platform: 'browser', @@ -107,6 +122,17 @@ describe('TestMuProvider', () => { }); expect((caps['goog:chromeOptions'] as any)?.args).toContain('--custom-flag'); }); + + it('ignores platformName from user capabilities (os/osVersion are the API)', () => { + const caps = provider.buildCapabilities({ + platform: 'browser', + browser: 'chrome', + os: 'Windows', + osVersion: '11', + capabilities: { platformName: 'macOS Monterey' }, + }); + expect(caps.platformName).toBe('Windows 11'); + }); }); describe('buildCapabilities — mobile platform', () => { From e20b0412fd8ed59bb253e5537dc92619288d9569 Mon Sep 17 00:00:00 2001 From: Vince Graics Date: Sun, 7 Jun 2026 21:59:01 +0200 Subject: [PATCH 5/5] fix(mobile): Allow mobile browser sessions to start without appPath --- README.md | 38 +++++++++++++++++++++- src/providers/cloud/saucelabs.provider.ts | 1 - src/tools/session.tool.ts | 14 ++++++-- tests/providers/saucelabs.provider.test.ts | 11 ------- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5f47ae4..ba6466b 100644 --- a/README.md +++ b/README.md @@ -382,7 +382,7 @@ start_session({ platform: 'browser', browser: 'chrome', os: 'Windows', // combined with osVersion → platformName - osVersion: '11', // e.g. "11", "Sequoia" (optional) + osVersion: '11', // e.g. "11", "15" (numbered Mac naming) region: 'eu-central-1', // default: eu-central-1 reporting: { build: 'v1.2.0', @@ -451,6 +451,42 @@ start_session({ }) ``` +### Mobile Browser Sessions + +Run a browser on a cloud mobile emulator/simulator without uploading an app: + +```javascript +// BrowserStack — Chrome on Android emulator +start_session({ + provider: 'browserstack', + platform: 'android', + browser: 'chrome', + deviceName: 'Google Pixel 7', + platformVersion: '13' +}) + +// Sauce Labs — Safari on iOS simulator +start_session({ + provider: 'saucelabs', + platform: 'ios', + browser: 'safari', + deviceName: 'iPhone 15', + platformVersion: '18', + region: 'eu-central-1' +}) + +// LambdaTest — Chrome on Android emulator +start_session({ + provider: 'testmu', + platform: 'android', + browser: 'chrome', + deviceName: 'Pixel 7', + platformVersion: '13' +}) +``` + +> **Note:** Mobile browser sessions do not require `app`, `appPath`, or `noReset`. The provider launches a browser on the emulator directly. + Use `list_apps` to see previously uploaded apps: ```javascript diff --git a/src/providers/cloud/saucelabs.provider.ts b/src/providers/cloud/saucelabs.provider.ts index 6c0e7b5..19c9279 100644 --- a/src/providers/cloud/saucelabs.provider.ts +++ b/src/providers/cloud/saucelabs.provider.ts @@ -59,7 +59,6 @@ export class SauceLabsProvider implements SessionProvider { // Mobile browser/emulator mode (e.g. Chrome on Android emulator) if (mobileBrowser) { - sauceOptions.appiumVersion = '2.11.0'; if (options.deviceOrientation) sauceOptions.deviceOrientation = options.deviceOrientation; const caps: Record = { diff --git a/src/tools/session.tool.ts b/src/tools/session.tool.ts index de6acac..3c8be4c 100644 --- a/src/tools/session.tool.ts +++ b/src/tools/session.tool.ts @@ -271,7 +271,10 @@ async function startBrowserSession(args: StartSessionArgs): Promise { const { platform, appPath, app, deviceName, noReset } = args; - if (!appPath && !app && noReset !== true) { + // Mobile browser/emulator mode (e.g. Chrome on Android emulator) — no app required + const isMobileBrowser = args.browser !== undefined; + + if (!isMobileBrowser && !appPath && !app && noReset !== true) { return { content: [{ type: 'text', @@ -325,7 +328,12 @@ async function startMobileSession(args: StartSessionArgs): Promise { expect(sauce.appiumVersion).toBe('latest'); }); - it('sets appiumVersion to 2.11.0 for mobile browser mode', () => { - const caps = provider.buildCapabilities({ - platform: 'android', - deviceName: 'Pixel 7', - platformVersion: '13', - browser: 'chrome', - }); - const sauce = caps['sauce:options'] as Record; - expect(sauce.appiumVersion).toBe('2.11.0'); - }); - it('defaults autoGrantPermissions and autoAcceptAlerts to true', () => { const caps = provider.buildCapabilities({ platform: 'android',