diff --git a/CLAUDE.md b/CLAUDE.md index 4c7ece0..89d2840 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) @@ -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, `/wd/hub`, mobile `deviceQuery`, `cloud:` 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 | @@ -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/`) diff --git a/README.md b/README.md index 788483d..e6a94f6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -384,6 +385,49 @@ export TESTINGBOT_SECRET=your_secret +
+Digital.ai Testing + +```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). + +
+ ### Browser Sessions Run a browser on a specific OS/version combination: @@ -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:** @@ -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: reference +upload_app({ provider: 'digitalai', path: '/path/to/app.apk' }) + // Start a session start_session({ provider: 'browserstack', @@ -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 diff --git a/src/providers/cloud/digitalai.provider.ts b/src/providers/cloud/digitalai.provider.ts new file mode 100644 index 0000000..a4161fa --- /dev/null +++ b/src/providers/cloud/digitalai.provider.ts @@ -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 = { + edge: 'MicrosoftEdge', +}; + +export class DigitalAiProvider implements SessionProvider { + name = 'digitalai'; + + getConnectionConfig(_options: Record): 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): Record { + const platform = options.platform as string; + const userCapabilities = (options.capabilities as Record | 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 = { + ...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 = { accessKey }; + if (testName) digitalaiOptions.testName = testName; + const deviceQuery = (options.deviceQuery as string | undefined) ?? buildDeviceQuery(platform, options); + if (deviceQuery) digitalaiOptions.deviceQuery = deviceQuery; + + const caps: Record = { + 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:". + if (options.app) caps['appium:app'] = options.app; + + return { ...userCapabilities, ...caps }; + } + + 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; + } + + /** Surface the live report link that Digital.ai returns as a session capability. */ + getStartNote(browser: WdioBrowser): string { + const url = (browser.capabilities as Record | 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 { + 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 | 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(); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e8d7c4d..5a7d03d 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -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([ ['browserstack', browserStackProvider], ['saucelabs', sauceLabsProvider], ['testmu', testMuProvider], ['testingbot', testingBotProvider], + ['digitalai', digitalAiProvider], ]); function getDefaultProvider(platform: string): SessionProvider { diff --git a/src/providers/types.ts b/src/providers/types.ts index 362cd15..3225168 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -21,6 +21,8 @@ export interface SessionProvider { buildCapabilities(options: Record): Record; getSessionType(options: Record): 'browser' | 'ios' | 'android'; shouldAutoDetach(options: Record): boolean; + /** Optional provider-specific note appended to the start_session output (e.g. a report URL). */ + getStartNote?(browser: WdioBrowser): string; startTunnel?(options: Record): Promise; stopTunnel?(tunnelHandle?: unknown): Promise; onSessionClose?(sessionId: string, sessionType: 'browser' | 'ios' | 'android', result: SessionResult, tunnelHandle?: unknown, browser?: WdioBrowser, region?: string): Promise; diff --git a/src/recording/code-generator.ts b/src/recording/code-generator.ts index ce913a0..4be41b5 100644 --- a/src/recording/code-generator.ts +++ b/src/recording/code-generator.ts @@ -37,6 +37,9 @@ function generateStep(step: RecordedStep, history: SessionHistory): string { const isSauceLabs = 'sauce:options' in history.capabilities; const isLambdaTest = 'lt:options' in history.capabilities; const isTestingBot = 'tb:options' in history.capabilities; + // Digital.ai uses flat digitalai:* caps for web (digitalai:accessKey/osName) and a + // nested digitalai:options object for mobile — match either. + const isDigitalAi = Object.keys(history.capabilities).some((k) => k.startsWith('digitalai:')); const capJson = indentJson(history.capabilities) .split('\n') .map((line, i) => (i > 0 ? ` ${line}` : line)) @@ -117,6 +120,30 @@ function generateStep(step: RecordedStep, history: SessionHistory): string { ].join('\n'); } + if (isDigitalAi) { + // Digital.ai uses a single hub for browser + mobile; the access key rides in the + // capabilities (flat digitalai:accessKey for web, nested digitalai:options for mobile). + const nav = + platform === 'browser' && p.navigationUrl + ? `\nawait browser.url('${escapeStr(p.navigationUrl)}');` + : ''; + // Scrub the resolved key to a process.env reference so the secret is not baked in. + const daiCaps = capJson.replace( + /("(?:digitalai:)?accessKey":\s*)"(?:[^"\\]|\\.)*"/g, + '$1process.env.DIGITALAI_ACCESS_KEY', + ); + return [ + 'const browser = await remote({', + " protocol: 'https',", + // DIGITALAI_CLOUD_URL is a full URL; remote() wants a bare hostname (strip scheme + path). + " hostname: (process.env.DIGITALAI_CLOUD_URL ?? '').replace(/^https?:\\/\\//, '').replace(/\\/.*/, ''),", + ' port: 443,', + " path: '/wd/hub',", + ` capabilities: ${daiCaps}`, + `});${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}`; @@ -188,6 +215,8 @@ export function generateCode(history: SessionHistory): string { const isSauceLabs = sauceOptions !== undefined; const isLambdaTest = ltOptions !== undefined; const isTestingBot = tbOptions !== undefined; + // Digital.ai uses flat digitalai:* caps (web) or a nested digitalai:options object (mobile). + const isDigitalAi = Object.keys(history.capabilities).some((k) => k.startsWith('digitalai:')); const usesLocalTunnel = isBrowserStack ? bstackOptions?.local === true : isSauceLabs ? (sauceOptions?.tunnelName !== undefined) : isLambdaTest ? (ltOptions?.tunnel === true) @@ -426,5 +455,31 @@ export function generateCode(history: SessionHistory): string { ].join('\n'); } + if (isDigitalAi) { + const daiSteps = steps.replace(/const browser = await remote\(/g, 'browser = await remote('); + const preamble = 'let browser;\nlet daiStatus = \'passed\';'; + const catchBlock = '} catch (e) {\n daiStatus = \'failed\';\n throw e;'; + // seetest:client.setReportStatus(status, message) — marks the cloud report pass/fail. + const statusUpdate = " await browser.execute('seetest:client.setReportStatus', daiStatus === 'passed' ? 'Passed' : 'Failed', daiStatus === 'passed' ? 'Test passed' : 'Test failed');"; + const finallyLines = [ + ' if (browser) {', + statusUpdate, + ' await browser.deleteSession();', + ' }', + ]; + + return [ + "import { remote } from 'webdriverio';", + '', + preamble, + 'try {', + daiSteps, + 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/session/state.ts b/src/session/state.ts index ee12f7e..a493ab1 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' | 'testmu' | 'testingbot'; + provider?: 'local' | 'browserstack' | 'saucelabs' | 'testmu' | 'testingbot' | 'digitalai'; region?: string; tunnelName?: string; tunnelHandle?: unknown; diff --git a/src/tools/cloud-provider.tool.ts b/src/tools/cloud-provider.tool.ts index c7ddae7..fdca8a9 100644 --- a/src/tools/cloud-provider.tool.ts +++ b/src/tools/cloud-provider.tool.ts @@ -4,6 +4,7 @@ import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ToolDefinition } from '../types/tool'; import { coerceBoolean } from '../utils/zod-helpers'; import { basicAuth } from '../utils/auth'; +import { resolveCloudHost } from '../providers/cloud/digitalai.provider'; // ─── Provider API config ─────────────────────────────────────────────────────── @@ -14,6 +15,8 @@ interface ProviderApiConfig { apiBase: string; /** Env var names for credentials */ credsEnvNames: [string, string]; + /** Authorization scheme for API requests (default: 'basic'). */ + authScheme?: 'basic' | 'bearer'; /** list_apps endpoint path (relative to apiBase) */ listPath: string; /** Whether list endpoint supports org-wide query param (BrowserStack only) */ @@ -140,11 +143,48 @@ const PROVIDER_CONFIGS: Record = { return { appRef: data.app_url ?? 'unknown', appName: fileName }; }, }, + digitalai: { + name: 'Digital.ai', + apiBase: '', // derived from DIGITALAI_CLOUD_URL in getProviderConfig + credsEnvNames: ['DIGITALAI_ACCESS_KEY', 'DIGITALAI_CLOUD_URL'], + authScheme: 'bearer', + listPath: '/api/v1/applications', + supportsOrgWide: false, + parseListResponse: (raw) => { + // Apps are referenced in a session as "cloud:" (the `name` field). + const apps = Array.isArray(raw) ? raw as Record[] : []; + return apps.map((a) => ({ + name: (a.applicationName ?? a.name ?? 'unknown') as string, + ref: a.name ? `cloud:${a.name}` : 'unknown', + uploadedAt: a.createdAtFormatted as string | undefined, + customId: a.uniqueName as string | undefined, + })); + }, + uploadPath: '/api/v1/applications/new', + uploadField: 'file', + parseUploadResponse: (raw, fileName) => { + const data = (raw as { data?: { name?: string } }).data ?? {}; + const name = data.name ?? fileName; + return { appRef: `cloud:${name}`, appName: name }; + }, + }, }; -function getProviderConfig(provider: string, region?: string): { config: ProviderApiConfig; auth: string } | { error: string } { +function getProviderConfig(provider: string, region?: string): { config: ProviderApiConfig; authHeader: string } | { error: string } { const base = PROVIDER_CONFIGS[provider]; if (!base) return { error: `Unknown provider: ${provider}` }; + + // Bearer providers (Digital.ai) use a single access key; the second env var is the cloud URL. + if (base.authScheme === 'bearer') { + const [keyEnv, urlEnv] = base.credsEnvNames; + const key = process.env[keyEnv]; + const host = resolveCloudHost(process.env[urlEnv]); + if (!key || !host) { + return { error: `Missing credentials: set ${base.credsEnvNames.join(' and ')} environment variables.` }; + } + return { config: { ...base, apiBase: `https://${host}` }, authHeader: `Bearer ${key}` }; + } + const [userEnv, keyEnv] = base.credsEnvNames; const user = process.env[userEnv]; const key = process.env[keyEnv]; @@ -156,17 +196,17 @@ function getProviderConfig(provider: string, region?: string): { config: Provide const config = provider === 'saucelabs' ? { ...base, apiBase: `https://api.${region ?? 'eu-central-1'}.saucelabs.com` } : base; - return { config, auth: basicAuth(user, key) }; + return { config, authHeader: `Basic ${basicAuth(user, key)}` }; } // ─── list_apps ──────────────────────────────────────────────────────────────── export const listAppsToolDefinition: ToolDefinition = { name: 'list_apps', - description: 'List apps uploaded to a cloud provider (BrowserStack App Automate, Sauce Labs App Storage, TestMu Real Device Cloud, or TestingBot Storage). Reads provider-specific credentials from environment.', + description: 'List apps uploaded to a cloud provider (BrowserStack App Automate, Sauce Labs App Storage, TestMu Real Device Cloud, TestingBot Storage, or Digital.ai Applications). Reads provider-specific credentials from environment.', annotations: { title: 'List Cloud Provider Apps', readOnlyHint: true, idempotentHint: true }, inputSchema: { - provider: z.enum(['browserstack', 'saucelabs', 'testmu', 'testingbot']).describe('Cloud provider'), + provider: z.enum(['browserstack', 'saucelabs', 'testmu', 'testingbot', 'digitalai']).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)'), @@ -175,7 +215,7 @@ export const listAppsToolDefinition: ToolDefinition = { }; type ListAppsArgs = { - provider: 'browserstack' | 'saucelabs' | 'testmu' | 'testingbot'; + provider: 'browserstack' | 'saucelabs' | 'testmu' | 'testingbot' | 'digitalai'; sortBy?: 'app_name' | 'uploaded_at'; organizationWide?: boolean; limit?: number; @@ -188,7 +228,7 @@ export const listAppsTool: ToolCallback = async (args: ListAppsArgs) => { if ('error' in resolved) { return { isError: true as const, content: [{ type: 'text' as const, text: resolved.error }] }; } - const { config, auth } = resolved; + const { config, authHeader } = resolved; try { let url = `${config.apiBase}${config.listPath}`; @@ -203,7 +243,7 @@ export const listAppsTool: ToolCallback = async (args: ListAppsArgs) => { for (const platform of ['android', 'ios']) { try { const res = await fetch(`${url}?type=${platform}`, { - headers: { Authorization: `Basic ${auth}` }, + headers: { Authorization: authHeader }, }); if (res.ok) { const raw = await res.json(); @@ -222,7 +262,7 @@ export const listAppsTool: ToolCallback = async (args: ListAppsArgs) => { } } else { const res = await fetch(url, { - headers: { Authorization: `Basic ${auth}` }, + headers: { Authorization: authHeader }, }); if (!res.ok) { @@ -246,10 +286,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, Sauce Labs, TestMu, or TestingBot). Returns the app URL for use in start_session.', + description: 'Upload a local .apk or .ipa to a cloud provider (BrowserStack, Sauce Labs, TestMu, TestingBot, or Digital.ai). Returns the app URL for use in start_session.', annotations: { title: 'Upload App to Cloud Provider', destructiveHint: false }, inputSchema: { - provider: z.enum(['browserstack', 'saucelabs', 'testmu', 'testingbot']).describe('Cloud provider'), + provider: z.enum(['browserstack', 'saucelabs', 'testmu', 'testingbot', 'digitalai']).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)'), @@ -257,7 +297,7 @@ export const uploadAppToolDefinition: ToolDefinition = { }; type UploadAppArgs = { - provider: 'browserstack' | 'saucelabs' | 'testmu' | 'testingbot'; + provider: 'browserstack' | 'saucelabs' | 'testmu' | 'testingbot' | 'digitalai'; path: string; customId?: string; region?: 'us-west-1' | 'eu-central-1' | 'apac-southeast-1'; @@ -269,7 +309,7 @@ export const uploadAppTool: ToolCallback = async (args: UploadAppArgs) => { if ('error' in resolved) { return { isError: true as const, content: [{ type: 'text' as const, text: resolved.error }] }; } - const { config, auth } = resolved; + const { config, authHeader } = resolved; if (!existsSync(path)) { return { isError: true as const, content: [{ type: 'text' as const, text: `File not found: ${path}` }] }; @@ -286,7 +326,7 @@ export const uploadAppTool: ToolCallback = async (args: UploadAppArgs) => { const res = await fetch(`${config.apiBase}${config.uploadPath}`, { method: 'POST', - headers: { Authorization: `Basic ${auth}` }, + headers: { Authorization: authHeader }, body: form, }); diff --git a/src/tools/session.tool.ts b/src/tools/session.tool.ts index a630cf8..044aef1 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', 'testmu', 'testingbot']).optional().default('local').describe('Session provider (default: local)'), + provider: z.enum(['local', 'browserstack', 'saucelabs', 'testmu', 'testingbot', 'digitalai']).optional().default('local').describe('Session provider (default: local). "digitalai" requires DIGITALAI_CLOUD_URL + DIGITALAI_ACCESS_KEY env vars.'), 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 for cloud provider browser sessions (e.g. "Windows", "Mac", "macOS", "Linux"). BrowserStack: sets bstack:options.os separately. TestMu/Sauce Labs/TestingBot: 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/TestingBot: 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, tb://... for TestingBot mobile sessions)'), + 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/TestingBot: combined with osVersion into W3C platformName. Digital.ai: combined with osVersion into the digitalai:osName capability (e.g. "Mac OS Sequoia", "Windows 10") — required for the grid to match a node. 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/TestingBot: combined with os into W3C platformName. Digital.ai: combined with os into digitalai:osName. Browser platform only.'), + app: z.string().optional().describe('App URL (bs://... for BrowserStack, storage:filename= for Sauce Labs, lt://... for TestMu, tb://... for TestingBot, cloud: for Digital.ai mobile sessions)'), reporting: z.object({ project: z.string().optional(), build: z.string().optional(), @@ -35,6 +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)'), + deviceQuery: z.string().optional().describe('Digital.ai device selection query for dynamic allocation, e.g. "@os=\'android\' and @version=\'14\' and @name=\'.*Pixel.*\'". Only used with provider: "digitalai" mobile sessions; if omitted, one is built from deviceName/platformVersion.'), 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'), @@ -70,7 +71,7 @@ export const startSessionToolDefinition: ToolDefinition = { }; type StartSessionArgs = { - provider?: 'local' | 'browserstack' | 'saucelabs' | 'testmu' | 'testingbot'; + provider?: 'local' | 'browserstack' | 'saucelabs' | 'testmu' | 'testingbot' | 'digitalai'; platform: 'browser' | 'ios' | 'android'; browser?: 'chrome' | 'firefox' | 'edge' | 'safari'; browserVersion?: string; @@ -82,6 +83,7 @@ type StartSessionArgs = { windowWidth?: number; windowHeight?: number; deviceName?: string; + deviceQuery?: string; platformVersion?: string; appPath?: string; automationName?: 'XCUITest' | 'UiAutomator2'; @@ -259,11 +261,12 @@ async function startBrowserSession(args: StartSessionArgs): Promise { + let provider: DigitalAiProvider; + + beforeEach(() => { + provider = new DigitalAiProvider(); + vi.stubEnv('DIGITALAI_CLOUD_URL', 'https://cloud.example.com'); + vi.stubEnv('DIGITALAI_ACCESS_KEY', 'access-key-123'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + describe('resolveCloudHost', () => { + it('strips protocol, trailing slashes, and /wd/hub', () => { + expect(resolveCloudHost('https://cloud.example.com')).toBe('cloud.example.com'); + expect(resolveCloudHost('http://cloud.example.com/')).toBe('cloud.example.com'); + expect(resolveCloudHost('cloud.example.com/wd/hub')).toBe('cloud.example.com'); + expect(resolveCloudHost('cloud.example.com')).toBe('cloud.example.com'); + }); + + it('returns undefined for empty input', () => { + expect(resolveCloudHost(undefined)).toBeUndefined(); + expect(resolveCloudHost('')).toBeUndefined(); + }); + }); + + describe('getConnectionConfig', () => { + it('builds the hub config from DIGITALAI_CLOUD_URL without basic auth', () => { + const config = provider.getConnectionConfig({ platform: 'browser' }); + expect(config.protocol).toBe('https'); + expect(config.hostname).toBe('cloud.example.com'); + expect(config.port).toBe(443); + expect(config.path).toBe('/wd/hub'); + expect(config.user).toBeUndefined(); + expect(config.key).toBeUndefined(); + }); + + it('throws when DIGITALAI_CLOUD_URL is missing', () => { + vi.stubEnv('DIGITALAI_CLOUD_URL', ''); + expect(() => provider.getConnectionConfig({ platform: 'browser' })).toThrow(/DIGITALAI_CLOUD_URL/); + }); + }); + + describe('buildCapabilities — browser platform', () => { + it('sets browserName and FLAT digitalai:accessKey (no digitalai:options) so the reporter activates', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + expect(caps.browserName).toBe('chrome'); + expect(caps['digitalai:accessKey']).toBe('access-key-123'); + expect(caps['digitalai:options']).toBeUndefined(); + }); + + it('maps edge to MicrosoftEdge', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'edge' }); + expect(caps.browserName).toBe('MicrosoftEdge'); + }); + + it('combines os and osVersion into digitalai:osName (not platformName)', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', os: 'Windows', osVersion: '10' }); + expect(caps['digitalai:osName']).toBe('Windows 10'); + expect(caps.platformName).toBeUndefined(); + }); + + it('uses os alone as digitalai:osName (e.g. "Mac OS Sequoia")', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', os: 'Mac OS Sequoia' }); + expect(caps['digitalai:osName']).toBe('Mac OS Sequoia'); + }); + + it('omits digitalai:osName when no os provided', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + expect(caps['digitalai:osName']).toBeUndefined(); + }); + + it('omits browserVersion unless explicitly provided', () => { + expect(provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }).browserVersion).toBeUndefined(); + expect(provider.buildCapabilities({ platform: 'browser', browser: 'chrome', browserVersion: '149' }).browserVersion).toBe('149'); + }); + + it('passes reporting session/project as flat digitalai:testName', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome', reporting: { project: 'Proj', session: 'Login test' } }); + expect(caps['digitalai:testName']).toBe('Login test'); + }); + + it('throws when DIGITALAI_ACCESS_KEY is missing', () => { + vi.stubEnv('DIGITALAI_ACCESS_KEY', ''); + expect(() => provider.buildCapabilities({ platform: 'browser', browser: 'chrome' })).toThrow(/DIGITALAI_ACCESS_KEY/); + }); + }); + + describe('buildCapabilities — mobile platform', () => { + it('does NOT pin appiumVersion (Appium version is chosen at the project level)', () => { + const caps = provider.buildCapabilities({ platform: 'android', deviceName: 'Pixel 7' }); + const dai = caps['digitalai:options'] as Record; + expect(dai.appiumVersion).toBeUndefined(); + }); + + it('browser sessions use flat caps (no digitalai:options)', () => { + const caps = provider.buildCapabilities({ platform: 'browser', browser: 'chrome' }); + expect(caps['digitalai:options']).toBeUndefined(); + }); + + it('uses an explicit deviceQuery verbatim', () => { + const caps = provider.buildCapabilities({ platform: 'android', deviceQuery: "@os='android' and @name='.*Pixel.*'" }); + const dai = caps['digitalai:options'] as Record; + expect(dai.deviceQuery).toBe("@os='android' and @name='.*Pixel.*'"); + }); + + it('builds a deviceQuery from deviceName and platformVersion', () => { + const caps = provider.buildCapabilities({ platform: 'android', deviceName: 'Pixel 7', platformVersion: '14' }); + const dai = caps['digitalai:options'] as Record; + expect(dai.deviceQuery).toBe("@os='android' and @version='14' and @name='Pixel 7'"); + }); + + it('escapes single quotes in deviceName when building a deviceQuery', () => { + const caps = provider.buildCapabilities({ platform: 'ios', deviceName: "iPhone 15 Pro's" }); + const dai = caps['digitalai:options'] as Record; + expect(dai.deviceQuery).toBe("@os='ios' and @name='iPhone 15 Pro\\'s'"); + }); + + it('omits deviceQuery when only the platform is known', () => { + const caps = provider.buildCapabilities({ platform: 'ios' }); + const dai = caps['digitalai:options'] as Record; + expect(dai.deviceQuery).toBeUndefined(); + }); + + it('sets appium:app for native app refs', () => { + const caps = provider.buildCapabilities({ platform: 'android', deviceName: 'Pixel 7', app: 'cloud:com.example.app' }); + expect(caps['appium:app']).toBe('cloud:com.example.app'); + }); + + it('defaults automationName per platform', () => { + expect(provider.buildCapabilities({ platform: 'ios', deviceName: 'iPhone 15' })['appium:automationName']).toBe('XCUITest'); + expect(provider.buildCapabilities({ platform: 'android', deviceName: 'Pixel 7' })['appium:automationName']).toBe('UiAutomator2'); + }); + }); + + describe('getSessionType', () => { + it('maps platform to session type', () => { + expect(provider.getSessionType({ platform: 'browser' })).toBe('browser'); + expect(provider.getSessionType({ platform: 'ios' })).toBe('ios'); + expect(provider.getSessionType({ platform: 'android' })).toBe('android'); + }); + }); + + describe('shouldAutoDetach', () => { + it('always returns false', () => { + expect(provider.shouldAutoDetach({})).toBe(false); + }); + }); + + describe('onSessionClose', () => { + it('marks the report Passed via seetest:client.setReportStatus', async () => { + const execute = vi.fn().mockResolvedValue(undefined); + const browser = { execute } as unknown as Browser; + await provider.onSessionClose('s1', 'browser', { status: 'passed' }, undefined, browser); + expect(execute).toHaveBeenCalledWith('seetest:client.setReportStatus', 'Passed', 'Test passed'); + }); + + it('marks the report Failed and includes the reason', async () => { + const execute = vi.fn().mockResolvedValue(undefined); + const browser = { execute } as unknown as Browser; + await provider.onSessionClose('s2', 'android', { status: 'failed', reason: 'element not found' }, undefined, browser); + expect(execute).toHaveBeenCalledWith('seetest:client.setReportStatus', 'Failed', 'Test failed: element not found'); + }); + + it('does nothing when no browser is provided', async () => { + await expect(provider.onSessionClose('s3', 'browser', { status: 'passed' })).resolves.toBeUndefined(); + }); + + it('does not throw when the command fails', async () => { + const execute = vi.fn().mockRejectedValue(new Error('session gone')); + const browser = { execute } as unknown as Browser; + await expect( + provider.onSessionClose('s4', 'ios', { status: 'failed' }, undefined, browser), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/providers/registry.test.ts b/tests/providers/registry.test.ts index d3d271d..93d8ace 100644 --- a/tests/providers/registry.test.ts +++ b/tests/providers/registry.test.ts @@ -5,6 +5,7 @@ import { LocalAppiumProvider } from '../../src/providers/local-appium.provider'; import { BrowserStackProvider } from '../../src/providers/cloud/browserstack.provider'; import { TestMuProvider } from '../../src/providers/cloud/testmu.provider'; import { TestingBotProvider } from '../../src/providers/cloud/testingbot.provider'; +import { DigitalAiProvider } from '../../src/providers/cloud/digitalai.provider'; describe('getProvider', () => { it('returns LocalBrowserProvider for local browser', () => { @@ -55,6 +56,18 @@ describe('getProvider', () => { expect(getProvider('testingbot', 'ios')).toBeInstanceOf(TestingBotProvider); }); + it('returns DigitalAiProvider for digitalai browser', () => { + expect(getProvider('digitalai', 'browser')).toBeInstanceOf(DigitalAiProvider); + }); + + it('returns DigitalAiProvider for digitalai android', () => { + expect(getProvider('digitalai', 'android')).toBeInstanceOf(DigitalAiProvider); + }); + + it('returns DigitalAiProvider for digitalai ios', () => { + expect(getProvider('digitalai', 'ios')).toBeInstanceOf(DigitalAiProvider); + }); + it('defaults to local when provider is undefined', () => { expect(getProvider(undefined as unknown as string, 'browser')).toBeInstanceOf(LocalBrowserProvider); }); diff --git a/tests/recording/code-generator.test.ts b/tests/recording/code-generator.test.ts index ba1d193..002b096 100644 --- a/tests/recording/code-generator.test.ts +++ b/tests/recording/code-generator.test.ts @@ -113,6 +113,42 @@ describe('generateCode - header', () => { expect(code).toContain("await browser.url('https://github.com/login');"); }); + it('generates start_session (Digital.ai) with the cloud hub connection and no basic auth', () => { + const history: SessionHistory = { + sessionId: 'dai-123', + type: 'browser', + startedAt: '2026-01-01T00:00:00.000Z', + capabilities: { + browserName: 'chrome', + 'digitalai:accessKey': 'super-secret-key', + 'digitalai:osName': 'Windows 10', + }, + steps: [{ + index: 1, + tool: 'start_session', + params: { platform: 'browser', browser: 'chrome', provider: 'digitalai', navigationUrl: 'https://example.com' }, + status: 'ok', + durationMs: 100, + timestamp: '2026-01-01T00:00:00.000Z', + }], + }; + const code = generateCode(history); + // hostname must strip the scheme/path from DIGITALAI_CLOUD_URL (remote() wants a bare host). + expect(code).toContain("hostname: (process.env.DIGITALAI_CLOUD_URL ?? '').replace(/^https?:\\/\\//, '').replace(/\\/.*/, ''),"); + expect(code).not.toContain('hostname: process.env.DIGITALAI_CLOUD_URL,'); + expect(code).toContain("path: '/wd/hub',"); + expect(code).toContain('"digitalai:osName"'); + expect(code).not.toContain('user: process.env'); + // The flat access key must be referenced via env, never baked in as a literal. + expect(code).toContain('"digitalai:accessKey": process.env.DIGITALAI_ACCESS_KEY'); + expect(code).not.toContain('super-secret-key'); + expect(code).toContain("await browser.url('https://example.com');"); + // Pass/fail wrapper, like the other cloud providers. + expect(code).toContain("let daiStatus = 'passed';"); + expect(code).toContain("await browser.execute('seetest:client.setReportStatus'"); + expect(code).toContain('} finally {'); + }); + it('generates start_session (mobile) using history.appiumConfig for connection config', () => { const history: SessionHistory = { sessionId: 'app-123', diff --git a/tests/tools/cloud-provider.tool.test.ts b/tests/tools/cloud-provider.tool.test.ts index ccae5d5..566f40f 100644 --- a/tests/tools/cloud-provider.tool.test.ts +++ b/tests/tools/cloud-provider.tool.test.ts @@ -468,3 +468,105 @@ describe('upload_app tool (TestingBot)', () => { expect(result.content[0].text).toContain('TESTINGBOT_KEY'); }); }); + +// ─── Digital.ai tests ───────────────────────────────────────────────────────── + +describe('list_apps tool (Digital.ai)', () => { + beforeEach(() => { + vi.stubEnv('DIGITALAI_ACCESS_KEY', 'dai-key'); + vi.stubEnv('DIGITALAI_CLOUD_URL', 'https://cloud.example.com'); + vi.spyOn(global, 'fetch'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('calls /api/v1/applications on the env-derived host with Bearer auth', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => [], + } as Response); + + await callList({ provider: 'digitalai' }); + + const [url, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://cloud.example.com/api/v1/applications'); + expect((options?.headers as Record)?.Authorization).toBe('Bearer dai-key'); + }); + + it('parses the flat array response into cloud: refs', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => [{ + name: 'com.experitest.ExperiBank', + applicationName: 'ExperiBank', + uniqueName: 'EriBank_1plugin', + createdAtFormatted: '2023-02-05 14:42:56', + }], + } as Response); + + const result = await callList({ provider: 'digitalai' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('ExperiBank'); + expect(result.content[0].text).toContain('cloud:com.experitest.ExperiBank'); + expect(result.content[0].text).toContain('EriBank_1plugin'); + }); + + it('does not emit cloud:undefined when an app has no name', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => [{ applicationName: 'In-progress upload', uniqueName: 'pending' }], + } as Response); + + const result = await callList({ provider: 'digitalai' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).not.toContain('cloud:undefined'); + expect(result.content[0].text).toContain('unknown'); + }); + + it('returns isError true when credentials are missing', async () => { + vi.unstubAllEnvs(); + const result = await callList({ provider: 'digitalai' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('DIGITALAI_ACCESS_KEY'); + }); +}); + +describe('upload_app tool (Digital.ai)', () => { + beforeEach(() => { + vi.stubEnv('DIGITALAI_ACCESS_KEY', 'dai-key'); + vi.stubEnv('DIGITALAI_CLOUD_URL', 'https://cloud.example.com'); + 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('posts to /api/v1/applications/new and returns a cloud: ref from data.name', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ status: 'SUCCESS', data: { id: '12345', name: 'com.sample.sample' }, code: 'OK' }), + } as Response); + + const result = await callUpload({ provider: 'digitalai', path: '/local/myapp.apk' }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('cloud:com.sample.sample'); + + const [url, options] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://cloud.example.com/api/v1/applications/new'); + expect((options?.headers as Record)?.Authorization).toBe('Bearer dai-key'); + }); + + it('returns isError true when credentials are missing', async () => { + vi.unstubAllEnvs(); + const result = await callUpload({ provider: 'digitalai', path: '/some/app.apk' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('DIGITALAI_ACCESS_KEY'); + }); +});