Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ src/
│ ├── browserstack.provider.ts # BrowserStack (browser + App Automate)
│ ├── saucelabs.provider.ts # Sauce Labs (browser + App Storage)
│ ├── testmu.provider.ts # TestMu / LambdaTest (browser + mobile)
│ └── testingbot.provider.ts # TestingBot (browser + mobile + Storage)
│ ├── testingbot.provider.ts # TestingBot (browser + mobile + Storage)
│ └── digitalai.provider.ts # Digital.ai Testing (browser + mobile; accessKey cap, deviceQuery)
├── 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)
Expand Down Expand Up @@ -149,9 +150,10 @@ MCP resources expose live session data — all at fixed URIs discoverable via Li
| `src/providers/types.ts` | `SessionProvider` interface — `startTunnel()`, `onSessionClose()` lifecycle hooks |
| `src/providers/cloud/browserstack.provider.ts` | BrowserStack provider — tunnel lifecycle + session result marking via `onSessionClose()` |
| `src/providers/cloud/testingbot.provider.ts` | TestingBot provider — `tb:options` caps, single hub, form-encoded `test[success]` result marking, JAR tunnel via `testingbot-tunnel-launcher` |
| `src/providers/cloud/digitalai.provider.ts` | Digital.ai provider — `digitalai:accessKey` (web) / `digitalai:options.accessKey` (mobile) caps, `<DIGITALAI_CLOUD_URL>/wd/hub`, mobile `deviceQuery`, `cloud:<id>` app refs; REST API via Bearer accessKey |
| `src/tools/session.tool.ts` | `start_session` (browser + mobile), `close_session` |
| `src/tools/get-elements.tool.ts` | `get_elements` — all elements with filtering + pagination |
| `src/tools/cloud-provider.tool.ts` | `list_apps`, `upload_app` — generalized across BrowserStack / Sauce Labs / TestMu (registered in `server.ts`) |
| `src/tools/cloud-provider.tool.ts` | `list_apps`, `upload_app` — generalized across BrowserStack / Sauce Labs / TestMu / TestingBot and Digital.ai (Digital.ai uses Bearer auth; registered in `server.ts`) |
| `src/resources/` | All MCP resource definitions (one per URI) |
| `src/scripts/get-interactable-browser-elements.ts` | Browser-context element detection |
| `src/locators/` | Mobile element detection + locator generation |
Expand Down Expand Up @@ -240,12 +242,14 @@ catch (e) {
| `TESTMU_ACCESS_KEY` | TestMu / LambdaTest sessions + tools |
| `TESTINGBOT_KEY` | TestingBot sessions + tools |
| `TESTINGBOT_SECRET` | TestingBot sessions + tools |
| `DIGITALAI_CLOUD_URL` | Digital.ai sessions + tools (cloud host, e.g. `https://cloud.example.com`) |
| `DIGITALAI_ACCESS_KEY` | Digital.ai sessions + tools |

## Planned Improvements

See `docs/architecture/` for proposals:

- `session-configuration-proposal.md` — Cloud provider pattern — BrowserStack, SauceLabs, TestMu, and TestingBot implemented; `providers/registry.ts` + `providers/cloud/` is the extension point for new providers
- `session-configuration-proposal.md` — Cloud provider pattern — BrowserStack, SauceLabs, TestMu, TestingBot, and Digital.ai 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
- `trace-recording-and-replay.md` — Playwright-compatible trace recording (implemented in `src/trace/`)
Expand Down
70 changes: 69 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,8 @@ appium

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/),
[LambdaTest](https://www.lambdatest.com/), and [TestingBot](https://testingbot.com/).
[LambdaTest](https://www.lambdatest.com/), [TestingBot](https://testingbot.com/), and
[Digital.ai Testing](https://digital.ai/products/continuous-testing/).

### Prerequisites

Expand Down Expand Up @@ -384,6 +385,49 @@ export TESTINGBOT_SECRET=your_secret

</details>

<details>
<summary>Digital.ai Testing</summary>

```bash
export DIGITALAI_CLOUD_URL=https://your-cloud.example.com
export DIGITALAI_ACCESS_KEY=your_access_key
```

```json
{
"mcpServers": {
"wdio-mcp": {
"command": "npx",
"args": ["-y", "@wdio/mcp@latest"],
"env": {
"DIGITALAI_CLOUD_URL": "https://your-cloud.example.com",
"DIGITALAI_ACCESS_KEY": "your_access_key"
}
}
}
}
```

| `DIGITALAI_CLOUD_URL` | Digital.ai cloud host, e.g. `https://your-cloud.example.com` (required) |
| `DIGITALAI_ACCESS_KEY` | Digital.ai access key (required) |

The access key is sent via the `digitalai:options` capability (mobile) or the flat `digitalai:accessKey` capability (web).

**Report pass/fail status:** WebdriverIO defaults to the BiDi protocol, over which Digital.ai's cloud cannot observe command failures — so reports default to "Passed". To make the cloud reflect actual pass/fail, opt in to classic WebDriver per session:

```js
start_session({
provider: 'digitalai', platform: 'browser', browser: 'chrome', os: 'Windows 10',
capabilities: { 'wdio:enforceWebDriverClassic': true }
})
```

(Pure client-side assertion failures still report as "Passed" — only failures that reach the cloud as WebDriver command errors are detected.)

**Mobile (Appium):** configure your Digital.ai project for **Appium-server execution** and pick its default Appium version via the project's **"Manage default Appium server version"** setting — the version is chosen at the project level (and tracks the versions your cloud supports), so this MCP does not pin one. See [Appium Server Test Execution](https://docs.digital.ai/continuous-testing/docs/te/test-execution-home/mobile-android-and-ios/appium/appium-server-test-execution).

</details>

### Browser Sessions

Run a browser on a specific OS/version combination:
Expand Down Expand Up @@ -444,6 +488,18 @@ start_session({
session: 'Login flow'
}
})

// Digital.ai
start_session({
provider: 'digitalai',
platform: 'browser',
browser: 'chrome',
os: 'Windows', // combined with osVersion → platformName (optional)
osVersion: '11',
reporting: {
session: 'Login flow' // → digitalai:options.testName
}
})
```

> **Provider-specific `os` / `osVersion` behavior:**
Expand All @@ -467,6 +523,9 @@ upload_app({ provider: 'testmu', path: '/path/to/app.apk' })
// TestingBot: returns tb:// URL
upload_app({ provider: 'testingbot', path: '/path/to/app.apk' })

// Digital.ai: returns cloud:<package-or-bundle> reference
upload_app({ provider: 'digitalai', path: '/path/to/app.apk' })

// Start a session
start_session({
provider: 'browserstack',
Expand Down Expand Up @@ -502,6 +561,15 @@ start_session({
deviceName: 'Pixel 7',
platformVersion: '13'
})

// Digital.ai native app — devices are selected via a deviceQuery
start_session({
provider: 'digitalai',
platform: 'android',
app: 'cloud:com.example.app',
deviceQuery: "@os='android' and @version='14' and @name='.*Pixel.*'"
// or omit deviceQuery and pass deviceName / platformVersion to build one
})
```

### Mobile Browser Sessions
Expand Down
156 changes: 156 additions & 0 deletions src/providers/cloud/digitalai.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { ConnectionConfig, SessionProvider, SessionResult } from '../types';
import type { Browser as WdioBrowser } from 'webdriverio';

/**
* Normalize a Digital.ai cloud URL (DIGITALAI_CLOUD_URL) down to a bare hostname.
* Accepts "https://cloud.example.com", "cloud.example.com", or a value that
* already includes a trailing "/wd/hub".
*/
export function resolveCloudHost(raw?: string): string | undefined {
if (!raw) return undefined;
return raw
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/wd\/hub\/?$/i, '')
.replace(/\/+$/, '');
}

/** Map start_session browser names to the values the Digital.ai Selenium grid expects. */
const DIGITALAI_BROWSER_NAMES: Record<string, string> = {
edge: 'MicrosoftEdge',
};

export class DigitalAiProvider implements SessionProvider {
name = 'digitalai';

getConnectionConfig(_options: Record<string, unknown>): ConnectionConfig {
const hostname = resolveCloudHost(process.env.DIGITALAI_CLOUD_URL);
if (!hostname) {
throw new Error('Missing DIGITALAI_CLOUD_URL environment variable (your Digital.ai Testing cloud host, e.g. "https://cloud.example.com").');
}
// Digital.ai authenticates via the accessKey capability.
return {
protocol: 'https',
hostname,
port: 443,
path: '/wd/hub',
};
}

buildCapabilities(options: Record<string, unknown>): Record<string, unknown> {
const platform = options.platform as string;
const userCapabilities = (options.capabilities as Record<string, unknown> | undefined) ?? {};
const reporting = options.reporting as { project?: string; build?: string; session?: string } | undefined;

const accessKey = process.env.DIGITALAI_ACCESS_KEY;
if (!accessKey) {
throw new Error('Missing DIGITALAI_ACCESS_KEY environment variable.');
}

const testName = reporting?.session ?? reporting?.project;

if (platform === 'browser') {
// Selenium grid: uses FLAT digitalai:* capabilities. The flat digitalai:accessKey +
// digitalai:testName are what activate the reporter (the cloud returns a
// digitalai:reportUrl); nesting them in digitalai:options authenticates but produces
// no report. Target OS via digitalai:osName (NOT platformName).
// Browser names: chrome | firefox | MicrosoftEdge | safari | opera.
const browser = (options.browser as string | undefined) ?? 'chrome';
const osName = options.os
? [options.os as string, options.osVersion as string | undefined].filter(Boolean).join(' ')
: undefined;

const caps: Record<string, unknown> = {
...userCapabilities,
browserName: DIGITALAI_BROWSER_NAMES[browser] ?? browser,
'digitalai:accessKey': accessKey,
};
if (testName) caps['digitalai:testName'] = testName;
if (osName) caps['digitalai:osName'] = osName;
if (options.browserVersion) caps.browserVersion = options.browserVersion as string;
return caps;
}

// Mobile (ios / android) — Digital.ai caps go in the nested digitalai:options object.
// The Appium server / version is selected at the Digital.ai project level (configure the
// project for Appium-server execution), not pinned here, so it tracks whatever versions
// the cloud supports. Devices via a deviceQuery.
const digitalaiOptions: Record<string, unknown> = { accessKey };
if (testName) digitalaiOptions.testName = testName;
const deviceQuery = (options.deviceQuery as string | undefined) ?? buildDeviceQuery(platform, options);
if (deviceQuery) digitalaiOptions.deviceQuery = deviceQuery;

const caps: Record<string, unknown> = {
platformName: platform,
'appium:automationName': (options.automationName as string | undefined) ?? (platform === 'ios' ? 'XCUITest' : 'UiAutomator2'),
'appium:newCommandTimeout': (options.newCommandTimeout as number | undefined) ?? 300,
'digitalai:options': digitalaiOptions,
};

// Native app — Digital.ai app references look like "cloud:<package-or-bundle>".
if (options.app) caps['appium:app'] = options.app;

return { ...userCapabilities, ...caps };
}

getSessionType(options: Record<string, unknown>): 'browser' | 'ios' | 'android' {
const platform = options.platform as string;
if (platform === 'browser') return 'browser';
return platform as 'ios' | 'android';
}

shouldAutoDetach(_options: Record<string, unknown>): boolean {
return false;
}

/** Surface the live report link that Digital.ai returns as a session capability. */
getStartNote(browser: WdioBrowser): string {
const url = (browser.capabilities as Record<string, unknown> | undefined)?.['digitalai:reportUrl'];
return typeof url === 'string' && url ? `\nReport: ${url}` : '';
}

/**
* Mark the test passed/failed on the cloud via the seetest:client.setReportStatus
* automation command (works for both Selenium and Appium sessions). Without this the
* cloud defaults every report to "Passed". Status values: Passed | Failed | Skipped.
*
* Note: like the cloud's own pass/fail detection, this command flows over the classic
* WebDriver protocol — pair it with `wdio:enforceWebDriverClassic: true` when you need
* the status to land reliably (WebdriverIO's default BiDi transport can bypass it).
*/
async onSessionClose(
_sessionId: string,
_sessionType: 'browser' | 'ios' | 'android',
result: SessionResult,
_tunnelHandle?: unknown,
browser?: WdioBrowser,
): Promise<void> {
if (!browser) return;
const passed = result.status === 'passed';
const status = passed ? 'Passed' : 'Failed';
const message = passed
? 'Test passed'
: `Test failed${result.reason ? `: ${result.reason}` : ''}`;
try {
await browser.execute('seetest:client.setReportStatus', status, message);
} catch (e) {
console.error('[Digital.ai] Failed to set report status:', e);
}
}
}

/**
* Build a Digital.ai deviceQuery from the standard start_session params when an
* explicit deviceQuery is not supplied. e.g. @os='android' and @version='14' and @name='.*Pixel.*'
*/
function buildDeviceQuery(platform: string, options: Record<string, unknown>): string | undefined {
// Values are wrapped in single quotes in the query, so escape any single quotes they contain.
const esc = (v: unknown): string => String(v).replace(/'/g, "\\'");
const clauses: string[] = [`@os='${platform}'`];
if (options.platformVersion) clauses.push(`@version='${esc(options.platformVersion)}'`);
if (options.deviceName) clauses.push(`@name='${esc(options.deviceName)}'`);
// Only os was inferred — not enough to target a device on its own.
return clauses.length > 1 ? clauses.join(' and ') : undefined;
}

export const digitalAiProvider = new DigitalAiProvider();
2 changes: 2 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { browserStackProvider } from './cloud/browserstack.provider';
import { sauceLabsProvider } from './cloud/saucelabs.provider';
import { testMuProvider } from './cloud/testmu.provider';
import { testingBotProvider } from './cloud/testingbot.provider';
import { digitalAiProvider } from './cloud/digitalai.provider';

const providers = new Map<string, SessionProvider>([
['browserstack', browserStackProvider],
['saucelabs', sauceLabsProvider],
['testmu', testMuProvider],
['testingbot', testingBotProvider],
['digitalai', digitalAiProvider],
]);

function getDefaultProvider(platform: string): SessionProvider {
Expand Down
2 changes: 2 additions & 0 deletions src/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface SessionProvider {
buildCapabilities(options: Record<string, unknown>): Record<string, unknown>;
getSessionType(options: Record<string, unknown>): 'browser' | 'ios' | 'android';
shouldAutoDetach(options: Record<string, unknown>): boolean;
/** Optional provider-specific note appended to the start_session output (e.g. a report URL). */
getStartNote?(browser: WdioBrowser): string;
startTunnel?(options: Record<string, unknown>): Promise<unknown>;
stopTunnel?(tunnelHandle?: unknown): Promise<void>;
onSessionClose?(sessionId: string, sessionType: 'browser' | 'ios' | 'android', result: SessionResult, tunnelHandle?: unknown, browser?: WdioBrowser, region?: string): Promise<void>;
Expand Down
Loading
Loading