From 759dc24ad2e761d7cdd709689d6700a6873f0edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 08:47:18 +0200 Subject: [PATCH 01/17] feat: add cloud webdriver artifacts --- .fallowrc.json | 4 + CONTEXT.md | 8 + package.json | 4 + scripts/integration-progress-model.ts | 5 + src/__tests__/cli-client-commands.test.ts | 1 + .../default-cloud-artifact-provider.test.ts | 116 ++++ src/__tests__/package-exports.test.ts | 1 + src/cli/commands/connection-runtime.ts | 6 +- src/cli/commands/connection.ts | 14 +- src/cli/parser/cli-flags.ts | 16 + src/client/client-normalizers.ts | 2 + src/client/client-shared.ts | 1 + src/client/client-types.ts | 14 +- src/client/client.ts | 6 +- src/cloud-artifacts.ts | 50 ++ src/cloud-webdriver.ts | 54 ++ .../aws-device-farm-artifacts.ts | 82 +++ src/cloud-webdriver/aws-device-farm.ts | 333 +++++++++++ src/cloud-webdriver/browserstack.ts | 297 ++++++++++ src/cloud-webdriver/capabilities.ts | 168 ++++++ src/cloud-webdriver/providers.ts | 7 + src/cloud-webdriver/runtime-helpers.ts | 30 + src/cloud-webdriver/runtime.ts | 373 +++++++++++++ src/cloud-webdriver/webdriver-client.ts | 275 +++++++++ src/cloud-webdriver/webdriver-gestures.ts | 44 ++ src/cloud-webdriver/webdriver-interactor.ts | 278 +++++++++ src/cloud-webdriver/webdriver-source.ts | 132 +++++ src/command-catalog.ts | 2 + src/commands/management/artifacts.ts | 48 ++ src/commands/management/index.ts | 2 + src/commands/management/output.test.ts | 50 +- src/commands/management/output.ts | 26 + .../__tests__/parity.test.ts | 1 + src/core/command-descriptor/registry.ts | 5 + src/daemon-runtime.ts | 2 + src/daemon/__tests__/lease-lifecycle.test.ts | 11 +- .../__tests__/request-handler-catalog.test.ts | 13 + src/daemon/handlers/lease.ts | 65 ++- src/daemon/handlers/session-close.ts | 22 +- src/daemon/handlers/session.ts | 4 + src/daemon/lease-lifecycle.ts | 9 +- src/daemon/request-handler-chain.ts | 6 + src/daemon/request-router.ts | 4 + src/default-cloud-artifact-provider.ts | 76 +++ src/index.ts | 117 +++- src/provider-device-runtime.ts | 29 + .../cloud-webdriver-provider-adapters.test.ts | 528 ++++++++++++++++++ .../cloud-webdriver-runtime.test.ts | 381 +++++++++++++ website/docs/docs/client-api.md | 92 ++- website/docs/docs/commands.md | 14 + website/docs/docs/security-trust.md | 4 +- 51 files changed, 3791 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/default-cloud-artifact-provider.test.ts create mode 100644 src/cloud-artifacts.ts create mode 100644 src/cloud-webdriver.ts create mode 100644 src/cloud-webdriver/aws-device-farm-artifacts.ts create mode 100644 src/cloud-webdriver/aws-device-farm.ts create mode 100644 src/cloud-webdriver/browserstack.ts create mode 100644 src/cloud-webdriver/capabilities.ts create mode 100644 src/cloud-webdriver/providers.ts create mode 100644 src/cloud-webdriver/runtime-helpers.ts create mode 100644 src/cloud-webdriver/runtime.ts create mode 100644 src/cloud-webdriver/webdriver-client.ts create mode 100644 src/cloud-webdriver/webdriver-gestures.ts create mode 100644 src/cloud-webdriver/webdriver-interactor.ts create mode 100644 src/cloud-webdriver/webdriver-source.ts create mode 100644 src/commands/management/artifacts.ts create mode 100644 src/default-cloud-artifact-provider.ts create mode 100644 test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts create mode 100644 test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts diff --git a/.fallowrc.json b/.fallowrc.json index 666aa8af3..437a74799 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -68,6 +68,10 @@ { "file": "src/platforms/ios/index.ts", "exports": ["listSimulatorApps", "uninstallIosApp"] + }, + { + "file": "src/cloud-webdriver.ts", + "exports": ["CLOUD_WEBDRIVER_PROVIDERS"] } ], "usedClassMembers": ["name", "listActiveLeases", "delete", "values", "elapsedMs", "isExpired"], diff --git a/CONTEXT.md b/CONTEXT.md index da6bf0bc6..0b8ef7cf6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,6 +4,14 @@ - Provider-backed integration scenario: device-free integration test that runs the real daemon request path and replaces only external device or host tool execution. - Provider: request-scoped adapter interface for external device, runner, or host tool execution. +- Cloud WebDriver runtime: package-shaped `ProviderDeviceRuntime` implementation that maps a + cloud-owned Appium/WebDriver session into agent-device lease, inventory, install, interactor, and + release hooks without adding provider-specific branches to daemon routing. Cloud WebDriver + adapters must expose explicit command capabilities because snapshots come from Appium page source + rather than agent-device native iOS runner or Android helper backends. +- CloudArtifact: provider-hosted session output such as video, Appium logs, device logs, automation + logs, or provider dashboard links. Cloud artifacts stay under the `cloudArtifacts` response field + so they do not collide with daemon-managed local/downloadable `artifacts`. - Provider transcript: exact record of provider calls used when a test must verify platform command translation. - Scenario transcript: command-level integration flow that describes user-visible behavior through daemon commands. - In-process provider scenario harness: integration runner that invokes the daemon request handler directly without opening an HTTP listener. diff --git a/package.json b/package.json index 508968b43..8dadac1c3 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,10 @@ "import": "./dist/src/android-adb.js", "types": "./dist/src/android-adb.d.ts" }, + "./android-snapshot-helper": { + "import": "./dist/src/android-snapshot-helper.js", + "types": "./dist/src/android-snapshot-helper.d.ts" + }, "./contracts": { "import": "./dist/src/contracts.js", "types": "./dist/src/contracts.d.ts" diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 28db37220..7b37ce3f8 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -230,6 +230,11 @@ function summarizeProviderScenarioFlagExclusions() { owner: 'connection/runtime/request policy tests', keys: ['force', 'noLogin', 'sessionLock', 'sessionLocked', 'sessionLockConflicts'], }, + { + name: 'cloud artifact provider lookup', + owner: 'cloud artifact provider, CLI output, and cloud WebDriver provider scenario tests', + keys: ['provider', 'providerSessionId'], + }, { name: 'Metro and React Native runtime preparation', owner: 'Metro companion integration and parser tests', diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 7a2559c4e..9c024f02a 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -1095,6 +1095,7 @@ function createStubClient(params: { list: async () => [], stateDir: async () => '/tmp/agent-device-state', close: async () => ({ session: 'default', identifiers: { session: 'default' } }), + artifacts: unexpectedCommandCall, }, apps: { install: async () => ({ diff --git a/src/__tests__/default-cloud-artifact-provider.test.ts b/src/__tests__/default-cloud-artifact-provider.test.ts new file mode 100644 index 000000000..802c84188 --- /dev/null +++ b/src/__tests__/default-cloud-artifact-provider.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import http, { type IncomingMessage, type ServerResponse } from 'node:http'; +import { afterEach, test } from 'vitest'; +import { createDefaultCloudArtifactProvider } from '../default-cloud-artifact-provider.ts'; +import { withCommandExecutorOverride } from '../utils/exec.ts'; + +let activeServer: http.Server | undefined; + +afterEach(async () => { + if (!activeServer) return; + await new Promise((resolve, reject) => { + activeServer?.close((error) => (error ? reject(error) : resolve())); + }); + activeServer = undefined; +}); + +test('default cloud artifact provider maps BrowserStack historical sessions from env credentials', async () => { + const server = await startSessionDetailsServer(); + const provider = createDefaultCloudArtifactProvider({ + BROWSERSTACK_USERNAME: 'user', + BROWSERSTACK_ACCESS_KEY: 'key', + BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `${server.url}/sessions`, + }); + + const result = await provider.listCloudArtifacts?.({ + provider: 'browserstack', + providerSessionId: 'wd-1', + }); + + assert.equal(result?.status, 'ready'); + assert.deepEqual( + result?.cloudArtifacts.map((artifact) => artifact.kind), + ['video', 'appium-log', 'device-log', 'provider-session', 'provider-session'], + ); +}); + +test('default cloud artifact provider maps AWS Device Farm historical sessions via aws cli', async () => { + const calls: string[][] = []; + const provider = createDefaultCloudArtifactProvider({}); + await withCommandExecutorOverride( + async (cmd, args) => { + calls.push([cmd, ...args]); + return { + stdout: JSON.stringify({ + artifacts: [ + { + name: args.includes('LOG') ? 'Appium Server Output' : 'Video', + type: args.includes('LOG') ? 'APPIUM_SERVER_OUTPUT' : 'VIDEO', + extension: args.includes('LOG') ? 'log' : 'mp4', + url: 'https://aws.example/artifact', + }, + ], + }), + stderr: '', + exitCode: 0, + }; + }, + async () => { + const result = await provider.listCloudArtifacts?.({ + provider: 'aws-device-farm', + providerSessionId: 'arn:aws:devicefarm:us-west-2:123:session/project/session/00000', + }); + assert.equal(result?.status, 'ready'); + assert.deepEqual( + result?.cloudArtifacts.map((artifact) => artifact.kind), + ['video', 'appium-log'], + ); + }, + ); + + assert.equal(calls[0]?.includes('us-west-2'), true); + assert.equal(calls[1]?.includes('LOG'), true); +}); + +test('default cloud artifact provider does not treat broad aws as a provider name', async () => { + const provider = createDefaultCloudArtifactProvider({}); + + const result = await provider.listCloudArtifacts?.({ + provider: 'aws', + providerSessionId: 'arn:aws:devicefarm:us-west-2:123:session/project/session/00000', + }); + + assert.equal(result, undefined); +}); + +async function startSessionDetailsServer(): Promise<{ url: string }> { + const server = http.createServer((req, res) => respond(req, res)); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + activeServer = server; + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { url: `http://127.0.0.1:${address.port}` }; +} + +function respond(req: IncomingMessage, res: ServerResponse): void { + if (req.method === 'GET' && req.url === '/sessions/wd-1.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + automation_session: { + video_url: 'https://browserstack.example/video.mp4', + appium_logs_url: 'https://browserstack.example/appium.log', + device_logs_url: 'https://browserstack.example/device.log', + browser_url: 'https://browserstack.example/dashboard', + public_url: 'https://browserstack.example/public', + }, + }), + ); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); +} diff --git a/src/__tests__/package-exports.test.ts b/src/__tests__/package-exports.test.ts index bd2790603..3d70dd957 100644 --- a/src/__tests__/package-exports.test.ts +++ b/src/__tests__/package-exports.test.ts @@ -27,6 +27,7 @@ const supportedSubpaths = [ './remote-config', './install-source', './android-adb', + './android-snapshot-helper', './contracts', './selectors', './finders', diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 3c68f56a5..429a995e5 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -457,8 +457,8 @@ export async function stopReactDevtoolsCleanup(options: { export async function releaseRemoteConnectionLease( client: AgentDeviceClient, state: RemoteConnectionState, -): Promise { - if (!state.leaseId) return false; +): Promise<{ released: boolean; provider?: Record }> { + if (!state.leaseId) return { released: false }; const result = await client.leases.release({ tenant: state.tenant, runId: state.runId, @@ -472,7 +472,7 @@ export async function releaseRemoteConnectionLease( clientId: state.clientId, deviceKey: state.deviceKey, }); - return result.released; + return result; } export async function releasePreviousLease( diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index d40774519..0edac9ac7 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -289,8 +289,9 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) } const connectedSession = state.session; + let providerData: Record | undefined; try { - await client.sessions.close({ shutdown: flags.shutdown }); + providerData = (await client.sessions.close({ shutdown: flags.shutdown })).provider; } catch { // Disconnect is idempotent; the session may already be closed. } @@ -299,7 +300,9 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) let released = false; if (state.leaseId) { try { - released = await releaseRemoteConnectionLease(client, state); + const release = await releaseRemoteConnectionLease(client, state); + released = release.released; + providerData ??= release.provider; } catch { // Bridges may release on close or be unreachable; local state still needs cleanup. } @@ -307,7 +310,12 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) removeRemoteConnectionState({ stateDir, session: connectedSession }); writeCommandOutput( flags, - { connected: false, session: connectedSession, released }, + { + connected: false, + session: connectedSession, + released, + ...(providerData ? { provider: providerData } : {}), + }, () => `Disconnected remote session "${connectedSession}".`, ); return true; diff --git a/src/cli/parser/cli-flags.ts b/src/cli/parser/cli-flags.ts index ecbaac844..96762c9f9 100644 --- a/src/cli/parser/cli-flags.ts +++ b/src/cli/parser/cli-flags.ts @@ -47,6 +47,8 @@ export type CliFlags = RemoteConfigMetroOptions & runId?: string; leaseId?: string; leaseBackend?: LeaseBackend; + provider?: string; + providerSessionId?: string; force?: boolean; noLogin?: boolean; kind?: string; @@ -311,6 +313,20 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--lease-backend ios-simulator|ios-instance|android-instance', usageDescription: 'Lease backend for remote tenant connection admission', }, + { + key: 'provider', + names: ['--provider'], + type: 'string', + usageLabel: '--provider ', + usageDescription: 'Cloud provider name for provider-scoped commands', + }, + { + key: 'providerSessionId', + names: ['--provider-session'], + type: 'string', + usageLabel: '--provider-session ', + usageDescription: 'Cloud provider session id or ARN', + }, { key: 'force', names: ['--force'], diff --git a/src/client/client-normalizers.ts b/src/client/client-normalizers.ts index a502402d1..fb2dcfd0e 100644 --- a/src/client/client-normalizers.ts +++ b/src/client/client-normalizers.ts @@ -272,6 +272,8 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { daemonTransport: options.daemonTransport, daemonServerMode: options.daemonServerMode, ...leaseScopeToCommandFlags(leaseScope), + provider: options.provider, + providerSessionId: options.providerSessionId, sessionIsolation: options.sessionIsolation, platform: options.platform, target: options.target, diff --git a/src/client/client-shared.ts b/src/client/client-shared.ts index 5c6c1db38..3a052fc6f 100644 --- a/src/client/client-shared.ts +++ b/src/client/client-shared.ts @@ -165,6 +165,7 @@ export function serializeCloseResult( return { session: result.session, ...(result.shutdown ? { shutdown: result.shutdown } : {}), + ...('provider' in result && result.provider ? { provider: result.provider } : {}), ...successText(result.session ? `Closed: ${result.session}` : 'Closed'), }; } diff --git a/src/client/client-types.ts b/src/client/client-types.ts index a54fd6492..a0c8d6d3f 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -46,6 +46,7 @@ import type { AlertAction, AlertInfo } from '../alert-contract.ts'; import type { DebugSymbolsOptions, DebugSymbolsResult } from '../contracts/debug-symbols.ts'; import type { RemoteConnectionProfileFields } from '../remote/remote-config-schema.ts'; import type { CommandResult } from '../core/command-descriptor/command-result.ts'; +import type { CloudArtifactsResult } from '../cloud-artifacts.ts'; export type { FindLocator } from '../utils/finders.ts'; export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; @@ -186,9 +187,15 @@ export type StartupPerfSample = { export type SessionCloseResult = { session: string; shutdown?: TargetShutdownResult; + provider?: Record; identifiers: AgentDeviceIdentifiers; }; +export type CloudArtifactsOptions = AgentDeviceRequestOverrides & { + provider?: string; + providerSessionId?: string; +}; + export type AppInstallOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { app?: string; @@ -894,6 +901,8 @@ export type InternalRequestOptions = AgentDeviceClientConfig & materializedPathRetentionMs?: number; materializationId?: string; leaseTtlMs?: number; + provider?: string; + providerSessionId?: string; }; export type CommandRequestResult = DaemonResponseData; @@ -915,6 +924,7 @@ export type AgentDeviceClient = { close: ( options?: AgentDeviceRequestOverrides & { shutdown?: boolean }, ) => Promise; + artifacts: (options?: CloudArtifactsOptions) => Promise; }; apps: { install: (options: AppInstallOptions) => Promise; @@ -934,7 +944,9 @@ export type AgentDeviceClient = { leases: { allocate: (options: LeaseAllocateOptions) => Promise; heartbeat: (options: LeaseScopedOptions) => Promise; - release: (options: LeaseScopedOptions) => Promise<{ released: boolean }>; + release: ( + options: LeaseScopedOptions, + ) => Promise<{ released: boolean; provider?: Record }>; }; metro: { prepare: (options: MetroPrepareOptions) => Promise; diff --git a/src/client/client.ts b/src/client/client.ts index a92171984..b6664131f 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -51,6 +51,7 @@ import { isNonDefaultResponseLevel, type ResponseLevel } from '../kernel/contrac import { readSerializedSnapshotCaptureAnnotations } from '../snapshot-capture-annotations.ts'; import { readSnapshotDiagnosticsSummary } from '../snapshot-diagnostics.ts'; import type { CommandFlags } from '../core/dispatch-context.ts'; +import type { CloudArtifactsResult } from '../cloud-artifacts.ts'; export function createAgentDeviceClient( config: AgentDeviceClientConfig = {}, @@ -150,9 +151,12 @@ export function createAgentDeviceClient( return { session, shutdown: normalizeTargetShutdownResult(data.shutdown), + provider: readObject(data.provider), identifiers: { session }, }; }, + artifacts: async (options = {}) => + await executeCommand('artifacts', options), }, apps: { install: async (options: AppInstallOptions) => @@ -236,7 +240,7 @@ export function createAgentDeviceClient( normalizeLease(await execute(INTERNAL_COMMANDS.leaseHeartbeat, [], options)), release: async (options) => { const data = await execute(INTERNAL_COMMANDS.leaseRelease, [], options); - return { released: data.released === true }; + return { released: data.released === true, provider: readObject(data.provider) }; }, }, metro: { diff --git a/src/cloud-artifacts.ts b/src/cloud-artifacts.ts new file mode 100644 index 000000000..b3c480ceb --- /dev/null +++ b/src/cloud-artifacts.ts @@ -0,0 +1,50 @@ +const CLOUD_ARTIFACT_KINDS = [ + 'video', + 'appium-log', + 'device-log', + 'automation-log', + 'provider-session', + 'raw', +] as const; + +export type CloudArtifactKind = (typeof CLOUD_ARTIFACT_KINDS)[number]; + +export type CloudArtifactAvailability = 'ready' | 'pending' | 'unavailable' | 'expired'; + +export type CloudArtifact = { + provider: string; + kind: CloudArtifactKind; + name: string; + url?: string; + providerSessionId?: string; + providerArtifactId?: string; + contentType?: string; + extension?: string; + availability?: CloudArtifactAvailability; + metadata?: Record; +}; + +export type CloudArtifactsStatus = 'ready' | 'pending' | 'unavailable'; + +export type CloudArtifactsResult = { + provider: string; + status: CloudArtifactsStatus; + cloudArtifacts: CloudArtifact[]; + providerSessionId?: string; + message?: string; +}; + +export type CloudArtifactsQuery = { + provider?: string; + leaseId?: string; + providerSessionId?: string; +}; + +/** + * Return undefined only when this provider implementation does not handle the query. + * Return a CloudArtifactsResult with status "unavailable" when the provider handled the + * query but artifact retrieval failed, and "pending" when artifacts are not finalized yet. + */ +export type CloudArtifactProvider = { + listCloudArtifacts?: (query: CloudArtifactsQuery) => Promise; +}; diff --git a/src/cloud-webdriver.ts b/src/cloud-webdriver.ts new file mode 100644 index 000000000..fda1992ac --- /dev/null +++ b/src/cloud-webdriver.ts @@ -0,0 +1,54 @@ +export { + CLOUD_WEBDRIVER_PROVIDERS, + type CloudWebDriverKnownProviderName, +} from './cloud-webdriver/providers.ts'; +export { + createCloudWebDriverRuntime, + type CloudWebDriverPlatform, + type CloudWebDriverBaseSession, + type CloudWebDriverPreparedSession, + type CloudWebDriverListArtifacts, + type CloudWebDriverPrepareSession, + type CloudWebDriverRuntimeOptions, + type CloudWebDriverUploadApp, + type CloudWebDriverUploadResult, +} from './cloud-webdriver/runtime.ts'; +export { + createBrowserStackWebDriverRuntime, + getBrowserStackWebDriverCapabilities, + listBrowserStackCloudArtifacts, + uploadBrowserStackApp, + type BrowserStackSessionDetailsOptions, + type BrowserStackUploadOptions, + type BrowserStackWebDriverRuntimeOptions, +} from './cloud-webdriver/browserstack.ts'; +export { + createAwsCliDeviceFarmClient, + createAwsDeviceFarmWebDriverRuntime, + getAwsDeviceFarmWebDriverCapabilities, + listAwsDeviceFarmCloudArtifacts, + selectAwsDeviceFarmWebDriverEndpoint, + type AwsDeviceFarmArtifact, + type AwsDeviceFarmArtifactGroup, + type AwsCliDeviceFarmClientOptions, + type AwsCreateRemoteAccessSessionInput, + type AwsDeviceFarmClient, + type AwsDeviceFarmRemoteAccessSession, + type AwsDeviceFarmWebDriverRuntimeOptions, +} from './cloud-webdriver/aws-device-farm.ts'; +export type { + CloudWebDriverCapabilityMap, + CloudWebDriverCapabilityOverrides, + CloudWebDriverOperation, + CloudWebDriverOperationCapability, + CloudWebDriverProviderCapabilities, + CloudWebDriverSupportLevel, +} from './cloud-webdriver/capabilities.ts'; +export type { WebDriverAuth, WebDriverRequestPolicy } from './cloud-webdriver/webdriver-client.ts'; +export type { + CloudArtifact, + CloudArtifactAvailability, + CloudArtifactKind, + CloudArtifactsResult, + CloudArtifactsStatus, +} from './cloud-artifacts.ts'; diff --git a/src/cloud-webdriver/aws-device-farm-artifacts.ts b/src/cloud-webdriver/aws-device-farm-artifacts.ts new file mode 100644 index 000000000..e628ba204 --- /dev/null +++ b/src/cloud-webdriver/aws-device-farm-artifacts.ts @@ -0,0 +1,82 @@ +import type { CloudArtifact, CloudArtifactsResult } from '../cloud-artifacts.ts'; +import type { AwsDeviceFarmClient } from './aws-device-farm.ts'; + +export type AwsDeviceFarmArtifactGroup = 'FILE' | 'LOG' | 'SCREENSHOT'; + +export type AwsDeviceFarmArtifact = { + arn?: string; + name?: string; + type?: string; + extension?: string; + url?: string; + metadata?: string; +}; + +export async function listAwsDeviceFarmCloudArtifacts( + provider: string, + providerSessionId: string | undefined, + client: AwsDeviceFarmClient, +): Promise { + if (!providerSessionId) return undefined; + const groups = await Promise.all([ + client.listArtifacts(providerSessionId, 'FILE'), + client.listArtifacts(providerSessionId, 'LOG'), + ]); + const artifacts = groups + .flat() + .flatMap((artifact) => mapAwsDeviceFarmArtifact(provider, providerSessionId, artifact)); + return { + provider, + providerSessionId, + status: artifacts.length > 0 ? 'ready' : 'pending', + cloudArtifacts: artifacts, + ...(artifacts.length > 0 ? {} : { message: 'AWS Device Farm artifacts are not ready yet.' }), + }; +} + +export function readAwsArtifacts(value: unknown): AwsDeviceFarmArtifact[] { + if (!value || typeof value !== 'object') return []; + const artifacts = (value as { artifacts?: unknown }).artifacts; + return Array.isArray(artifacts) ? (artifacts as AwsDeviceFarmArtifact[]) : []; +} + +function mapAwsDeviceFarmArtifact( + provider: string, + providerSessionId: string, + artifact: AwsDeviceFarmArtifact, +): CloudArtifact[] { + const url = artifact.url; + if (typeof url !== 'string' || url.length === 0) return []; + return [ + { + provider, + providerSessionId, + kind: awsCloudArtifactKind(artifact.type), + name: artifact.name ?? artifact.type ?? 'AWS Device Farm artifact', + url, + providerArtifactId: artifact.arn, + extension: artifact.extension, + availability: 'ready', + metadata: { + awsType: artifact.type, + ...(artifact.metadata ? { awsMetadata: artifact.metadata } : {}), + }, + }, + ]; +} + +function awsCloudArtifactKind(type: string | undefined): CloudArtifact['kind'] { + switch (type) { + case 'VIDEO': + return 'video'; + case 'APPIUM_SERVER_OUTPUT': + return 'appium-log'; + case 'DEVICE_LOG': + return 'device-log'; + case 'MESSAGE_LOG': + case 'UNKNOWN': + return 'automation-log'; + default: + return 'raw'; + } +} diff --git a/src/cloud-webdriver/aws-device-farm.ts b/src/cloud-webdriver/aws-device-farm.ts new file mode 100644 index 000000000..d20cb05b6 --- /dev/null +++ b/src/cloud-webdriver/aws-device-farm.ts @@ -0,0 +1,333 @@ +import { + createCloudWebDriverCapabilities, + type CloudWebDriverProviderCapabilities, +} from './capabilities.ts'; +import { createCloudWebDriverRuntime } from './runtime.ts'; +import { + listAwsDeviceFarmCloudArtifacts, + readAwsArtifacts, + type AwsDeviceFarmArtifact, + type AwsDeviceFarmArtifactGroup, +} from './aws-device-farm-artifacts.ts'; +import type { + CloudWebDriverPlatform, + CloudWebDriverRuntimeOptions, + CloudWebDriverPrepareSession, +} from './runtime.ts'; +import type { DeviceLease } from '../daemon/lease-registry.ts'; +import type { ProviderDeviceRuntime } from '../provider-device-runtime.ts'; +import { runCmd } from '../utils/exec.ts'; +import { AppError } from '../kernel/errors.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; + +const AWS_DEVICE_FARM_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm; + +export { + listAwsDeviceFarmCloudArtifacts, + type AwsDeviceFarmArtifact, + type AwsDeviceFarmArtifactGroup, +} from './aws-device-farm-artifacts.ts'; + +export type AwsDeviceFarmRemoteAccessSession = { + arn: string; + status?: string; + result?: string; + remoteDriverEndpoint?: string; + endpoint?: string; + remoteDebugUrl?: string; + remoteRecordAppUrl?: string; + endpoints?: Record; + device?: { + name?: string; + platform?: string; + os?: string; + }; +}; + +export type AwsDeviceFarmClient = { + createRemoteAccessSession( + input: AwsCreateRemoteAccessSessionInput, + ): Promise; + getRemoteAccessSession(arn: string): Promise; + stopRemoteAccessSession(arn: string): Promise; + listArtifacts(arn: string, type: AwsDeviceFarmArtifactGroup): Promise; +}; + +export type AwsCreateRemoteAccessSessionInput = { + projectArn: string; + deviceArn: string; + name: string; + appArn?: string; + interactionMode?: 'INTERACTIVE' | 'NO_VIDEO' | 'VIDEO_ONLY'; + configuration?: Record; +}; + +export type AwsDeviceFarmWebDriverRuntimeOptions = { + projectArn: string; + deviceArn: string; + region?: string; + platform?: CloudWebDriverPlatform; + deviceName?: string; + appArn?: string; + sessionName?: string | ((lease: DeviceLease) => string); + webdriverCapabilities?: + | Record + | ((lease: DeviceLease) => Record); + client?: AwsDeviceFarmClient; + pollIntervalMs?: number; + startupTimeoutMs?: number; + interactionMode?: AwsCreateRemoteAccessSessionInput['interactionMode']; + configuration?: AwsCreateRemoteAccessSessionInput['configuration']; + deviceId?: CloudWebDriverRuntimeOptions['deviceId']; + requestPolicy?: CloudWebDriverRuntimeOptions['requestPolicy']; +}; + +export function getAwsDeviceFarmWebDriverCapabilities( + platform: CloudWebDriverPlatform, +): CloudWebDriverProviderCapabilities { + return createCloudWebDriverCapabilities({ + provider: AWS_DEVICE_FARM_PROVIDER, + platform, + overrides: { + install: { + support: 'unsupported', + note: 'Pass appArn when creating the remote access session; local artifact upload/install is not implemented.', + }, + portReverse: { + support: 'unsupported', + note: 'AWS Device Farm remote access does not expose agent-device port reverse.', + }, + artifacts: { + support: 'supported', + note: 'AWS Device Farm remote access exposes provider-hosted video, Appium logs, and device logs after session completion.', + }, + }, + }); +} + +export function createAwsDeviceFarmWebDriverRuntime( + options: AwsDeviceFarmWebDriverRuntimeOptions, +): ProviderDeviceRuntime { + const client = options.client ?? createAwsCliDeviceFarmClient({ region: options.region }); + const platform = options.platform ?? 'android'; + const deviceName = options.deviceName ?? 'AWS Device Farm device'; + return createCloudWebDriverRuntime({ + provider: AWS_DEVICE_FARM_PROVIDER, + endpoint: 'http://127.0.0.1/', + platform, + deviceName, + webdriverCapabilities: options.webdriverCapabilities, + prepareSession: createAwsDeviceFarmPrepareSession({ + ...options, + platform, + deviceName, + client, + }), + deviceId: options.deviceId, + requestPolicy: options.requestPolicy, + capabilityOverrides: getAwsDeviceFarmWebDriverCapabilities(platform).operations, + }); +} + +export type AwsCliDeviceFarmClientOptions = { + region?: string; + awsCommand?: string; +}; + +export function createAwsCliDeviceFarmClient( + options: AwsCliDeviceFarmClientOptions = {}, +): AwsDeviceFarmClient { + const regionArgs = options.region ? ['--region', options.region] : []; + const awsCommand = options.awsCommand ?? 'aws'; + return { + createRemoteAccessSession: async (input) => { + const args = [ + 'devicefarm', + 'create-remote-access-session', + ...regionArgs, + '--project-arn', + input.projectArn, + '--device-arn', + input.deviceArn, + '--name', + input.name, + ...(input.appArn ? ['--app-arn', input.appArn] : []), + ...(input.interactionMode ? ['--interaction-mode', input.interactionMode] : []), + ...(input.configuration ? ['--configuration', JSON.stringify(input.configuration)] : []), + '--output', + 'json', + ]; + const json = await runAwsJson(awsCommand, args); + return readRemoteAccessSession(json); + }, + getRemoteAccessSession: async (arn) => { + const json = await runAwsJson(awsCommand, [ + 'devicefarm', + 'get-remote-access-session', + ...regionArgs, + '--arn', + arn, + '--output', + 'json', + ]); + return readRemoteAccessSession(json); + }, + stopRemoteAccessSession: async (arn) => { + const json = await runAwsJson(awsCommand, [ + 'devicefarm', + 'stop-remote-access-session', + ...regionArgs, + '--arn', + arn, + '--output', + 'json', + ]); + return readRemoteAccessSession(json); + }, + listArtifacts: async (arn, type) => { + const json = await runAwsJson(awsCommand, [ + 'devicefarm', + 'list-artifacts', + ...regionArgs, + '--arn', + arn, + '--type', + type, + '--output', + 'json', + ]); + return readAwsArtifacts(json); + }, + }; +} + +function createAwsDeviceFarmPrepareSession( + options: Required< + Pick< + AwsDeviceFarmWebDriverRuntimeOptions, + 'client' | 'platform' | 'deviceName' | 'projectArn' | 'deviceArn' + > + > & + Omit, +): CloudWebDriverPrepareSession { + return async ({ lease, base }) => { + const remoteAccess = await options.client.createRemoteAccessSession({ + projectArn: options.projectArn, + deviceArn: options.deviceArn, + appArn: options.appArn, + name: resolveAwsSessionName(options.sessionName, lease), + interactionMode: options.interactionMode, + configuration: options.configuration, + }); + const running = await waitForRunningRemoteAccessSession(remoteAccess.arn, options); + const endpoint = selectAwsDeviceFarmWebDriverEndpoint(running); + if (!endpoint) { + throw new AppError('COMMAND_FAILED', 'AWS Device Farm did not expose a WebDriver endpoint.', { + sessionArn: running.arn, + status: running.status, + }); + } + return { + ...base, + endpoint, + platform: options.platform, + deviceName: running.device?.name ?? options.deviceName, + cleanup: async () => { + await options.client.stopRemoteAccessSession(running.arn); + return { awsDeviceFarmSessionArn: running.arn }; + }, + listArtifacts: async ({ provider, providerSessionId }) => + await listAwsDeviceFarmCloudArtifacts(provider, providerSessionId, options.client), + providerSessionId: running.arn, + providerData: { + awsDeviceFarmSessionArn: running.arn, + }, + }; + }; +} + +export function selectAwsDeviceFarmWebDriverEndpoint( + session: AwsDeviceFarmRemoteAccessSession, +): string | undefined { + const endpointValues = + session.endpoints && typeof session.endpoints === 'object' + ? Object.values(session.endpoints) + : []; + const candidates = [ + session.remoteDriverEndpoint, + session.endpoint, + ...endpointValues, + session.remoteDebugUrl, + session.remoteRecordAppUrl, + ].filter((value): value is string => typeof value === 'string' && value.length > 0); + const endpoint = candidates.find((value) => !/^wss?:\/\//i.test(value)); + return endpoint ? normalizeAwsDeviceFarmEndpoint(endpoint) : undefined; +} + +function normalizeAwsDeviceFarmEndpoint(endpoint: string): string { + return /^https?:\/\//i.test(endpoint) ? endpoint : `http://${endpoint}`; +} + +async function waitForRunningRemoteAccessSession( + arn: string, + options: { + client: AwsDeviceFarmClient; + pollIntervalMs?: number; + startupTimeoutMs?: number; + }, +): Promise { + const timeoutMs = options.startupTimeoutMs ?? 120_000; + const pollIntervalMs = options.pollIntervalMs ?? 5_000; + const startedAt = Date.now(); + let last = await options.client.getRemoteAccessSession(arn); + while (Date.now() - startedAt < timeoutMs) { + if (last.status === 'RUNNING') return last; + if (last.status === 'ERRORED' || last.status === 'STOPPED' || last.status === 'COMPLETED') { + throw new AppError('COMMAND_FAILED', 'AWS Device Farm remote access session did not start.', { + sessionArn: arn, + status: last.status, + result: last.result, + }); + } + await delay(pollIntervalMs); + last = await options.client.getRemoteAccessSession(arn); + } + throw new AppError('COMMAND_FAILED', 'Timed out waiting for AWS Device Farm remote access.', { + sessionArn: arn, + status: last.status, + result: last.result, + timeoutMs, + }); +} + +function resolveAwsSessionName( + value: string | ((lease: DeviceLease) => string) | undefined, + lease: DeviceLease, +): string { + if (typeof value === 'function') return value(lease); + return value ?? `agent-device-${lease.leaseId}`; +} + +async function runAwsJson(command: string, args: string[]): Promise { + const result = await runCmd(command, args, { maxBuffer: 10 * 1024 * 1024 }); + return JSON.parse(result.stdout) as unknown; +} + +function readRemoteAccessSession(value: unknown): AwsDeviceFarmRemoteAccessSession { + if (!value || typeof value !== 'object') { + throw new AppError('COMMAND_FAILED', 'AWS Device Farm response was not an object.', { + response: value, + }); + } + const session = (value as { remoteAccessSession?: unknown }).remoteAccessSession; + if (!session || typeof session !== 'object') { + throw new AppError('COMMAND_FAILED', 'AWS Device Farm response missed remoteAccessSession.', { + response: value, + }); + } + return session as AwsDeviceFarmRemoteAccessSession; +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/cloud-webdriver/browserstack.ts b/src/cloud-webdriver/browserstack.ts new file mode 100644 index 000000000..f5f0e6bc0 --- /dev/null +++ b/src/cloud-webdriver/browserstack.ts @@ -0,0 +1,297 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CloudArtifact, CloudArtifactsResult } from '../cloud-artifacts.ts'; +import { + createCloudWebDriverCapabilities, + type CloudWebDriverProviderCapabilities, +} from './capabilities.ts'; +import { + createCloudWebDriverRuntime, + type CloudWebDriverPlatform, + type CloudWebDriverRuntimeOptions, + type CloudWebDriverUploadApp, +} from './runtime.ts'; +import type { ProviderDeviceRuntime } from '../provider-device-runtime.ts'; +import type { DeviceLease } from '../daemon/lease-registry.ts'; +import { AppError } from '../kernel/errors.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; + +const BROWSERSTACK_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.browserStack; +const BROWSERSTACK_APP_AUTOMATE_ENDPOINT = 'https://hub-cloud.browserstack.com/wd/hub/'; +const BROWSERSTACK_APP_UPLOAD_ENDPOINT = 'https://api-cloud.browserstack.com/app-automate/upload'; +const BROWSERSTACK_SESSION_DETAILS_ENDPOINT = + 'https://api-cloud.browserstack.com/app-automate/sessions'; + +export type BrowserStackWebDriverRuntimeOptions = { + username: string; + accessKey: string; + platform: CloudWebDriverPlatform; + deviceName: string; + osVersion: string; + app?: string; + projectName?: string; + buildName?: string | ((lease: DeviceLease) => string); + sessionName?: string | ((lease: DeviceLease) => string); + webdriverCapabilities?: + | Record + | ((lease: DeviceLease) => Record); + endpoint?: string | URL; + uploadEndpoint?: string | URL; + sessionDetailsEndpoint?: string | URL; + deviceId?: CloudWebDriverRuntimeOptions['deviceId']; + requestPolicy?: CloudWebDriverRuntimeOptions['requestPolicy']; +}; + +export function getBrowserStackWebDriverCapabilities( + platform: CloudWebDriverPlatform, +): CloudWebDriverProviderCapabilities { + return createCloudWebDriverCapabilities({ + provider: BROWSERSTACK_PROVIDER, + platform, + overrides: { + install: { + support: 'partial', + note: 'Local app artifacts are uploaded to BrowserStack App Automate, then installed with Appium.', + }, + portReverse: { + support: 'unsupported', + note: 'Use BrowserStack Local for network tunneling; agent-device port reverse is not available.', + }, + artifacts: { + support: 'supported', + note: 'BrowserStack session details expose provider-hosted video, Appium logs, device logs, and dashboard links.', + }, + }, + }); +} + +export function createBrowserStackWebDriverRuntime( + options: BrowserStackWebDriverRuntimeOptions, +): ProviderDeviceRuntime { + const uploadEndpoint = options.uploadEndpoint ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT; + const artifactOptions = { + username: options.username, + accessKey: options.accessKey, + endpoint: options.sessionDetailsEndpoint ?? BROWSERSTACK_SESSION_DETAILS_ENDPOINT, + }; + return createCloudWebDriverRuntime({ + provider: BROWSERSTACK_PROVIDER, + endpoint: options.endpoint ?? BROWSERSTACK_APP_AUTOMATE_ENDPOINT, + platform: options.platform, + deviceName: options.deviceName, + auth: { + username: options.username, + accessKey: options.accessKey, + }, + webdriverCapabilities: (lease) => browserStackCapabilities(options, lease), + uploadApp: createBrowserStackUploadApp({ + username: options.username, + accessKey: options.accessKey, + endpoint: uploadEndpoint, + }), + listArtifacts: async ({ provider, providerSessionId }) => + await listBrowserStackCloudArtifacts(provider, providerSessionId, artifactOptions), + deviceId: options.deviceId, + requestPolicy: options.requestPolicy, + capabilityOverrides: getBrowserStackWebDriverCapabilities(options.platform).operations, + }); +} + +export type BrowserStackSessionDetailsOptions = { + username: string; + accessKey: string; + endpoint?: string | URL; +}; + +export async function listBrowserStackCloudArtifacts( + provider: string, + providerSessionId: string | undefined, + options: BrowserStackSessionDetailsOptions, +): Promise { + if (!providerSessionId) return undefined; + const details = await fetchBrowserStackSessionDetails(providerSessionId, options); + const artifacts = mapBrowserStackArtifacts(provider, providerSessionId, details); + return { + provider, + providerSessionId, + status: artifacts.length > 0 ? 'ready' : 'pending', + cloudArtifacts: artifacts, + ...(artifacts.length > 0 ? {} : { message: 'BrowserStack artifacts are not ready yet.' }), + }; +} + +export type BrowserStackUploadOptions = { + username: string; + accessKey: string; + endpoint?: string | URL; +}; + +export async function uploadBrowserStackApp( + appPath: string, + options: BrowserStackUploadOptions, +): Promise { + const file = await fs.readFile(appPath); + const form = new FormData(); + form.set('file', new Blob([file]), path.basename(appPath)); + const response = await fetch(options.endpoint ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT, { + method: 'POST', + headers: { + Authorization: browserStackAuthHeader(options), + }, + body: form, + }); + const json = (await response.json()) as unknown; + const appUrl = readBrowserStackAppUrl(json); + if (!response.ok || !appUrl) { + throw new AppError('COMMAND_FAILED', 'BrowserStack app upload failed.', { + status: response.status, + response: json, + }); + } + return appUrl; +} + +function createBrowserStackUploadApp( + options: Required, +): CloudWebDriverUploadApp { + return async ({ appPath, options: installOptions }) => { + const appReference = await uploadBrowserStackApp(appPath, options); + return { + appReference, + bundleId: installOptions?.appIdentifierHint, + packageName: installOptions?.packageNameHint, + launchTarget: installOptions?.appIdentifierHint ?? installOptions?.packageNameHint, + }; + }; +} + +function browserStackCapabilities( + options: BrowserStackWebDriverRuntimeOptions, + lease: DeviceLease, +): Record { + const configured = + typeof options.webdriverCapabilities === 'function' + ? options.webdriverCapabilities(lease) + : (options.webdriverCapabilities ?? {}); + return { + device: options.deviceName, + os_version: options.osVersion, + ...(options.app ? { app: options.app } : {}), + 'bstack:options': { + ...(options.projectName ? { projectName: options.projectName } : {}), + buildName: resolveBrowserStackLabel(options.buildName, lease) ?? lease.runId, + sessionName: resolveBrowserStackLabel(options.sessionName, lease) ?? lease.leaseId, + }, + ...configured, + }; +} + +function resolveBrowserStackLabel( + value: string | ((lease: DeviceLease) => string) | undefined, + lease: DeviceLease, +): string | undefined { + return typeof value === 'function' ? value(lease) : value; +} + +function browserStackAuthHeader( + options: Pick, +): string { + return `Basic ${Buffer.from(`${options.username}:${options.accessKey}`).toString('base64')}`; +} + +async function fetchBrowserStackSessionDetails( + sessionId: string, + options: BrowserStackSessionDetailsOptions, +): Promise> { + const endpoint = new URL( + `${trimTrailingSlash(String(options.endpoint ?? BROWSERSTACK_SESSION_DETAILS_ENDPOINT))}/${sessionId}.json`, + ); + const response = await fetch(endpoint, { + headers: { + Authorization: browserStackAuthHeader(options), + }, + }); + const json = (await response.json()) as unknown; + if (!response.ok || !json || typeof json !== 'object') { + throw new AppError('COMMAND_FAILED', 'BrowserStack session details lookup failed.', { + status: response.status, + response: json, + }); + } + const details = (json as { automation_session?: unknown }).automation_session ?? json; + return details && typeof details === 'object' ? (details as Record) : {}; +} + +function mapBrowserStackArtifacts( + provider: string, + providerSessionId: string, + details: Record, +): CloudArtifact[] { + return [ + browserStackUrlArtifact( + provider, + providerSessionId, + details, + 'video_url', + 'video', + 'Session video', + ), + browserStackUrlArtifact( + provider, + providerSessionId, + details, + 'appium_logs_url', + 'appium-log', + 'Appium logs', + ), + browserStackUrlArtifact( + provider, + providerSessionId, + details, + 'device_logs_url', + 'device-log', + 'Device logs', + ), + browserStackUrlArtifact( + provider, + providerSessionId, + details, + 'browser_url', + 'provider-session', + 'BrowserStack dashboard', + ), + browserStackUrlArtifact( + provider, + providerSessionId, + details, + 'public_url', + 'provider-session', + 'Public session link', + ), + ].filter((artifact): artifact is CloudArtifact => artifact !== undefined); +} + +function browserStackUrlArtifact( + provider: string, + providerSessionId: string, + details: Record, + field: string, + kind: CloudArtifact['kind'], + name: string, +): CloudArtifact | undefined { + const url = details[field]; + if (typeof url !== 'string' || url.length === 0) return undefined; + return { provider, providerSessionId, kind, name, url, availability: 'ready' }; +} + +function trimTrailingSlash(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1; + return value.slice(0, end); +} + +function readBrowserStackAppUrl(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const appUrl = (value as { app_url?: unknown }).app_url; + return typeof appUrl === 'string' ? appUrl : undefined; +} diff --git a/src/cloud-webdriver/capabilities.ts b/src/cloud-webdriver/capabilities.ts new file mode 100644 index 000000000..9fa8b9930 --- /dev/null +++ b/src/cloud-webdriver/capabilities.ts @@ -0,0 +1,168 @@ +import type { SnapshotResult } from '../core/interactor-types.ts'; +import type { CloudWebDriverPlatform } from './runtime.ts'; + +export type CloudWebDriverOperation = + | 'lease' + | 'inventory' + | 'install' + | 'open' + | 'close' + | 'snapshot' + | 'screenshot' + | 'tap' + | 'doubleTap' + | 'longPress' + | 'swipe' + | 'scroll' + | 'fill' + | 'type' + | 'back' + | 'home' + | 'rotate' + | 'appSwitcher' + | 'clipboard.read' + | 'clipboard.write' + | 'settings' + | 'pinch' + | 'rotateGesture' + | 'transformGesture' + | 'logs' + | 'record' + | 'artifacts' + | 'portReverse' + | 'nativeSnapshotBackend'; + +export type CloudWebDriverSupportLevel = 'supported' | 'partial' | 'unsupported'; + +export type CloudWebDriverOperationCapability = { + support: CloudWebDriverSupportLevel; + note?: string; +}; + +export type CloudWebDriverCapabilityMap = Record< + CloudWebDriverOperation, + CloudWebDriverOperationCapability +>; + +export type CloudWebDriverProviderCapabilities = { + provider: string; + platform: CloudWebDriverPlatform; + snapshotBackend: Extract; + snapshotSource: 'appium-page-source'; + operations: CloudWebDriverCapabilityMap; +}; + +export type CloudWebDriverCapabilityOverrides = Partial< + Record +>; + +const supported: CloudWebDriverOperationCapability = { support: 'supported' }; +const unsupported: CloudWebDriverOperationCapability = { support: 'unsupported' }; + +const BASE_WEBDRIVER_CAPABILITIES: CloudWebDriverCapabilityMap = { + lease: supported, + inventory: { + support: 'partial', + note: 'Inventory exposes only the leased cloud device, not the provider catalog.', + }, + install: { + support: 'partial', + note: 'Requires provider-specific upload or a path visible to the remote Appium server.', + }, + open: supported, + close: supported, + snapshot: { + support: 'partial', + note: 'Uses Appium page source XML, not agent-device native snapshot backends.', + }, + screenshot: supported, + tap: supported, + doubleTap: supported, + longPress: supported, + swipe: supported, + scroll: { + support: 'partial', + note: 'Implemented as viewport-relative W3C pointer gestures.', + }, + fill: supported, + type: supported, + back: supported, + home: { + support: 'partial', + note: 'Uses provider/Appium mobile pressButton support where available.', + }, + rotate: { + support: 'partial', + note: 'Uses provider/Appium mobile rotate support where available.', + }, + appSwitcher: { + support: 'partial', + note: 'Uses provider/Appium mobile pressButton support where available.', + }, + 'clipboard.read': { + support: 'partial', + note: 'Uses provider/Appium clipboard extension support where available.', + }, + 'clipboard.write': { + support: 'partial', + note: 'Uses provider/Appium clipboard extension support where available.', + }, + settings: unsupported, + pinch: unsupported, + rotateGesture: unsupported, + transformGesture: unsupported, + logs: unsupported, + record: unsupported, + artifacts: unsupported, + portReverse: unsupported, + nativeSnapshotBackend: { + support: 'unsupported', + note: 'Cloud WebDriver cannot upload or run agent-device native runner/helper backends.', + }, +}; + +export function createCloudWebDriverCapabilities(options: { + provider: string; + platform: CloudWebDriverPlatform; + overrides?: CloudWebDriverCapabilityOverrides; +}): CloudWebDriverProviderCapabilities { + return { + provider: options.provider, + platform: options.platform, + snapshotBackend: options.platform === 'ios' ? 'xctest' : 'android', + snapshotSource: 'appium-page-source', + operations: applyCapabilityOverrides(BASE_WEBDRIVER_CAPABILITIES, options.overrides), + }; +} + +export function capabilitySupported( + capabilities: CloudWebDriverProviderCapabilities, + operation: CloudWebDriverOperation, +): boolean { + return capabilities.operations[operation].support !== 'unsupported'; +} + +export function unsupportedCapabilityMessage( + capabilities: CloudWebDriverProviderCapabilities, + operation: CloudWebDriverOperation, +): string { + const capability = capabilities.operations[operation]; + const note = capability.note ? ` ${capability.note}` : ''; + return `${capabilities.provider} WebDriver runtime does not support ${operation}.${note}`; +} + +function applyCapabilityOverrides( + base: CloudWebDriverCapabilityMap, + overrides: CloudWebDriverCapabilityOverrides | undefined, +): CloudWebDriverCapabilityMap { + const next = { ...base }; + for (const [operation, override] of Object.entries(overrides ?? {}) as Array< + [CloudWebDriverOperation, CloudWebDriverSupportLevel | CloudWebDriverOperationCapability] + >) { + next[operation] = + typeof override === 'string' + ? { ...base[operation], support: override } + : { ...base[operation], ...override }; + } + return next; +} diff --git a/src/cloud-webdriver/providers.ts b/src/cloud-webdriver/providers.ts new file mode 100644 index 000000000..7fb9c493e --- /dev/null +++ b/src/cloud-webdriver/providers.ts @@ -0,0 +1,7 @@ +export const CLOUD_WEBDRIVER_PROVIDERS = { + browserStack: 'browserstack', + awsDeviceFarm: 'aws-device-farm', +} as const; + +export type CloudWebDriverKnownProviderName = + (typeof CLOUD_WEBDRIVER_PROVIDERS)[keyof typeof CLOUD_WEBDRIVER_PROVIDERS]; diff --git a/src/cloud-webdriver/runtime-helpers.ts b/src/cloud-webdriver/runtime-helpers.ts new file mode 100644 index 000000000..fe119bffd --- /dev/null +++ b/src/cloud-webdriver/runtime-helpers.ts @@ -0,0 +1,30 @@ +import type { SnapshotResult } from '../core/interactor-types.ts'; +import type { + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, +} from '../provider-device-runtime.ts'; +import type { CloudWebDriverPlatform, CloudWebDriverUploadResult } from './runtime.ts'; + +export function providerInstallResult( + upload: CloudWebDriverUploadResult | undefined, + options: ProviderDeviceInstallOptions | undefined, +): ProviderDeviceInstallResult { + const bundleId = upload?.bundleId ?? options?.appIdentifierHint; + const packageName = upload?.packageName ?? options?.packageNameHint; + return { + bundleId, + packageName, + appName: upload?.appName, + launchTarget: firstDefined(upload?.launchTarget, bundleId, packageName), + }; +} + +export function snapshotBackendForPlatform( + platform: CloudWebDriverPlatform, +): Extract { + return platform === 'ios' ? 'xctest' : 'android'; +} + +function firstDefined(...values: Array): T | undefined { + return values.find((value) => value !== undefined); +} diff --git a/src/cloud-webdriver/runtime.ts b/src/cloud-webdriver/runtime.ts new file mode 100644 index 000000000..d95c36607 --- /dev/null +++ b/src/cloud-webdriver/runtime.ts @@ -0,0 +1,373 @@ +import type { + CloudArtifactProvider, + CloudArtifactsQuery, + CloudArtifactsResult, +} from '../cloud-artifacts.ts'; +import type { DeviceInventoryProvider } from '../core/dispatch-resolve.ts'; +import type { Interactor } from '../core/interactor-types.ts'; +import type { LeaseLifecycleProvider } from '../daemon/handlers/lease.ts'; +import type { DeviceLease } from '../daemon/lease-registry.ts'; +import type { + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, + ProviderDeviceRuntime, +} from '../provider-device-runtime.ts'; +import type { DeviceInfo, Platform } from '../kernel/device.ts'; +import { AppError } from '../kernel/errors.ts'; +import { + capabilitySupported, + createCloudWebDriverCapabilities, + unsupportedCapabilityMessage, + type CloudWebDriverCapabilityOverrides, + type CloudWebDriverProviderCapabilities, +} from './capabilities.ts'; +import { providerInstallResult, snapshotBackendForPlatform } from './runtime-helpers.ts'; +import { + WebDriverClient, + type WebDriverAuth, + type WebDriverRequestPolicy, +} from './webdriver-client.ts'; +import { createWebDriverInteractor } from './webdriver-interactor.ts'; + +export type CloudWebDriverPlatform = Extract; + +export type CloudWebDriverUploadResult = ProviderDeviceInstallResult & { + appReference: string; +}; + +export type CloudWebDriverUploadApp = (params: { + provider: string; + lease: DeviceLease; + device: DeviceInfo; + app: string; + appPath: string; + options?: ProviderDeviceInstallOptions; +}) => Promise; + +export type CloudWebDriverBaseSession = { + provider: string; + endpoint: string | URL; + platform: CloudWebDriverPlatform; + deviceName: string; + webdriverCapabilities: Record; + auth?: WebDriverAuth; + headers?: Record; +}; + +export type CloudWebDriverPreparedSession = CloudWebDriverBaseSession & { + deviceId?: string; + providerSessionId?: string; + uploadApp?: CloudWebDriverUploadApp; + listArtifacts?: CloudWebDriverListArtifacts; + cleanup?: () => Promise | undefined>; + providerData?: Record; +}; + +export type CloudWebDriverListArtifacts = (params: { + provider: string; + phase: 'active' | 'released' | 'lookup'; + lease?: DeviceLease; + device?: DeviceInfo; + webDriverSessionId?: string; + providerSessionId?: string; +}) => Promise; + +export type CloudWebDriverPrepareSession = (params: { + lease: DeviceLease; + base: CloudWebDriverBaseSession; +}) => Promise; + +export type CloudWebDriverRuntimeOptions = { + provider: string; + endpoint: string | URL; + platform: CloudWebDriverPlatform; + deviceName: string; + webdriverCapabilities?: + | Record + | ((lease: DeviceLease) => Record); + auth?: WebDriverAuth; + headers?: Record; + requestPolicy?: WebDriverRequestPolicy; + uploadApp?: CloudWebDriverUploadApp; + listArtifacts?: CloudWebDriverListArtifacts; + deviceId?: (lease: DeviceLease) => string; + prepareSession?: CloudWebDriverPrepareSession; + capabilityOverrides?: CloudWebDriverCapabilityOverrides; +}; + +type WebDriverProviderSession = { + lease: DeviceLease; + device: DeviceInfo; + client: WebDriverClient; + interactor: Interactor; + prepared: CloudWebDriverPreparedSession; + webDriverSessionId: string; + providerSessionId: string; +}; + +export function createCloudWebDriverRuntime( + options: CloudWebDriverRuntimeOptions, +): ProviderDeviceRuntime { + return new CloudWebDriverRuntime(options); +} + +class CloudWebDriverRuntime implements ProviderDeviceRuntime { + readonly provider: string; + readonly leaseLifecycle: LeaseLifecycleProvider; + readonly cloudArtifacts: CloudArtifactProvider; + readonly deviceInventoryProvider: DeviceInventoryProvider; + readonly capabilities: CloudWebDriverProviderCapabilities; + + private readonly sessionsByLeaseId = new Map(); + private readonly options: CloudWebDriverRuntimeOptions; + + constructor(options: CloudWebDriverRuntimeOptions) { + this.options = options; + this.provider = options.provider; + this.capabilities = createCloudWebDriverCapabilities({ + provider: options.provider, + platform: options.platform, + overrides: options.capabilityOverrides, + }); + this.leaseLifecycle = { + allocate: async (lease) => await this.allocate(lease), + heartbeat: async (lease) => this.heartbeat(lease), + release: async (lease) => await this.release(lease), + }; + this.cloudArtifacts = { + listCloudArtifacts: async (query) => await this.listCloudArtifacts(query), + }; + this.deviceInventoryProvider = async (request) => { + if (request.leaseProvider !== this.provider) return null; + if (!request.leaseId) return []; + const session = this.sessionsByLeaseId.get(request.leaseId); + return session ? [session.device] : []; + }; + } + + ownsDevice(device: DeviceInfo): boolean { + return [...this.sessionsByLeaseId.values()].some((session) => session.device.id === device.id); + } + + getInteractor(device: DeviceInfo): Interactor | undefined { + return [...this.sessionsByLeaseId.values()].find((session) => session.device.id === device.id) + ?.interactor; + } + + async installApp( + device: DeviceInfo, + app: string, + appPath: string, + installOptions?: ProviderDeviceInstallOptions, + ): Promise { + const session = this.findSessionForDevice(device); + if (!session) return undefined; + const upload = await this.uploadAppIfNeeded(session, device, app, appPath, installOptions); + await session.client.installApp(upload?.appReference ?? appPath); + return providerInstallResult(upload, installOptions); + } + + async installInstallablePath( + device: DeviceInfo, + installablePath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + return await this.installApp(device, '', installablePath, options); + } + + async shutdown(): Promise { + await Promise.allSettled( + [...this.sessionsByLeaseId.values()].map(async (session) => await this.closeSession(session)), + ); + this.sessionsByLeaseId.clear(); + } + + private async allocate(lease: DeviceLease): Promise | undefined> { + if (lease.leaseProvider !== this.provider) return undefined; + const prepared = await this.prepareSession(lease); + const client = new WebDriverClient({ + endpoint: prepared.endpoint, + auth: prepared.auth, + headers: prepared.headers, + requestPolicy: this.options.requestPolicy, + }); + const session = await this.createSessionWithPreparedCleanup(client, prepared); + const device = this.deviceForLease(lease, prepared); + const providerSessionId = prepared.providerSessionId ?? session.sessionId; + this.sessionsByLeaseId.set(lease.leaseId, { + lease, + device, + client, + prepared, + webDriverSessionId: session.sessionId, + providerSessionId, + interactor: createWebDriverInteractor({ + client, + backend: snapshotBackendForPlatform(prepared.platform), + capabilities: this.capabilities, + }), + }); + return { + provider: this.provider, + deviceId: device.id, + sessionId: session.sessionId, + providerSessionId, + capabilities: this.capabilities, + ...prepared.providerData, + }; + } + + private async createSessionWithPreparedCleanup( + client: WebDriverClient, + prepared: CloudWebDriverPreparedSession, + ): Promise>> { + try { + return await client.createSession(prepared.webdriverCapabilities); + } catch (error) { + await prepared.cleanup?.(); + throw error; + } + } + + private heartbeat(lease: DeviceLease): Record | undefined { + if (lease.leaseProvider !== this.provider) return undefined; + if (!this.sessionsByLeaseId.has(lease.leaseId)) return undefined; + return { provider: this.provider }; + } + + private async release(lease: DeviceLease): Promise | undefined> { + if (lease.leaseProvider !== this.provider) return undefined; + const session = this.sessionsByLeaseId.get(lease.leaseId); + if (!session) return undefined; + this.sessionsByLeaseId.delete(lease.leaseId); + const cleanup = await this.closeSession(session); + const artifacts = await this.safeListArtifacts(session, 'released'); + return { + provider: this.provider, + providerSessionId: session.providerSessionId, + ...cleanup, + ...(artifacts ? { cloudArtifacts: artifacts } : {}), + }; + } + + private async listCloudArtifacts( + query: CloudArtifactsQuery, + ): Promise { + if (query.provider !== this.provider) return undefined; + const session = query.leaseId ? this.sessionsByLeaseId.get(query.leaseId) : undefined; + if (session) return await this.safeListArtifacts(session, 'active'); + if (!query.providerSessionId || !this.options.listArtifacts) return undefined; + return await this.options.listArtifacts({ + provider: this.provider, + phase: 'lookup', + providerSessionId: query.providerSessionId, + }); + } + + private async prepareSession(lease: DeviceLease): Promise { + const base = this.baseSessionForLease(lease); + return this.options.prepareSession ? await this.options.prepareSession({ lease, base }) : base; + } + + private baseSessionForLease(lease: DeviceLease): CloudWebDriverBaseSession { + const configured = + typeof this.options.webdriverCapabilities === 'function' + ? this.options.webdriverCapabilities(lease) + : (this.options.webdriverCapabilities ?? {}); + return { + provider: this.provider, + endpoint: this.options.endpoint, + platform: this.options.platform, + deviceName: this.options.deviceName, + auth: this.options.auth, + headers: this.options.headers, + webdriverCapabilities: { + platformName: this.options.platform === 'ios' ? 'iOS' : 'Android', + 'appium:deviceName': this.options.deviceName, + ...configured, + }, + }; + } + + private deviceForLease(lease: DeviceLease, prepared: CloudWebDriverPreparedSession): DeviceInfo { + return { + platform: prepared.platform, + id: + prepared.deviceId ?? + this.options.deviceId?.(lease) ?? + `${this.provider}:${prepared.platform}:${lease.leaseId}`, + name: prepared.deviceName, + kind: 'device', + target: 'mobile', + booted: true, + }; + } + + private findSessionForDevice(device: DeviceInfo): WebDriverProviderSession | undefined { + return [...this.sessionsByLeaseId.values()].find((session) => session.device.id === device.id); + } + + private async uploadAppIfNeeded( + session: WebDriverProviderSession, + device: DeviceInfo, + app: string, + appPath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + if (!capabilitySupported(this.capabilities, 'install')) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + unsupportedCapabilityMessage(this.capabilities, 'install'), + { provider: this.provider, deviceId: device.id, platform: device.platform }, + ); + } + const uploadApp = session.prepared.uploadApp ?? this.options.uploadApp; + if (!uploadApp) return undefined; + return await uploadApp({ + provider: this.provider, + lease: session.lease, + device, + app, + appPath, + options, + }); + } + + private async closeSession( + session: WebDriverProviderSession, + ): Promise | undefined> { + let cleanupResult: Record | undefined; + try { + await session.client.deleteSession(); + } finally { + cleanupResult = await session.prepared.cleanup?.(); + } + return cleanupResult; + } + + private async safeListArtifacts( + session: WebDriverProviderSession, + phase: 'active' | 'released', + ): Promise { + const listArtifacts = session.prepared.listArtifacts ?? this.options.listArtifacts; + if (!listArtifacts) return undefined; + try { + return await listArtifacts({ + provider: this.provider, + phase, + lease: session.lease, + device: session.device, + webDriverSessionId: session.webDriverSessionId, + providerSessionId: session.providerSessionId, + }); + } catch (error) { + return { + provider: this.provider, + providerSessionId: session.providerSessionId, + status: 'unavailable', + cloudArtifacts: [], + message: error instanceof Error ? error.message : String(error), + }; + } + } +} diff --git a/src/cloud-webdriver/webdriver-client.ts b/src/cloud-webdriver/webdriver-client.ts new file mode 100644 index 000000000..ba82a5782 --- /dev/null +++ b/src/cloud-webdriver/webdriver-client.ts @@ -0,0 +1,275 @@ +import fs from 'node:fs/promises'; +import { AppError } from '../kernel/errors.ts'; + +export type WebDriverAuth = { + username: string; + accessKey: string; +}; + +export type WebDriverClientOptions = { + endpoint: string | URL; + auth?: WebDriverAuth; + headers?: Record; + requestPolicy?: WebDriverRequestPolicy; +}; + +export type WebDriverRequestPolicy = { + timeoutMs?: number; + retryAttempts?: number; + retryDelayMs?: number; +}; + +export type WebDriverSession = { + sessionId: string; + capabilities: Record; +}; + +export type W3CActionSequence = { + type: 'pointer' | 'key' | 'wheel'; + id: string; + parameters?: Record; + actions: Record[]; +}; + +type WebDriverResponse = { + value?: unknown; + sessionId?: string; +}; + +type WebDriverRequestOverrides = { + retryAttempts?: number; +}; + +export class WebDriverClient { + private readonly endpoint: URL; + private readonly headers: Record; + private readonly requestPolicy: Required; + private sessionId: string | undefined; + + constructor(options: WebDriverClientOptions) { + this.endpoint = withTrailingSlash(new URL(options.endpoint)); + this.headers = { + ...(options.auth ? { Authorization: basicAuth(options.auth) } : {}), + ...options.headers, + }; + this.requestPolicy = { + timeoutMs: options.requestPolicy?.timeoutMs ?? 30_000, + retryAttempts: options.requestPolicy?.retryAttempts ?? 1, + retryDelayMs: options.requestPolicy?.retryDelayMs ?? 250, + }; + } + + async createSession(capabilities: Record): Promise { + const value = await this.requestValue('POST', '/session', { + capabilities: normalizeCapabilities(capabilities), + }); + const session = readSession(value); + this.sessionId = session.sessionId; + return session; + } + + // fallow-ignore-next-line unused-class-member + async deleteSession(): Promise { + const sessionId = this.requireSessionId(); + await this.requestValue('DELETE', `/session/${sessionId}`); + this.sessionId = undefined; + } + + // fallow-ignore-next-line unused-class-member + async installApp(appPath: string): Promise { + await this.sessionRequest('POST', '/appium/device/install_app', { appPath }); + } + + async activateApp(appId: string): Promise { + await this.executeScript('mobile: activateApp', [{ appId, bundleId: appId }]); + } + + async terminateApp(appId: string): Promise { + await this.executeScript('mobile: terminateApp', [{ appId, bundleId: appId }]); + } + + async performActions(actions: W3CActionSequence[]): Promise { + await this.sessionRequest('POST', '/actions', { actions }); + } + + async releaseActions(): Promise { + await this.sessionRequest('DELETE', '/actions', undefined, { retryAttempts: 0 }); + } + + async sendKeys(text: string): Promise { + await this.sessionRequest('POST', '/keys', { + text, + value: Array.from(text), + }); + } + + async back(): Promise { + await this.sessionRequest('POST', '/back'); + } + + async source(): Promise { + const value = await this.sessionRequest('GET', '/source'); + if (typeof value !== 'string') { + throw new AppError('COMMAND_FAILED', 'WebDriver source response was not a string', { + valueType: typeof value, + }); + } + return value; + } + + async screenshot(outPath: string): Promise { + const value = await this.sessionRequest('GET', '/screenshot'); + if (typeof value !== 'string') { + throw new AppError('COMMAND_FAILED', 'WebDriver screenshot response was not base64 text', { + valueType: typeof value, + }); + } + await fs.writeFile(outPath, Buffer.from(value, 'base64')); + } + + async executeScript(script: string, args: unknown[] = []): Promise { + return await this.sessionRequest('POST', '/execute/sync', { script, args }); + } + + private async sessionRequest( + method: string, + pathSuffix: string, + body?: unknown, + overrides?: WebDriverRequestOverrides, + ): Promise { + const sessionId = this.requireSessionId(); + return await this.requestValue(method, `/session/${sessionId}${pathSuffix}`, body, overrides); + } + + private requireSessionId(): string { + if (!this.sessionId) { + throw new AppError('SESSION_NOT_FOUND', 'WebDriver session has not been created yet.'); + } + return this.sessionId; + } + + private async requestValue( + method: string, + path: string, + body?: unknown, + overrides?: WebDriverRequestOverrides, + ): Promise { + let lastError: unknown; + const retryAttempts = overrides?.retryAttempts ?? this.requestPolicy.retryAttempts; + for (let attempt = 0; attempt <= retryAttempts; attempt += 1) { + try { + return await this.requestValueOnce(method, path, body); + } catch (error) { + lastError = error; + if (!isRetriableWebDriverError(error) || attempt >= retryAttempts) { + throw error; + } + await delay(this.requestPolicy.retryDelayMs); + } + } + throw lastError; + } + + private async requestValueOnce(method: string, path: string, body?: unknown): Promise { + const response = await fetch(new URL(trimLeadingSlash(path), this.endpoint), { + method, + headers: { + Accept: 'application/json', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...this.headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(this.requestPolicy.timeoutMs), + }); + const text = await response.text(); + const payload = text ? parseJsonResponse(text) : {}; + if (!response.ok) { + throw webdriverError(response.status, payload); + } + return readWebDriverValue(payload); + } +} + +function normalizeCapabilities(capabilities: Record): Record { + if ('alwaysMatch' in capabilities || 'firstMatch' in capabilities) return capabilities; + return { alwaysMatch: capabilities }; +} + +function readSession(value: unknown): WebDriverSession { + if (!value || typeof value !== 'object') { + throw new AppError('COMMAND_FAILED', 'WebDriver create-session response was empty.'); + } + const record = value as Record; + const sessionId = + typeof record.sessionId === 'string' + ? record.sessionId + : typeof record.session_id === 'string' + ? record.session_id + : undefined; + if (!sessionId) { + throw new AppError('COMMAND_FAILED', 'WebDriver create-session response missed sessionId.', { + response: record, + }); + } + const capabilities = + record.capabilities && typeof record.capabilities === 'object' + ? (record.capabilities as Record) + : {}; + return { sessionId, capabilities }; +} + +function readWebDriverValue(payload: unknown): unknown { + if (!payload || typeof payload !== 'object') return payload; + const response = payload as WebDriverResponse; + if ('value' in response) return response.value; + return payload; +} + +function parseJsonResponse(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch (error) { + throw new AppError('COMMAND_FAILED', 'WebDriver response was not valid JSON.', { text }, error); + } +} + +function webdriverError(status: number, payload: unknown): AppError { + const value = + payload && typeof payload === 'object' && 'value' in payload + ? (payload as { value?: unknown }).value + : payload; + const message = + value && + typeof value === 'object' && + typeof (value as { message?: unknown }).message === 'string' + ? (value as { message: string }).message + : `WebDriver request failed with HTTP ${status}.`; + return new AppError('COMMAND_FAILED', message, { status, response: payload }); +} + +function isRetriableWebDriverError(error: unknown): boolean { + if (error instanceof AppError) { + const status = error.details?.status; + return typeof status === 'number' && status >= 500; + } + return error instanceof TypeError || (error instanceof Error && error.name === 'TimeoutError'); +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function trimLeadingSlash(path: string): string { + return path.replace(/^\/+/, ''); +} + +function withTrailingSlash(url: URL): URL { + if (url.pathname.endsWith('/')) return url; + const copy = new URL(url); + copy.pathname = `${copy.pathname}/`; + return copy; +} + +function basicAuth(auth: WebDriverAuth): string { + return `Basic ${Buffer.from(`${auth.username}:${auth.accessKey}`).toString('base64')}`; +} diff --git a/src/cloud-webdriver/webdriver-gestures.ts b/src/cloud-webdriver/webdriver-gestures.ts new file mode 100644 index 000000000..e9ce11ae2 --- /dev/null +++ b/src/cloud-webdriver/webdriver-gestures.ts @@ -0,0 +1,44 @@ +import { centerOfRect } from '../kernel/snapshot.ts'; +import type { ScrollDirection } from '../core/scroll-gesture.ts'; +import type { W3CActionSequence } from './webdriver-client.ts'; + +export function touchPointer(name: string, actions: Record[]): W3CActionSequence { + return { + type: 'pointer', + id: name, + parameters: { pointerType: 'touch' }, + actions, + }; +} + +export function scrollStart( + direction: ScrollDirection, + distance: number, +): { x: number; y: number } { + const rect = { x: 0, y: 0, width: 400, height: 800 }; + const center = centerOfRect(rect); + switch (direction) { + case 'up': + return { x: center.x, y: center.y + Math.round(distance / 2) }; + case 'down': + return { x: center.x, y: center.y - Math.round(distance / 2) }; + case 'left': + return { x: center.x + Math.round(distance / 2), y: center.y }; + case 'right': + return { x: center.x - Math.round(distance / 2), y: center.y }; + } +} + +export function scrollEnd(direction: ScrollDirection, distance: number): { x: number; y: number } { + const start = scrollStart(direction, distance); + switch (direction) { + case 'up': + return { ...start, y: start.y - distance }; + case 'down': + return { ...start, y: start.y + distance }; + case 'left': + return { ...start, x: start.x - distance }; + case 'right': + return { ...start, x: start.x + distance }; + } +} diff --git a/src/cloud-webdriver/webdriver-interactor.ts b/src/cloud-webdriver/webdriver-interactor.ts new file mode 100644 index 000000000..1fbdc496e --- /dev/null +++ b/src/cloud-webdriver/webdriver-interactor.ts @@ -0,0 +1,278 @@ +import type { + Interactor, + ScreenshotOptions, + SnapshotOptions, + SnapshotResult, +} from '../core/interactor-types.ts'; +import type { BackMode } from '../core/back-mode.ts'; +import type { DeviceRotation } from '../core/device-rotation.ts'; +import type { ScrollDirection, TransformGestureParams } from '../core/scroll-gesture.ts'; +import type { SettingOptions } from '../platforms/permission-utils.ts'; +import { AppError } from '../kernel/errors.ts'; +import { + capabilitySupported, + unsupportedCapabilityMessage, + type CloudWebDriverOperation, + type CloudWebDriverProviderCapabilities, +} from './capabilities.ts'; +import { scrollEnd, scrollStart, touchPointer } from './webdriver-gestures.ts'; +import type { WebDriverClient } from './webdriver-client.ts'; +import { parseWebDriverSource } from './webdriver-source.ts'; + +export type WebDriverInteractorOptions = { + client: WebDriverClient; + backend: Extract; + capabilities: CloudWebDriverProviderCapabilities; +}; + +export function createWebDriverInteractor(options: WebDriverInteractorOptions): Interactor { + return new WebDriverInteractor(options.client, options.backend, options.capabilities); +} + +class WebDriverInteractor implements Interactor { + private readonly client: WebDriverClient; + private readonly backend: Extract; + private readonly capabilities: CloudWebDriverProviderCapabilities; + + constructor( + client: WebDriverClient, + backend: Extract, + capabilities: CloudWebDriverProviderCapabilities, + ) { + this.client = client; + this.backend = backend; + this.capabilities = capabilities; + } + + async open( + app: string, + options?: { + activity?: string; + appBundleId?: string; + launchConsole?: string; + launchArgs?: string[]; + url?: string; + }, + ): Promise { + this.requireSupport('open'); + if (options?.url) { + await this.client.executeScript('mobile: deepLink', [{ url: options.url, package: app }]); + return; + } + const appId = options?.appBundleId ?? app; + if (!appId) return; + await this.client.activateApp(appId); + } + + async openDevice(): Promise { + this.requireSupport('open'); + await this.client.executeScript('mobile: activateApp', [{}]); + } + + async close(app: string): Promise { + this.requireSupport('close'); + if (!app) return; + await this.client.terminateApp(app); + } + + async tap(x: number, y: number): Promise> { + this.requireSupport('tap'); + await this.pointerGesture('tap', [ + { type: 'pointerMove', duration: 0, x, y }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + ]); + return { backend: 'webdriver', x, y }; + } + + async doubleTap(x: number, y: number): Promise> { + this.requireSupport('doubleTap'); + await this.pointerGesture('doubleTap', [ + { type: 'pointerMove', duration: 0, x, y }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + { type: 'pause', duration: 80 }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + ]); + return { backend: 'webdriver', x, y }; + } + + async swipe( + x1: number, + y1: number, + x2: number, + y2: number, + durationMs = 400, + ): Promise> { + this.requireSupport('swipe'); + await this.pointerGesture('swipe', [ + { type: 'pointerMove', duration: 0, x: x1, y: y1 }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerMove', duration: durationMs, x: x2, y: y2 }, + { type: 'pointerUp', button: 0 }, + ]); + return { backend: 'webdriver', x1, y1, x2, y2, durationMs }; + } + + async pan( + x1: number, + y1: number, + x2: number, + y2: number, + durationMs?: number, + ): Promise> { + return await this.swipe(x1, y1, x2, y2, durationMs); + } + + async fling( + x1: number, + y1: number, + x2: number, + y2: number, + durationMs = 150, + ): Promise> { + return await this.swipe(x1, y1, x2, y2, durationMs); + } + + async longPress(x: number, y: number, durationMs = 600): Promise> { + this.requireSupport('longPress'); + await this.pointerGesture('longPress', [ + { type: 'pointerMove', duration: 0, x, y }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: durationMs }, + { type: 'pointerUp', button: 0 }, + ]); + return { backend: 'webdriver', x, y, durationMs }; + } + + async focus(x: number, y: number): Promise> { + return await this.tap(x, y); + } + + async type(text: string): Promise { + this.requireSupport('type'); + await this.client.sendKeys(text); + } + + async fill( + x: number, + y: number, + text: string, + _delayMs?: number, + ): Promise> { + this.requireSupport('fill'); + await this.tap(x, y); + await this.type(text); + return { backend: 'webdriver', x, y, text }; + } + + async scroll( + direction: ScrollDirection, + options?: { amount?: number; pixels?: number; durationMs?: number }, + ): Promise> { + this.requireSupport('scroll'); + const distance = options?.pixels ?? options?.amount ?? 300; + const durationMs = options?.durationMs ?? 350; + const start = scrollStart(direction, distance); + const end = scrollEnd(direction, distance); + await this.swipe(start.x, start.y, end.x, end.y, durationMs); + return { backend: 'webdriver', direction, distance, durationMs }; + } + + async pinch(_scale: number, _x?: number, _y?: number): Promise | void> { + this.unsupported('pinch'); + } + + async screenshot(outPath: string, _options?: ScreenshotOptions): Promise { + this.requireSupport('screenshot'); + await this.client.screenshot(outPath); + } + + async snapshot(_options?: SnapshotOptions): Promise { + this.requireSupport('snapshot'); + return { + backend: this.backend, + nodes: parseWebDriverSource(await this.client.source()), + }; + } + + async back(_mode?: BackMode): Promise { + this.requireSupport('back'); + await this.client.back(); + } + + async home(): Promise { + this.requireSupport('home'); + await this.client.executeScript('mobile: pressButton', [{ name: 'home' }]); + } + + async rotate(orientation: DeviceRotation): Promise { + this.requireSupport('rotate'); + await this.client.executeScript('mobile: rotate', [{ orientation }]); + } + + async rotateGesture( + _degrees: number, + _x?: number, + _y?: number, + _velocity?: number, + ): Promise | void> { + this.unsupported('rotateGesture'); + } + + async transformGesture( + _options: TransformGestureParams, + ): Promise | void> { + this.unsupported('transformGesture'); + } + + async appSwitcher(): Promise { + this.requireSupport('appSwitcher'); + await this.client.executeScript('mobile: pressButton', [{ name: 'appSwitch' }]); + } + + async readClipboard(): Promise { + this.requireSupport('clipboard.read'); + const value = await this.client.executeScript('mobile: getClipboard', [{}]); + return typeof value === 'string' ? value : ''; + } + + async writeClipboard(text: string): Promise { + this.requireSupport('clipboard.write'); + await this.client.executeScript('mobile: setClipboard', [{ content: text }]); + } + + async setSetting( + _setting: string, + _state: string, + _appId?: string, + _options?: SettingOptions, + ): Promise | void> { + this.unsupported('settings'); + } + + private async pointerGesture(name: string, actions: Record[]): Promise { + await this.client.performActions([touchPointer(name, actions)]); + // Some Appium grids accept W3C actions but reject DELETE /actions. A failed + // best-effort input-state reset should not make the completed gesture fail. + await this.client.releaseActions().catch(() => undefined); + } + + private requireSupport(operation: CloudWebDriverOperation): void { + if (capabilitySupported(this.capabilities, operation)) return; + this.unsupported(operation); + } + + private unsupported(operation: CloudWebDriverOperation): never { + throw new AppError( + 'UNSUPPORTED_OPERATION', + unsupportedCapabilityMessage(this.capabilities, operation), + { + provider: this.capabilities.provider, + platform: this.capabilities.platform, + operation, + }, + ); + } +} diff --git a/src/cloud-webdriver/webdriver-source.ts b/src/cloud-webdriver/webdriver-source.ts new file mode 100644 index 000000000..d12a26fc3 --- /dev/null +++ b/src/cloud-webdriver/webdriver-source.ts @@ -0,0 +1,132 @@ +import type { RawSnapshotNode } from '../kernel/snapshot.ts'; + +export function parseWebDriverSource(source: string): RawSnapshotNode[] { + const nodes: RawSnapshotNode[] = []; + const stack: number[] = []; + const tagPattern = /<\s*(\/?)([A-Za-z][\w:.-]*)([^>]*?)(\/?)\s*>/g; + let match: RegExpExecArray | null; + while ((match = tagPattern.exec(source))) { + const closing = match[1] === '/'; + const tagName = match[2] ?? 'Element'; + const attributes = match[3] ?? ''; + if (closing) { + stack.pop(); + continue; + } + const attrs = parseXmlAttributes(attributes); + const selfClosing = match[4] === '/' || attributes.trimEnd().endsWith('/'); + if (Object.keys(attrs).length === 0) continue; + const parentIndex = stack.at(-1); + const node = sourceNodeFromAttributes( + nodes.length, + tagName, + attrs, + parentIndex, + parentIndex === undefined ? 0 : (nodes[parentIndex]?.depth ?? 0) + 1, + ); + nodes.push(node); + if (!selfClosing) stack.push(node.index); + } + return nodes; +} + +function sourceNodeFromAttributes( + index: number, + type: string, + attrs: Record, + parentIndex: number | undefined, + depth: number, +): RawSnapshotNode { + const rect = rectFromAttributes(attrs); + const enabled = booleanAttribute(attrs.enabled, true); + const visibleToUser = booleanAttribute(attrs.displayed ?? attrs.visible, true); + return { + index, + type, + role: roleFromType(type, attrs), + label: firstAttribute(attrs, ['content-desc', 'label', 'text', 'name']), + value: nonEmpty(attrs.value), + identifier: firstAttribute(attrs, ['resource-id', 'id', 'accessibility-id', 'name']), + rect, + enabled, + selected: booleanAttribute(attrs.selected), + focused: booleanAttribute(attrs.focused), + visibleToUser, + hittable: visibleToUser && enabled && rect !== undefined && rect.width > 0 && rect.height > 0, + depth, + parentIndex, + }; +} + +function parseXmlAttributes(input: string): Record { + const attrs: Record = {}; + const attrPattern = /([:\w.-]+)\s*=\s*"([^"]*)"/g; + let match: RegExpExecArray | null; + while ((match = attrPattern.exec(input))) { + attrs[match[1] ?? ''] = decodeXmlEntities(match[2] ?? ''); + } + return attrs; +} + +function rectFromAttributes(attrs: Record): RawSnapshotNode['rect'] | undefined { + const bounds = parseAndroidBounds(attrs.bounds); + if (bounds) return bounds; + const x = numberAttribute(attrs.x); + const y = numberAttribute(attrs.y); + const width = numberAttribute(attrs.width); + const height = numberAttribute(attrs.height); + if (x === undefined || y === undefined || width === undefined || height === undefined) { + return undefined; + } + return { x, y, width, height }; +} + +function parseAndroidBounds(value: string | undefined): RawSnapshotNode['rect'] | undefined { + if (!value) return undefined; + const match = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/.exec(value); + if (!match) return undefined; + const [x1, y1, x2, y2] = match.slice(1).map(Number); + if (x1 === undefined || y1 === undefined || x2 === undefined || y2 === undefined) { + return undefined; + } + return { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) }; +} + +function firstAttribute( + attrs: Record, + names: readonly string[], +): string | undefined { + for (const name of names) { + const value = nonEmpty(attrs[name]); + if (value !== undefined) return value; + } + return undefined; +} + +function nonEmpty(value: string | undefined): string | undefined { + return value ? value : undefined; +} + +function booleanAttribute(value: string | undefined, defaultValue = false): boolean { + if (value === undefined) return defaultValue; + return value === 'true' || value === '1'; +} + +function numberAttribute(value: string | undefined): number | undefined { + if (value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function roleFromType(type: string, attrs: Record): string | undefined { + return nonEmpty(attrs.class) ?? nonEmpty(type.replace(/^XCUIElementType/, '').toLowerCase()); +} + +function decodeXmlEntities(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 8d5ccf331..48f603af6 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -2,6 +2,7 @@ export const PUBLIC_COMMANDS = { alert: 'alert', appState: 'appstate', appSwitcher: 'app-switcher', + artifacts: 'artifacts', apps: 'apps', back: 'back', batch: 'batch', @@ -112,6 +113,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( LOCAL_CLI_COMMANDS.reactDevtools, LOCAL_CLI_COMMANDS.session, LOCAL_CLI_COMMANDS.web, + PUBLIC_COMMANDS.artifacts, PUBLIC_COMMANDS.appState, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.batch, diff --git a/src/commands/management/artifacts.ts b/src/commands/management/artifacts.ts new file mode 100644 index 000000000..d0f4625a0 --- /dev/null +++ b/src/commands/management/artifacts.ts @@ -0,0 +1,48 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { stringField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { commonInputFromFlags, direct } from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { defineCommandFacet } from '../family/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { managementCliOutputFormatters } from './output.ts'; + +const artifactsCommandMetadata = defineFieldCommandMetadata( + 'artifacts', + 'List cloud provider artifacts for an active or completed provider session.', + { + provider: stringField('Cloud provider name, for example browserstack or aws-device-farm.'), + providerSessionId: stringField('Cloud provider session id or ARN.'), + }, +); + +const artifactsCommandDefinition = defineExecutableCommand( + artifactsCommandMetadata, + (client, input) => client.sessions.artifacts(input), +); + +const artifactsCliSchema = { + summary: 'List cloud provider session artifacts', + usageOverride: 'artifacts [provider-session-id] --provider ', + positionalArgs: ['provider-session-id?'], + allowedFlags: ['provider', 'providerSessionId'], +} as const satisfies CommandSchemaOverride; + +const artifactsCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + provider: flags.provider, + providerSessionId: positionals[0] ?? flags.providerSessionId, +}); + +const artifactsDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.artifacts); + +export const artifactsCommandFacet = defineCommandFacet({ + name: 'artifacts', + metadata: artifactsCommandMetadata, + definition: artifactsCommandDefinition, + cliSchema: artifactsCliSchema, + cliReader: artifactsCliReader, + daemonWriter: artifactsDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.artifacts, +}); diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts index 1d63dae3c..e41c632d4 100644 --- a/src/commands/management/index.ts +++ b/src/commands/management/index.ts @@ -1,4 +1,5 @@ import { defineCommandFamilyFromFacets } from '../family/types.ts'; +import { artifactsCommandFacet } from './artifacts.ts'; import { appsCommandFacet, closeCommandFacet, openCommandFacet } from './app.ts'; import { deviceManagementCommandFacets } from './device.ts'; import { installManagementCommandFacets } from './install.ts'; @@ -11,6 +12,7 @@ export const managementCommandFamily = defineCommandFamilyFromFacets({ name: 'management', commands: [ ...deviceManagementCommandFacets, + artifactsCommandFacet, prepareCommandFacet, appsCommandFacet, sessionCommandFacet, diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index dfe7e176f..41941503b 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { openCliOutput } from './output.ts'; +import { managementCliOutputFormatters, openCliOutput } from './output.ts'; describe('openCliOutput', () => { test('prints session state directory on a second line', () => { @@ -18,3 +18,51 @@ describe('openCliOutput', () => { }); }); }); + +describe('artifactsCliOutput', () => { + test('prints ready artifact URLs and preserves JSON data', () => { + const output = managementCliOutputFormatters.artifacts({ + input: {}, + result: { + provider: 'browserstack', + providerSessionId: 'wd-1', + status: 'ready', + cloudArtifacts: [ + { + provider: 'browserstack', + providerSessionId: 'wd-1', + kind: 'video', + name: 'Session video', + url: 'https://provider.example/video.mp4', + availability: 'ready', + }, + ], + }, + }); + + expect(output.text).toBe('video: Session video ready https://provider.example/video.mp4'); + expect(output.data).toMatchObject({ + cloudArtifacts: [{ url: 'https://provider.example/video.mp4' }], + }); + }); + + test('prints exact retry command for pending provider sessions', () => { + const output = managementCliOutputFormatters.artifacts({ + input: {}, + result: { + provider: 'aws-device-farm', + providerSessionId: 'arn:aws:devicefarm:us-west-2:123:session/project/session/00000', + status: 'pending', + cloudArtifacts: [], + message: 'AWS Device Farm artifacts are not ready yet.', + }, + }); + + expect(output.text).toBe( + [ + 'AWS Device Farm artifacts are not ready yet.', + 'Retry: agent-device artifacts arn:aws:devicefarm:us-west-2:123:session/project/session/00000 --provider aws-device-farm --json', + ].join('\n'), + ); + }); +}); diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 618d8ab62..3cfb7022b 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -16,6 +16,7 @@ import type { CommandRequestResult, SessionCloseResult, } from '../../client/client-types.ts'; +import type { CloudArtifactsResult } from '../../cloud-artifacts.ts'; import { readCommandMessage } from '../../utils/success-text.ts'; import type { CliOutput } from '../command-contract.ts'; import { @@ -73,6 +74,19 @@ function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput return messageCliOutput(serializeCloseResult(result)); } +function artifactsCliOutput(result: CloudArtifactsResult): CliOutput { + const emptyText = [result.message ?? `No cloud artifacts available for ${result.provider}.`]; + const retryCommand = formatCloudArtifactsRetryCommand(result); + if (retryCommand) emptyText.push(`Retry: ${retryCommand}`); + return { + data: result, + text: + result.cloudArtifacts.length > 0 + ? result.cloudArtifacts.map(formatCloudArtifactLine).join('\n') + : emptyText.join('\n'), + }; +} + function deployCliOutput(result: AppDeployResult): CliOutput { return messageCliOutput(serializeDeployResult(result)); } @@ -111,6 +125,7 @@ export const managementCliOutputFormatters = { appsFilter: input.appsFilter as Parameters[0]['appsFilter'], }), session: resultOutput(sessionCliOutput), + artifacts: resultOutput(artifactsCliOutput), open: resultOutput(openCliOutput), close: resultOutput(closeCliOutput), install: resultOutput(deployCliOutput), @@ -126,3 +141,14 @@ function formatDeviceLine(device: AgentDeviceDevice): string { const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; return `${device.name} (${device.platform}${kind}${target})${booted}`; } + +function formatCloudArtifactLine(artifact: CloudArtifactsResult['cloudArtifacts'][number]): string { + const url = artifact.url ? ` ${artifact.url}` : ''; + const availability = artifact.availability ? ` ${artifact.availability}` : ''; + return `${artifact.kind}: ${artifact.name}${availability}${url}`; +} + +function formatCloudArtifactsRetryCommand(result: CloudArtifactsResult): string | undefined { + if (!result.providerSessionId) return undefined; + return `agent-device artifacts ${result.providerSessionId} --provider ${result.provider} --json`; +} diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index cf05288d1..443bb6128 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -30,6 +30,7 @@ const UNROUTED_PUBLIC_COMMANDS = new Set([ // or always-admitted commands, so the capability matrix has never covered them. const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.appState, + PUBLIC_COMMANDS.artifacts, PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.gesture, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index d08824438..85ea60f3d 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -107,6 +107,11 @@ const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, batchable: false, }, + { + name: PUBLIC_COMMANDS.artifacts, + daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, + batchable: false, + }, // -- session (route: session) -- { diff --git a/src/daemon-runtime.ts b/src/daemon-runtime.ts index faa1db94e..aaa3bd6f0 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -5,6 +5,7 @@ import { cleanupStaleAppLogProcesses } from './daemon/app-log-process.ts'; import { resolveDaemonPaths, resolveDaemonServerMode } from './daemon/config.ts'; import { createDaemonHttpServer } from './daemon/http-server.ts'; import { trackDownloadableArtifact } from './daemon/artifact-tracking.ts'; +import { createDefaultCloudArtifactProvider } from './default-cloud-artifact-provider.ts'; import { LeaseRegistry } from './daemon/lease-registry.ts'; import { createRequestHandler } from './daemon/request-router.ts'; import { teardownSessionResources } from './daemon/session-teardown.ts'; @@ -89,6 +90,7 @@ export async function startDaemonRuntime( token, sessionStore, leaseRegistry, + cloudArtifactProvider: createDefaultCloudArtifactProvider(env), trackDownloadableArtifact, }); diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index 0eea3d4af..ede428747 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -85,7 +85,7 @@ test('cleanupExpiredLeasedSession consumes expired lease and deletes the session expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); -test('releaseSessionLease releases with the stored session owner scope', () => { +test('releaseSessionLease releases with the stored session owner scope', async () => { const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', @@ -107,9 +107,16 @@ test('releaseSessionLease releases with the stored session owner scope', () => { }, }); - releaseSessionLease({ session, leaseRegistry }); + const provider = await releaseSessionLease({ + session, + leaseRegistry, + leaseLifecycleProvider: { + release: async (lease) => ({ provider: lease.leaseProvider }), + }, + }); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); + expect(provider).toEqual({ provider: 'proxy' }); }); test('resolveSessionLeaseForRequest prefers admitted lease and falls back to existing lease', () => { diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 0e1a77383..a5f49a1dd 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -62,6 +62,7 @@ test('catalog commands use generic routing only when intentionally passthrough o test('lease handler executes commands owned by the lease route', async () => { const leaseRegistry = new LeaseRegistry(); + const sessionStore = makeSessionStore('agent-device-lease-route-'); const allocated = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-a' }); const leaseCommands = [ @@ -83,6 +84,8 @@ test('lease handler executes commands owned by the lease route', async () => { }, positionals: [], }, + sessionName: 'catalog-test', + sessionStore, leaseRegistry, }); @@ -92,6 +95,7 @@ test('lease handler executes commands owned by the lease route', async () => { test('lease handler preserves device-aware lease fields', async () => { const leaseRegistry = new LeaseRegistry(); + const sessionStore = makeSessionStore('agent-device-lease-fields-'); const allocateResponse = await handleLeaseCommands({ req: { command: INTERNAL_COMMANDS.leaseAllocate, @@ -107,6 +111,8 @@ test('lease handler preserves device-aware lease fields', async () => { }, positionals: [], }, + sessionName: 'catalog-test', + sessionStore, leaseRegistry, }); @@ -132,6 +138,8 @@ test('lease handler preserves device-aware lease fields', async () => { }, positionals: [], }, + sessionName: 'catalog-test', + sessionStore, leaseRegistry, }); @@ -144,6 +152,7 @@ test('lease handler preserves device-aware lease fields', async () => { test('lease release calls provider hook using the released lease without heartbeat mutation', async () => { const leaseRegistry = new LeaseRegistry(); + const sessionStore = makeSessionStore('agent-device-lease-release-'); const releaseCalls: string[] = []; const allocateResponse = await handleLeaseCommands({ req: { @@ -159,6 +168,8 @@ test('lease release calls provider hook using the released lease without heartbe }, positionals: [], }, + sessionName: 'catalog-test', + sessionStore, leaseRegistry, }); assert.equal(allocateResponse?.ok, true); @@ -179,6 +190,8 @@ test('lease release calls provider hook using the released lease without heartbe }, positionals: [], }, + sessionName: 'catalog-test', + sessionStore, leaseRegistry, leaseLifecycleProvider: { release: async (releasedLease) => { diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 301625286..cbe78acf9 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -1,11 +1,15 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { CloudArtifactProvider } from '../../cloud-artifacts.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import type { DeviceLease, LeaseRegistry } from '../lease-registry.ts'; -import { resolveLeaseScope } from '../lease-context.ts'; +import type { SessionStore } from '../session-store.ts'; +import { resolveLeaseScope, resolveRequestOrSessionLeaseScope } from '../lease-context.ts'; import { leaseScopeToAllocateRequest, leaseScopeToHeartbeatRequest, leaseScopeToReleaseRequest, } from '../../core/lease-scope.ts'; +import { AppError } from '../../kernel/errors.ts'; export type LeaseLifecycleProvider = { allocate?: (lease: DeviceLease) => Promise | undefined>; @@ -15,14 +19,31 @@ export type LeaseLifecycleProvider = { type LeaseHandlerArgs = { req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; leaseRegistry: LeaseRegistry; leaseLifecycleProvider?: LeaseLifecycleProvider; + cloudArtifactProvider?: CloudArtifactProvider; }; export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { - const { req, leaseRegistry, leaseLifecycleProvider } = args; + const { + req, + sessionName, + sessionStore, + leaseRegistry, + leaseLifecycleProvider, + cloudArtifactProvider, + } = args; const leaseScope = resolveLeaseScope(req); switch (req.command) { + case PUBLIC_COMMANDS.artifacts: { + const artifactScope = resolveRequestOrSessionLeaseScope(req, sessionStore.get(sessionName)); + return { + ok: true, + data: await listCloudArtifactsForRequest(req, artifactScope, cloudArtifactProvider), + }; + } case 'lease_allocate': { const lease = leaseRegistry.allocateLease(leaseScopeToAllocateRequest(leaseScope)); let providerData: Record | undefined; @@ -69,3 +90,43 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise, + cloudArtifactProvider: CloudArtifactProvider | undefined, +) { + const providerSessionId = readFlagString(req.flags, 'providerSessionId'); + if (!leaseScope.leaseProvider) { + throw new AppError( + 'INVALID_ARGS', + 'artifacts requires --provider for provider session lookup or an active cloud connection.', + ); + } + if (!leaseScope.leaseId && !providerSessionId) { + throw new AppError( + 'INVALID_ARGS', + 'artifacts requires an active cloud lease or --provider-session .', + ); + } + const result = await cloudArtifactProvider?.listCloudArtifacts?.({ + provider: leaseScope.leaseProvider, + leaseId: leaseScope.leaseId, + providerSessionId, + }); + if (!result) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `Cloud artifacts are not available for provider "${leaseScope.leaseProvider}".`, + ); + } + return result; +} + +function readFlagString( + flags: Record | undefined, + key: string, +): string | undefined { + const value = flags?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index f1cd7cb63..55384e580 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -22,6 +22,7 @@ import { errorResponse } from './response.ts'; import { recordSessionAction } from './handler-utils.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { releaseSessionLease } from '../lease-lifecycle.ts'; +import type { LeaseLifecycleProvider } from './lease.ts'; import { stopAppleRunnerForClose, stopSessionAndroidNativePerfCapture, @@ -54,12 +55,14 @@ export async function handleCloseCommand(params: { logPath: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; + leaseLifecycleProvider?: LeaseLifecycleProvider; }): Promise { - const { req, sessionName, logPath, sessionStore, leaseRegistry } = params; + const { req, sessionName, logPath, sessionStore, leaseRegistry, leaseLifecycleProvider } = params; const session = sessionStore.get(sessionName); if (!session) { return await closeWithoutSession(req, logPath); } + let providerData: Record | undefined; try { await stopSessionAppLog(session); await stopSessionApplePerfCapture(session); @@ -111,7 +114,7 @@ export async function handleCloseCommand(params: { // Always release the device lease and drop the session, even if teardown // above threw: a failed close must not strand device ownership until the // inactivity expiry. The original error still propagates after finally. - releaseSessionLease({ session, leaseRegistry }); + providerData = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider }); sessionStore.delete(sessionName); } const shutdownResult = await maybeShutdownSessionTarget({ @@ -122,12 +125,23 @@ export async function handleCloseCommand(params: { return { ok: true, data: withSuccessText( - { session: session.name, shutdown: shutdownResult }, + { + session: session.name, + shutdown: shutdownResult, + ...(providerData ? { provider: providerData } : {}), + }, `Closed: ${session.name}`, ), }; } - return { ok: true, data: { session: session.name, ...successText(`Closed: ${session.name}`) } }; + return { + ok: true, + data: { + session: session.name, + ...successText(`Closed: ${session.name}`), + ...(providerData ? { provider: providerData } : {}), + }, + }; } function shouldDispatchPlatformClose(req: DaemonRequest, session: SessionState): boolean { diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 1bbcb15c5..21a569573 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -40,6 +40,7 @@ import { getSessionCommandKind } from '../daemon-command-registry.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { PREPARE_REQUEST_TIMEOUT_MS } from '../request-timeouts.ts'; import { Deadline } from '../../utils/retry.ts'; +import type { LeaseLifecycleProvider } from './lease.ts'; async function handlePrepareCommand(params: { req: DaemonRequest; @@ -229,6 +230,7 @@ export async function handleSessionCommands(params: { logPath: string; sessionStore: SessionStore; leaseRegistry?: LeaseRegistry; + leaseLifecycleProvider?: LeaseLifecycleProvider; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; androidAdbExecutor?: AndroidAdbExecutor; @@ -239,6 +241,7 @@ export async function handleSessionCommands(params: { logPath, sessionStore, leaseRegistry = new LeaseRegistry(), + leaseLifecycleProvider, invoke, invokeReplayAction, androidAdbExecutor, @@ -421,6 +424,7 @@ export async function handleSessionCommands(params: { logPath, sessionStore, leaseRegistry, + leaseLifecycleProvider, }); } diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index d7ce7f0ff..d61a511e4 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -8,6 +8,7 @@ import { } from './request-admission.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, SessionState } from './types.ts'; +import type { LeaseLifecycleProvider } from './handlers/lease.ts'; export type SessionTeardown = (session: SessionState, sessionName: string) => Promise; @@ -93,12 +94,13 @@ export function resolveSessionLeaseForRequest(params: { ); } -export function releaseSessionLease(params: { +export async function releaseSessionLease(params: { session: SessionState; leaseRegistry: LeaseRegistry; -}): void { + leaseLifecycleProvider?: LeaseLifecycleProvider; +}): Promise | undefined> { const lease = params.session.lease; - if (!lease) return; + if (!lease) return undefined; const result = params.leaseRegistry.releaseLease( leaseScopeToReleaseRequest({ leaseId: lease.leaseId, @@ -119,4 +121,5 @@ export function releaseSessionLease(params: { released: result.released, }, }); + return result.lease ? await params.leaseLifecycleProvider?.release?.(result.lease) : undefined; } diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index ba87d9d9f..c0cd24ac9 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -1,4 +1,5 @@ import type { CommandFlags } from '../core/dispatch.ts'; +import type { CloudArtifactProvider } from '../cloud-artifacts.ts'; import type { AndroidAdbExecutor } from '../platforms/android/adb-executor.ts'; import { AppError } from '../kernel/errors.ts'; import { getDaemonCommandRoute } from './daemon-command-registry.ts'; @@ -23,6 +24,7 @@ type RequestHandlerChainParams = { sessionStore: SessionStore; leaseRegistry: LeaseRegistry; leaseLifecycleProvider?: LeaseLifecycleProvider; + cloudArtifactProvider?: CloudArtifactProvider; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; androidAdbExecutor?: AndroidAdbExecutor; @@ -63,8 +65,11 @@ async function runLeaseHandler(params: RequestHandlerChainParams): Promise(task: () => Promise) => Promise; trackDownloadableArtifact: (opts: { artifactPath: string; @@ -86,6 +88,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { recordingProvider, deviceInventoryProvider, leaseLifecycleProvider, + cloudArtifactProvider, providerDeviceRuntimeScope, trackDownloadableArtifact, } = deps; @@ -202,6 +205,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { sessionStore, leaseRegistry, leaseLifecycleProvider, + cloudArtifactProvider, invoke: handleRequest, invokeReplayAction: allowReplayActions ? createReplayScopedActionInvoker(lockedScope, providerScope) diff --git a/src/default-cloud-artifact-provider.ts b/src/default-cloud-artifact-provider.ts new file mode 100644 index 000000000..abc382ede --- /dev/null +++ b/src/default-cloud-artifact-provider.ts @@ -0,0 +1,76 @@ +import type { CloudArtifactProvider, CloudArtifactsResult } from './cloud-artifacts.ts'; +import { + createAwsCliDeviceFarmClient, + listAwsDeviceFarmCloudArtifacts, +} from './cloud-webdriver/aws-device-farm.ts'; +import { listBrowserStackCloudArtifacts } from './cloud-webdriver/browserstack.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS } from './cloud-webdriver/providers.ts'; +import { AppError } from './kernel/errors.ts'; + +export type DefaultCloudArtifactProviderEnv = { + BROWSERSTACK_USERNAME?: string; + BROWSERSTACK_ACCESS_KEY?: string; + BROWSERSTACK_SESSION_DETAILS_ENDPOINT?: string; + AWS_REGION?: string; + AWS_DEFAULT_REGION?: string; +}; + +export function createDefaultCloudArtifactProvider( + env: DefaultCloudArtifactProviderEnv = process.env, +): CloudArtifactProvider { + return { + listCloudArtifacts: async (query) => { + if (!query.providerSessionId) return undefined; + switch (query.provider) { + case CLOUD_WEBDRIVER_PROVIDERS.browserStack: + return await listBrowserStackArtifacts(query.providerSessionId, env); + case CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm: + return await listAwsArtifacts(query.providerSessionId, env); + default: + return undefined; + } + }, + }; +} + +async function listBrowserStackArtifacts( + providerSessionId: string, + env: DefaultCloudArtifactProviderEnv, +): Promise { + const username = env.BROWSERSTACK_USERNAME; + const accessKey = env.BROWSERSTACK_ACCESS_KEY; + if (!username || !accessKey) { + throw new AppError( + 'INVALID_ARGS', + 'BrowserStack artifact lookup requires BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.', + ); + } + return await listBrowserStackCloudArtifacts( + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + providerSessionId, + { + username, + accessKey, + endpoint: env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, + }, + ); +} + +async function listAwsArtifacts( + providerSessionId: string, + env: DefaultCloudArtifactProviderEnv, +): Promise { + const client = createAwsCliDeviceFarmClient({ + region: + env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? readAwsRegionFromDeviceFarmArn(providerSessionId), + }); + return await listAwsDeviceFarmCloudArtifacts( + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + providerSessionId, + client, + ); +} + +function readAwsRegionFromDeviceFarmArn(arn: string): string | undefined { + return /^arn:[^:]+:devicefarm:([^:]+):/.exec(arn)?.[1]; +} diff --git a/src/index.ts b/src/index.ts index 6e366e0a8..37f70e470 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,20 +4,133 @@ export { AppError, isAgentDeviceError, normalizeAgentDeviceError } from './kerne export { centerOfRect } from './kernel/snapshot.ts'; export type { + ArtifactAdapter, + ArtifactDescriptor, + CreateTempFileOptions, + FileInputRef, + FileOutputRef, + LocalArtifactAdapterOptions, + OutputVisibility, + ReserveOutputOptions, + ReservedOutputFile, + ResolveInputOptions, + ResolvedInputFile, + TemporaryFile, +} from './io.ts'; + +export type { AppErrorCode, NormalizedError } from './kernel/errors.ts'; + +export type { + ReplayTestReporter, + ReplayTestReporterContext, + ReplayTestReporterFactory, + ReplayTestReporterLoadContext, +} from './cli-test-reporters/types.ts'; + +export type { CommandResult } from './core/command-descriptor/command-result.ts'; +export type { ResponseLevel } from './kernel/contracts.ts'; +export type { BootCommandResult, ShutdownCommandResult } from './contracts/device.ts'; +export type { ViewportCommandResult } from './contracts/viewport.ts'; +export type { + CloudArtifact, + CloudArtifactAvailability, + CloudArtifactKind, + CloudArtifactsResult, + CloudArtifactsStatus, +} from './cloud-artifacts.ts'; + +export type { + AgentDeviceClient, + AgentDeviceClientConfig, + AgentDeviceCommandClient, AgentDeviceDaemonTransport, - AlertCommandResult, + AgentDeviceDevice, + AgentDeviceIdentifiers, + AgentDeviceRequestOverrides, + AgentDeviceSelectionOptions, + AgentDeviceSession, + AgentDeviceSessionDevice, + AlertAction, + AlertCommandOptions, + AlertInfo, + AlertPlatform, + AlertSource, + AppCloseOptions, + AppCloseResult, + AppDeployOptions, + AppDeployResult, + AppInstallFromSourceOptions, + AppInstallFromSourceResult, AppListOptions, + AppOpenOptions, + AppOpenResult, + AppPushOptions, + AppStateCommandOptions, AppStateCommandResult, + AppSwitcherCommandOptions, AppSwitcherCommandResult, + AppTriggerEventOptions, BackCommandOptions, BackCommandResult, + BatchRunOptions, + BatchRunResult, + BatchStep, + CaptureDiffOptions, + CaptureScreenshotOptions, + CaptureScreenshotResult, + CaptureSnapshotOptions, + CaptureSnapshotResult, + ClickOptions, + ClipboardCommandOptions, ClipboardCommandResult, + CloudArtifactsOptions, + CommandRequestResult, + DeviceBootOptions, + DeviceShutdownOptions, + ElementTarget, + FillOptions, + FindLocator, + FindOptions, + FocusOptions, + GetOptions, + HomeCommandOptions, HomeCommandResult, + InteractionTarget, + IsOptions, + KeyboardCommandOptions, KeyboardCommandResult, + LogsOptions, + LongPressOptions, + MaterializationReleaseOptions, + MaterializationReleaseResult, + MetroPrepareOptions, + MetroPrepareResult, + NetworkOptions, + PerfOptions, + PermissionTarget, + PinchOptions, + PressOptions, RecordOptions, + ReplayRunOptions, + ReplayTestOptions, RotateCommandOptions, RotateCommandResult, ScrollOptions, + SessionCloseResult, + SettingsUpdateOptions, + StartupPerfSample, + SwipeOptions, + TargetShutdownResult, + TraceOptions, + TypeTextOptions, + WaitCommandOptions, } from './client/client.ts'; -export type { SnapshotNode } from './kernel/snapshot.ts'; +export type { + Point, + Rect, + ScreenshotOverlayRef, + SnapshotNode, + SnapshotVisibility, + SnapshotVisibilityReason, +} from './kernel/snapshot.ts'; diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 32d5a0fd1..8e9ea6983 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -1,4 +1,9 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import type { + CloudArtifactProvider, + CloudArtifactsQuery, + CloudArtifactsResult, +} from './cloud-artifacts.ts'; import type { Interactor } from './core/interactor-types.ts'; import type { DeviceInventoryProvider } from './core/dispatch-resolve.ts'; import type { LeaseLifecycleProvider } from './daemon/handlers/lease.ts'; @@ -22,6 +27,7 @@ export type ProviderDeviceInstallOptions = { export type ProviderDeviceRuntime = { provider: string; leaseLifecycle: LeaseLifecycleProvider; + cloudArtifacts?: CloudArtifactProvider; deviceInventoryProvider: DeviceInventoryProvider; ownsDevice(device: DeviceInfo): boolean; getInteractor(device: DeviceInfo): Interactor | undefined; @@ -55,6 +61,7 @@ export type ProviderPortReverseOptions = { export type ProviderDeviceRuntimeRequestProviders = { leaseLifecycleProvider?: LeaseLifecycleProvider; + cloudArtifactProvider?: CloudArtifactProvider; deviceInventoryProvider?: DeviceInventoryProvider; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; }; @@ -152,6 +159,7 @@ export function createProviderDeviceRuntimeRequestProviders( ): ProviderDeviceRuntimeRequestProviders { return { leaseLifecycleProvider: composeLeaseProvider(runtimes), + cloudArtifactProvider: composeCloudArtifactProvider(runtimes), deviceInventoryProvider: composeDeviceInventoryProvider(runtimes), providerDeviceRuntimeScope: async (task) => await withProviderDeviceRuntimeScope(runtimes, task), @@ -169,6 +177,15 @@ function composeLeaseProvider( }; } +function composeCloudArtifactProvider( + runtimes: ProviderDeviceRuntime[], +): CloudArtifactProvider | undefined { + if (runtimes.length === 0) return undefined; + return { + listCloudArtifacts: async (query) => await firstCloudArtifactsResult(runtimes, query), + }; +} + function composeDeviceInventoryProvider( runtimes: ProviderDeviceRuntime[], ): DeviceInventoryProvider | undefined { @@ -183,6 +200,18 @@ function composeDeviceInventoryProvider( }; } +async function firstCloudArtifactsResult( + runtimes: ProviderDeviceRuntime[], + query: CloudArtifactsQuery, +): Promise { + for (const runtime of runtimes) { + if (!runtimeMatchesProvider(runtime, query.provider)) continue; + const result = await runtime.cloudArtifacts?.listCloudArtifacts?.(query); + if (result) return result; + } + return undefined; +} + async function firstProviderResult( runtimes: ProviderDeviceRuntime[], method: keyof LeaseLifecycleProvider, diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts new file mode 100644 index 000000000..71a588dc8 --- /dev/null +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -0,0 +1,528 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http, { type IncomingMessage, type ServerResponse } from 'node:http'; +import path from 'node:path'; +import { test } from 'vitest'; +import { + createAwsCliDeviceFarmClient, + createAwsDeviceFarmWebDriverRuntime, + createBrowserStackWebDriverRuntime, + getAwsDeviceFarmWebDriverCapabilities, + getBrowserStackWebDriverCapabilities, + listAwsDeviceFarmCloudArtifacts, + listBrowserStackCloudArtifacts, + selectAwsDeviceFarmWebDriverEndpoint, + uploadBrowserStackApp, + type AwsDeviceFarmClient, +} from '../../../src/cloud-webdriver.ts'; +import type { DeviceLease } from '../../../src/daemon/lease-registry.ts'; +import { withCommandExecutorOverride } from '../../../src/utils/exec.ts'; +import { withProviderScenarioResource, withProviderScenarioTempDir } from './harness.ts'; + +type HttpCall = { + method: string; + path: string; + body?: unknown; +}; + +test('BrowserStack adapter prepares App Automate capabilities and uploads install artifacts', async () => { + await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { + await withProviderScenarioTempDir('agent-device-browserstack-adapter-', async (tempDir) => { + const appPath = path.join(tempDir, 'demo.apk'); + fs.writeFileSync(appPath, 'fake apk'); + const lease = makeLease('browserstack'); + const runtime = createBrowserStackWebDriverRuntime({ + username: 'user', + accessKey: 'key', + endpoint: `${server.url}/wd/hub/`, + uploadEndpoint: `${server.url}/app-automate/upload`, + sessionDetailsEndpoint: `${server.url}/app-automate/sessions`, + platform: 'android', + deviceName: 'Google Pixel 8', + osVersion: '14.0', + app: 'bs://preuploaded', + projectName: 'agent-device', + buildName: (lease) => `build-${lease.runId}`, + sessionName: (lease) => `session-${lease.leaseId}`, + }); + try { + await runtime.leaseLifecycle.allocate?.(lease); + const [device] = + (await runtime.deviceInventoryProvider({ + leaseProvider: 'browserstack', + leaseId: lease.leaseId, + platform: 'android', + })) ?? []; + assert.ok(device); + await runtime.installApp?.(device, 'com.example.demo', appPath, { + packageNameHint: 'com.example.demo', + }); + const release = await runtime.leaseLifecycle.release?.(lease); + assert.equal( + (release?.cloudArtifacts as { cloudArtifacts?: unknown[] } | undefined)?.cloudArtifacts + ?.length, + 5, + ); + } finally { + await runtime.shutdown(); + } + assertBrowserStackCalls(server.calls, lease); + }); + }); +}, 15_000); + +test('cloud provider adapters declare command capabilities explicitly', () => { + const browserStack = getBrowserStackWebDriverCapabilities('android'); + assert.equal(browserStack.operations.snapshot.support, 'partial'); + assert.equal(browserStack.operations.install.support, 'partial'); + assert.equal(browserStack.operations.artifacts.support, 'supported'); + assert.equal(browserStack.operations.nativeSnapshotBackend.support, 'unsupported'); + assert.match(browserStack.operations.portReverse.note ?? '', /BrowserStack Local/); + + const aws = getAwsDeviceFarmWebDriverCapabilities('android'); + assert.equal(aws.operations.snapshot.support, 'partial'); + assert.equal(aws.operations.install.support, 'unsupported'); + assert.equal(aws.operations.artifacts.support, 'supported'); + assert.match(aws.operations.install.note ?? '', /appArn/); + assert.equal(aws.operations.nativeSnapshotBackend.support, 'unsupported'); +}); + +test('AWS Device Farm adapter selects WebDriver endpoint and stops remote access on release', async () => { + await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { + const lease = makeLease('aws-device-farm'); + const client = new FakeAwsDeviceFarmClient(`${server.url}/wd/hub/`); + const runtime = createAwsDeviceFarmWebDriverRuntime({ + client, + projectArn: 'arn:aws:devicefarm:us-west-2:123:project/project-id', + deviceArn: 'arn:aws:devicefarm:us-west-2::device/device-id', + platform: 'android', + deviceName: 'Google Pixel 8', + sessionName: (lease) => `aws-${lease.leaseId}`, + pollIntervalMs: 1, + }); + try { + const allocation = await runtime.leaseLifecycle.allocate?.(lease); + assert.equal(allocation?.awsDeviceFarmSessionArn, client.sessionArn); + const release = await runtime.leaseLifecycle.release?.(lease); + assert.equal( + (release?.cloudArtifacts as { cloudArtifacts?: unknown[] } | undefined)?.cloudArtifacts + ?.length, + 3, + ); + } finally { + await runtime.shutdown(); + } + assert.deepEqual(client.calls, [ + 'create:aws-lease1', + 'get:arn:aws:devicefarm:session/fake', + 'stop:arn:aws:devicefarm:session/fake', + 'list:arn:aws:devicefarm:session/fake:FILE', + 'list:arn:aws:devicefarm:session/fake:LOG', + ]); + assert.equal(server.calls[0]?.path, '/wd/hub/session'); + assert.equal(server.calls.at(-1)?.path, '/wd/hub/session/wd-1'); + }); +}, 15_000); + +test('AWS Device Farm adapter rejects local artifact install until upload support exists', async () => { + await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { + await withProviderScenarioTempDir('agent-device-aws-install-unsupported-', async (tempDir) => { + const appPath = path.join(tempDir, 'demo.apk'); + fs.writeFileSync(appPath, 'fake apk'); + const lease = makeLease('aws-device-farm'); + const client = new FakeAwsDeviceFarmClient(`${server.url}/wd/hub/`); + const runtime = createAwsDeviceFarmWebDriverRuntime({ + client, + projectArn: 'arn:aws:devicefarm:us-west-2:123:project/project-id', + deviceArn: 'arn:aws:devicefarm:us-west-2::device/device-id', + platform: 'android', + deviceName: 'Google Pixel 8', + pollIntervalMs: 1, + }); + try { + await runtime.leaseLifecycle.allocate?.(lease); + const [device] = + (await runtime.deviceInventoryProvider({ + leaseProvider: 'aws-device-farm', + leaseId: lease.leaseId, + platform: 'android', + })) ?? []; + assert.ok(device); + assert.ok(runtime.installApp); + await assert.rejects( + () => runtime.installApp!(device, 'com.example.demo', appPath), + /local artifact upload\/install is not implemented/, + ); + } finally { + await runtime.leaseLifecycle.release?.(lease); + await runtime.shutdown(); + } + }); + }); +}, 15_000); + +test('WebDriver session creation retries transient provider failures', async () => { + await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { + server.sessionFailuresRemaining = 1; + const lease = makeLease('browserstack'); + const runtime = createBrowserStackWebDriverRuntime({ + username: 'user', + accessKey: 'key', + endpoint: `${server.url}/wd/hub/`, + uploadEndpoint: `${server.url}/app-automate/upload`, + sessionDetailsEndpoint: `${server.url}/app-automate/sessions`, + platform: 'android', + deviceName: 'Google Pixel 8', + osVersion: '14.0', + requestPolicy: { + retryAttempts: 1, + retryDelayMs: 1, + }, + }); + try { + const allocation = await runtime.leaseLifecycle.allocate?.(lease); + assert.equal(allocation?.provider, 'browserstack'); + await runtime.leaseLifecycle.release?.(lease); + } finally { + await runtime.shutdown(); + } + assert.equal(server.calls.filter((call) => call.path === '/wd/hub/session').length, 2); + }); +}, 15_000); + +test('AWS Device Farm endpoint selection skips live-control WebSocket URLs', () => { + assert.equal( + selectAwsDeviceFarmWebDriverEndpoint({ + arn: 'arn', + remoteDebugUrl: 'wss://live-control.example/socket', + endpoints: { + video: 'wss://video.example/socket', + appium: 'devicefarm-appium.example/wd/hub/', + }, + }), + 'http://devicefarm-appium.example/wd/hub/', + ); +}); + +test('BrowserStack upload helper returns uploaded app reference', async () => { + await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { + await withProviderScenarioTempDir('agent-device-browserstack-upload-', async (tempDir) => { + const appPath = path.join(tempDir, 'demo.apk'); + fs.writeFileSync(appPath, 'fake apk'); + const appUrl = await uploadBrowserStackApp(appPath, { + username: 'user', + accessKey: 'key', + endpoint: `${server.url}/app-automate/upload`, + }); + assert.equal(appUrl, 'bs://uploaded-app'); + assert.equal(server.calls[0]?.path, '/app-automate/upload'); + }); + }); +}, 15_000); + +test('BrowserStack session details map provider-hosted cloud artifacts', async () => { + await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { + const result = await listBrowserStackCloudArtifacts('browserstack', 'wd-1', { + username: 'user', + accessKey: 'key', + endpoint: `${server.url}/app-automate/sessions`, + }); + assert.equal(result?.status, 'ready'); + assert.deepEqual( + result?.cloudArtifacts.map((artifact) => artifact.kind), + ['video', 'appium-log', 'device-log', 'provider-session', 'provider-session'], + ); + }); +}); + +test('AWS Device Farm artifacts map to shared cloud artifact kinds', async () => { + const client = new FakeAwsDeviceFarmClient('http://provider.example/wd/hub/'); + const result = await listAwsDeviceFarmCloudArtifacts( + 'aws-device-farm', + client.sessionArn, + client, + ); + assert.deepEqual( + result?.cloudArtifacts.map((artifact) => artifact.kind), + ['video', 'device-log', 'appium-log'], + ); +}); + +test('AWS CLI Device Farm client maps remote access commands', async () => { + const calls: string[][] = []; + const client = createAwsCliDeviceFarmClient({ region: 'us-west-2', awsCommand: 'aws' }); + await withCommandExecutorOverride( + async (cmd, args) => { + calls.push([cmd, ...args]); + return { + stdout: JSON.stringify({ remoteAccessSession: { arn: 'arn', status: 'RUNNING' } }), + stderr: '', + exitCode: 0, + }; + }, + async () => { + await client.createRemoteAccessSession({ + projectArn: 'project', + deviceArn: 'device', + name: 'session', + }); + await client.getRemoteAccessSession('arn'); + await client.stopRemoteAccessSession('arn'); + await client.listArtifacts('arn', 'FILE'); + }, + ); + assert.deepEqual(calls, [ + [ + 'aws', + 'devicefarm', + 'create-remote-access-session', + '--region', + 'us-west-2', + '--project-arn', + 'project', + '--device-arn', + 'device', + '--name', + 'session', + '--output', + 'json', + ], + [ + 'aws', + 'devicefarm', + 'get-remote-access-session', + '--region', + 'us-west-2', + '--arn', + 'arn', + '--output', + 'json', + ], + [ + 'aws', + 'devicefarm', + 'stop-remote-access-session', + '--region', + 'us-west-2', + '--arn', + 'arn', + '--output', + 'json', + ], + [ + 'aws', + 'devicefarm', + 'list-artifacts', + '--region', + 'us-west-2', + '--arn', + 'arn', + '--type', + 'FILE', + '--output', + 'json', + ], + ]); +}); + +function assertBrowserStackCalls(calls: readonly HttpCall[], lease: DeviceLease): void { + assert.equal(calls[0]?.path, '/wd/hub/session'); + assert.deepEqual(calls[0]?.body, { + capabilities: { + alwaysMatch: { + platformName: 'Android', + 'appium:deviceName': 'Google Pixel 8', + device: 'Google Pixel 8', + os_version: '14.0', + app: 'bs://preuploaded', + 'bstack:options': { + projectName: 'agent-device', + buildName: 'build-run-a', + sessionName: `session-${lease.leaseId}`, + }, + }, + }, + }); + assert.equal(calls[1]?.path, '/app-automate/upload'); + assert.equal(calls[2]?.path, '/wd/hub/session/wd-1/appium/device/install_app'); + assert.deepEqual(calls[2]?.body, { appPath: 'bs://uploaded-app' }); + assert.equal(calls.at(-2)?.path, '/wd/hub/session/wd-1'); + assert.equal(calls.at(-1)?.path, '/app-automate/sessions/wd-1.json'); +} + +class FakeAwsDeviceFarmClient implements AwsDeviceFarmClient { + readonly sessionArn = 'arn:aws:devicefarm:session/fake'; + readonly calls: string[] = []; + private readonly webDriverEndpoint: string; + + constructor(webDriverEndpoint: string) { + this.webDriverEndpoint = webDriverEndpoint; + } + + async createRemoteAccessSession(input: { name: string }) { + this.calls.push(`create:${input.name}`); + return { arn: this.sessionArn, status: 'PENDING' }; + } + + async getRemoteAccessSession(arn: string) { + this.calls.push(`get:${arn}`); + return { + arn, + status: 'RUNNING', + remoteDebugUrl: 'wss://live-control.example/socket', + endpoints: { + appium: this.webDriverEndpoint, + }, + device: { + name: 'Google Pixel 8', + platform: 'ANDROID', + os: '14', + }, + }; + } + + async stopRemoteAccessSession(arn: string) { + this.calls.push(`stop:${arn}`); + return { arn, status: 'STOPPING' }; + } + + async listArtifacts(arn: string, type: 'FILE' | 'LOG' | 'SCREENSHOT') { + this.calls.push(`list:${arn}:${type}`); + if (type === 'FILE') { + return [ + { + arn: `${arn}/video`, + name: 'VIDEO', + type: 'VIDEO', + extension: 'mp4', + url: 'https://aws.example/video.mp4', + }, + { + arn: `${arn}/device-log`, + name: 'DEVICE_LOG', + type: 'DEVICE_LOG', + extension: 'log', + url: 'https://aws.example/device.log', + }, + ]; + } + if (type === 'LOG') { + return [ + { + arn: `${arn}/appium-log`, + name: 'APPIUM_SERVER_OUTPUT', + type: 'APPIUM_SERVER_OUTPUT', + extension: 'log', + url: 'https://aws.example/appium.log', + }, + ]; + } + return []; + } +} + +class FakeCloudProviderServer { + readonly calls: HttpCall[] = []; + sessionFailuresRemaining = 0; + url = ''; + + private readonly server: http.Server; + + private constructor(server: http.Server) { + this.server = server; + } + + static async start(): Promise { + const instance = new FakeCloudProviderServer(http.createServer()); + instance.server.on('request', async (req, res) => await instance.handle(req, res)); + await new Promise((resolve, reject) => { + instance.server.once('error', reject); + instance.server.listen(0, '127.0.0.1', resolve); + }); + const address = instance.server.address(); + assert.ok(address && typeof address === 'object'); + instance.url = `http://127.0.0.1:${address.port}`; + return instance; + } + + async close(): Promise { + await new Promise((resolve, reject) => { + this.server.close((error) => (error ? reject(error) : resolve())); + }); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const body = await readRequestBody(req); + this.calls.push({ + method: req.method ?? 'GET', + path: req.url ?? '/', + ...(body === undefined ? {} : { body }), + }); + this.respond(req, res); + } + + private respond(req: IncomingMessage, res: ServerResponse): void { + if (req.method === 'POST' && req.url === '/wd/hub/session') { + if (this.sessionFailuresRemaining > 0) { + this.sessionFailuresRemaining -= 1; + writeJson(res, { value: { message: 'transient provider failure' } }, 503); + return; + } + writeJson(res, { value: { sessionId: 'wd-1', capabilities: {} } }); + return; + } + if (req.method === 'POST' && req.url === '/app-automate/upload') { + writeJson(res, { app_url: 'bs://uploaded-app' }); + return; + } + if (req.method === 'GET' && req.url === '/app-automate/sessions/wd-1.json') { + writeJson(res, { + automation_session: { + video_url: 'https://browserstack.example/video.mp4', + appium_logs_url: 'https://browserstack.example/appium.log', + device_logs_url: 'https://browserstack.example/device.log', + browser_url: 'https://browserstack.example/dashboard', + public_url: 'https://browserstack.example/public', + }, + }); + return; + } + writeJson(res, { value: null }); + } +} + +function makeLease(provider: string): DeviceLease { + const now = Date.now(); + return { + leaseId: 'lease1', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: provider, + deviceKey: 'device-a', + clientId: 'client-a', + createdAt: now, + heartbeatAt: now, + expiresAt: now + 60_000, + }; +} + +async function readRequestBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + const buffer = Buffer.concat(chunks); + if (isMultipartRequest(req)) return { multipartBytes: buffer.length }; + const text = buffer.toString('utf8'); + return text ? (JSON.parse(text) as unknown) : undefined; +} + +function isMultipartRequest(req: IncomingMessage): boolean { + return req.headers['content-type']?.startsWith('multipart/form-data') === true; +} + +function writeJson(res: ServerResponse, body: unknown, status = 200): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts new file mode 100644 index 000000000..776f3f1a9 --- /dev/null +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -0,0 +1,381 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http, { type IncomingMessage, type ServerResponse } from 'node:http'; +import path from 'node:path'; +import { test } from 'vitest'; +import { createCloudWebDriverRuntime } from '../../../src/cloud-webdriver.ts'; +import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; +import type { DeviceLease } from '../../../src/daemon/lease-registry.ts'; +import type { DaemonRequest } from '../../../src/daemon/types.ts'; +import { assertRpcOk } from './assertions.ts'; +import { + createProviderScenarioHarness, + withProviderScenarioResource, + withProviderScenarioTempDir, +} from './harness.ts'; +import { runProviderScenario, type ProviderScenarioStep } from './scenario.ts'; + +const WEBDRIVER_PROVIDER = 'webdriver-fake'; + +type WebDriverHttpCall = { + method: string; + path: string; + body?: unknown; +}; + +test('Cloud WebDriver runtime drives provider devices through daemon commands', async () => { + await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => { + const { daemon, server } = world; + await withProviderScenarioTempDir('agent-device-cloud-webdriver-', async (tempDir) => { + const appPath = path.join(tempDir, 'demo.apk'); + fs.writeFileSync(appPath, 'fake apk'); + const lease = await allocateWebDriverLease(daemon); + const steps = cloudWebDriverScenarioSteps(appPath, lease); + const releaseStep = steps.at(-1); + assert.ok(releaseStep); + await runProviderScenario(daemon, steps.slice(0, -1), { + flags: leaseFlags(lease.leaseId), + meta: leaseMeta(lease.leaseId), + }); + const inferredArtifacts = await daemon.callCommand('artifacts'); + const inferredData = assertRpcOk<{ + provider?: string; + status?: string; + providerSessionId?: string; + }>(inferredArtifacts); + assert.equal(inferredData.provider, WEBDRIVER_PROVIDER); + assert.equal(inferredData.status, 'ready'); + assert.equal(inferredData.providerSessionId, 'wd-1'); + + world.failNextArtifactLookup(); + const unavailableArtifacts = await daemon.callCommand('artifacts'); + const unavailableData = assertRpcOk<{ + provider?: string; + status?: string; + providerSessionId?: string; + cloudArtifacts?: unknown[]; + }>(unavailableArtifacts); + assert.equal(unavailableData.provider, WEBDRIVER_PROVIDER); + assert.equal(unavailableData.status, 'unavailable'); + assert.equal(unavailableData.providerSessionId, 'wd-1'); + assert.deepEqual(unavailableData.cloudArtifacts, []); + + await runProviderScenario(daemon, [releaseStep], { + flags: leaseFlags(lease.leaseId), + meta: leaseMeta(lease.leaseId), + }); + assertWebDriverCalls(server.calls, lease.leaseId, appPath); + }); + }); +}, 15_000); + +async function createCloudWebDriverWorld() { + const server = await FakeWebDriverServer.start(); + let artifactFailuresRemaining = 0; + const runtime = createCloudWebDriverRuntime({ + provider: WEBDRIVER_PROVIDER, + endpoint: `${server.url}/wd/hub/`, + platform: 'android', + deviceName: 'BrowserStack Google Pixel 8', + webdriverCapabilities: (lease) => ({ + 'appium:automationName': 'UiAutomator2', + 'bstack:options': { + buildName: lease.runId, + sessionName: lease.leaseId, + }, + }), + listArtifacts: async ({ provider, providerSessionId }) => { + if (artifactFailuresRemaining > 0) { + artifactFailuresRemaining -= 1; + throw new Error('provider artifact lookup failed'); + } + return { + provider, + providerSessionId, + status: 'ready', + cloudArtifacts: [ + { + provider, + providerSessionId, + kind: 'video', + name: 'Session video', + url: 'https://provider.example/video.mp4', + availability: 'ready', + }, + ], + }; + }, + }); + const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + const daemon = await createProviderScenarioHarness({ + ...providers, + deviceInventoryProvider: providers.deviceInventoryProvider!, + }); + return { + daemon, + server, + failNextArtifactLookup: () => { + artifactFailuresRemaining += 1; + }, + close: async () => { + await runtime.shutdown(); + await daemon.close(); + await server.close(); + }, + }; +} + +async function allocateWebDriverLease( + daemon: Awaited>, +): Promise { + const allocate = await daemon.callCommand('lease_allocate', [], leaseFlags(), { + meta: leaseMeta(), + }); + const data = assertRpcOk<{ + lease: DeviceLease; + provider?: { + capabilities?: { operations?: { snapshot?: { support?: string } } }; + }; + }>(allocate); + assert.equal(data.provider?.capabilities?.operations?.snapshot?.support, 'partial'); + return data.lease; +} + +function cloudWebDriverScenarioSteps(appPath: string, lease: DeviceLease): ProviderScenarioStep[] { + return [ + { + name: 'heartbeat', + command: 'lease_heartbeat', + expectData: { provider: { provider: WEBDRIVER_PROVIDER } }, + }, + { + name: 'install', + command: 'install', + positionals: ['com.example.demo', appPath], + expectData: { + platform: 'android', + packageName: 'com.example.demo', + }, + }, + { + name: 'open', + command: 'open', + positionals: ['com.example.demo'], + expectData: { + platform: 'android', + id: `webdriver-fake:android:${lease.leaseId}`, + serial: `webdriver-fake:android:${lease.leaseId}`, + }, + }, + { name: 'click', command: 'click', positionals: ['10', '20'], expectData: { x: 10, y: 20 } }, + { + name: 'fill', + command: 'fill', + positionals: ['12', '24', 'hello cloud'], + expectData: { x: 12, y: 24, text: 'hello cloud' }, + }, + { + name: 'snapshot', + command: 'snapshot', + assert: (response) => { + const data = assertRpcOk<{ + nodes?: Array<{ + label?: string; + identifier?: string; + depth?: number; + parentIndex?: number; + hittable?: boolean; + }>; + }>(response); + assert.equal(data.nodes?.[1]?.label, 'Login'); + assert.equal(data.nodes?.[1]?.identifier, 'com.example:id/login'); + assert.equal(data.nodes?.[1]?.depth, 1); + assert.equal(data.nodes?.[1]?.parentIndex, 0); + assert.equal(data.nodes?.[1]?.hittable, true); + }, + }, + { + name: 'artifacts', + command: 'artifacts', + expectData: { + provider: WEBDRIVER_PROVIDER, + status: 'ready', + providerSessionId: 'wd-1', + }, + }, + { + name: 'release', + command: 'lease_release', + assert: (response) => { + const data = assertRpcOk<{ + released?: boolean; + provider?: { + provider?: string; + providerSessionId?: string; + cloudArtifacts?: { + status?: string; + cloudArtifacts?: Array<{ kind?: string }>; + }; + }; + }>(response); + assert.equal(data.released, true); + assert.equal(data.provider?.provider, WEBDRIVER_PROVIDER); + assert.equal(data.provider?.providerSessionId, 'wd-1'); + assert.equal(data.provider?.cloudArtifacts?.status, 'ready'); + assert.equal(data.provider?.cloudArtifacts?.cloudArtifacts?.[0]?.kind, 'video'); + }, + }, + ]; +} + +function assertWebDriverCalls( + calls: readonly WebDriverHttpCall[], + leaseId: string, + appPath: string, +): void { + assert.deepEqual( + calls.map((call) => `${call.method} ${call.path}`), + [ + 'POST /wd/hub/session', + 'POST /wd/hub/session/wd-1/appium/device/install_app', + 'POST /wd/hub/session/wd-1/execute/sync', + 'POST /wd/hub/session/wd-1/actions', + 'DELETE /wd/hub/session/wd-1/actions', + 'POST /wd/hub/session/wd-1/actions', + 'DELETE /wd/hub/session/wd-1/actions', + 'POST /wd/hub/session/wd-1/keys', + 'GET /wd/hub/session/wd-1/source', + 'DELETE /wd/hub/session/wd-1', + ], + ); + assert.deepEqual(calls[0]?.body, { + capabilities: { + alwaysMatch: { + platformName: 'Android', + 'appium:deviceName': 'BrowserStack Google Pixel 8', + 'appium:automationName': 'UiAutomator2', + 'bstack:options': { + buildName: 'run-a', + sessionName: leaseId, + }, + }, + }, + }); + assert.deepEqual(calls[1]?.body, { appPath }); + assert.deepEqual(calls[2]?.body, { + script: 'mobile: activateApp', + args: [{ appId: 'com.example.demo', bundleId: 'com.example.demo' }], + }); + assert.deepEqual(calls[7]?.body, { text: 'hello cloud', value: Array.from('hello cloud') }); +} + +class FakeWebDriverServer { + readonly calls: WebDriverHttpCall[] = []; + url = ''; + + private readonly server: http.Server; + + private constructor(server: http.Server) { + this.server = server; + } + + static async start(): Promise { + const instance = new FakeWebDriverServer(http.createServer()); + instance.server.on('request', async (req, res) => await instance.handle(req, res)); + await new Promise((resolve, reject) => { + instance.server.once('error', reject); + instance.server.listen(0, '127.0.0.1', resolve); + }); + const address = instance.server.address(); + assert.ok(address && typeof address === 'object'); + instance.url = `http://127.0.0.1:${address.port}`; + return instance; + } + + async close(): Promise { + await new Promise((resolve, reject) => { + this.server.close((error) => (error ? reject(error) : resolve())); + }); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const call: WebDriverHttpCall = { + method: req.method ?? 'GET', + path: req.url ?? '/', + }; + const body = await readJsonBody(req); + if (body !== undefined) call.body = body; + this.calls.push(call); + this.respond(call, res); + } + + private respond(call: WebDriverHttpCall, res: ServerResponse): void { + if (call.method === 'POST' && call.path === '/wd/hub/session') { + writeJson(res, { + value: { + sessionId: 'wd-1', + capabilities: { platformName: 'Android' }, + }, + }); + return; + } + if (call.method === 'GET' && call.path === '/wd/hub/session/wd-1/source') { + writeJson(res, { + value: + '' + + '' + + '', + }); + return; + } + if (call.method === 'DELETE' && call.path === '/wd/hub/session/wd-1/actions') { + writeJson( + res, + { + value: { + message: 'The requested resource could not be found.', + }, + }, + 500, + ); + return; + } + writeJson(res, { value: null }); + } +} + +function leaseFlags(leaseId?: string): DaemonRequest['flags'] { + return { + platform: 'android', + tenant: 'team-a', + runId: 'run-a', + leaseId, + leaseProvider: WEBDRIVER_PROVIDER, + }; +} + +function leaseMeta(leaseId?: string): DaemonRequest['meta'] { + return { + tenantId: 'team-a', + runId: 'run-a', + leaseId, + leaseBackend: 'android-instance', + leaseProvider: WEBDRIVER_PROVIDER, + deviceKey: 'webdriver-android-a', + clientId: 'client-a', + }; +} + +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + const text = Buffer.concat(chunks).toString('utf8'); + return text ? (JSON.parse(text) as unknown) : undefined; +} + +function writeJson(res: ServerResponse, body: unknown, status = 200): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index cedd383d2..0a7ed2fa2 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -51,6 +51,14 @@ Public subpath API exposed for Node consumers: - types: `MaterializeInstallSource` - `agent-device/artifacts` - `resolveAndroidArchivePackageName(archivePath)` +- `agent-device/android-snapshot-helper` + - `ensureAndroidSnapshotHelper(options)` + - `captureAndroidSnapshotWithHelper(options)` + - `parseAndroidSnapshotHelperOutput(output)` + - `parseAndroidSnapshotHelperXml(xml, metadata?, options?, maxNodes?)` + - `prepareAndroidSnapshotHelperArtifactFromManifestUrl(options)` + - `verifyAndroidSnapshotHelperArtifact(artifact)` + - types: `AndroidAdbExecutor`, `AndroidSnapshotHelperArtifact`, `AndroidSnapshotHelperManifest`, `AndroidSnapshotHelperOutput`, `AndroidSnapshotHelperParsedSnapshot` - `agent-device/android-adb` - `createAndroidPortReverseManager(provider)` - `captureAndroidLogcatWithAdb(executor, options?)` @@ -62,7 +70,7 @@ Public subpath API exposed for Node consumers: - `getAndroidAppStateWithAdb(executor)` - types: `AndroidAdbExecutor`, `AndroidAdbExecutorOptions`, `AndroidPortReverseEndpoint` -The `contracts`, `selectors`, `finders`, `install-source`, `android-adb`, `artifacts`, `batch`, `metro`, `remote-config`, and `io` subpaths are the supported Node entry points. The former compatibility subpaths `agent-device/android-apps` and `agent-device/daemon`, plus hosted-runtime subpaths `agent-device/commands`, `agent-device/backend`, `agent-device/testing/conformance`, and `agent-device/observability`, are no longer published. +The `contracts`, `selectors`, `finders`, `install-source`, `android-adb`, `artifacts`, `batch`, `metro`, `remote-config`, and `io` subpaths are the supported Node entry points. The former compatibility subpaths `agent-device/android-apps` and `agent-device/daemon`, plus hosted-runtime subpaths `agent-device/cloud-webdriver`, `agent-device/commands`, `agent-device/backend`, `agent-device/testing/conformance`, and `agent-device/observability`, are not published. ## Basic usage @@ -106,6 +114,42 @@ standalone Simulator app. `client.sessions.stateDir()` mirrors `session state-dir` and returns the resolved daemon state directory as a pure local resolution — it never starts or contacts the daemon. Pass `{ stateDir }` to resolve an explicit override the same way the CLI resolves `--state-dir`. +`client.sessions.artifacts({ provider, providerSessionId })` mirrors `artifacts --provider ... --provider-session ...` and returns provider-hosted `cloudArtifacts`. +Use it for BrowserStack or AWS Device Farm session videos/logs after a cloud session has stopped, or omit `providerSessionId` when an embedding host has registered a cloud runtime that can infer the active lease. + +```ts +const result = await client.sessions.artifacts({ + provider: 'aws-device-farm', + providerSessionId: 'arn:aws:devicefarm:us-west-2:123:session/project/session/00000', +}); + +for (const artifact of result.cloudArtifacts) { + console.log(artifact.kind, artifact.name, artifact.url); +} +``` + +## Device cloud sessions + +BrowserStack and AWS Device Farm can be driven through the normal typed client methods. Use the CLI `connect browserstack` / `connect aws-device-farm` flow when you want persisted local connection state. Use direct client config when a Node integration already owns credentials and provider selectors. + +```ts +import { createAgentDeviceClient } from 'agent-device'; + +const client = createAgentDeviceClient({ + leaseProvider: 'browserstack', + platform: 'android', + device: 'Google Pixel 8', + providerOsVersion: '14.0', + providerApp: 'bs://app-id', +}); + +await client.apps.open({ app: 'com.example.app' }); +await client.capture.snapshot({ interactiveOnly: true }); +const closed = await client.sessions.close(); +``` + +Use `client.sessions.artifacts({ provider, providerSessionId })` with `closed.provider?.providerSessionId` to fetch hosted video/log URLs after close. See [Device Clouds & Farms](/docs/device-clouds) for BrowserStack, AWS Device Farm, CLI, JavaScript, and MCP flows. + ## Web sessions Typed client commands can target browser sessions with the same command methods by passing @@ -143,10 +187,7 @@ only when the provider supports `adb reverse` argument semantics. The manager ma idempotent for the same owner and rejects conflicting owners for the same local endpoint. ```ts -import { - getAndroidAppStateWithAdb, - listAndroidAppsWithAdb, -} from 'agent-device/android-adb'; +import { getAndroidAppStateWithAdb, listAndroidAppsWithAdb } from 'agent-device/android-adb'; const provider = { exec: async (args, options) => await runAdbThroughRemoteTunnel(args, options), @@ -306,25 +347,40 @@ Direct Android `.apk` and `.aab` URL sources can still resolve package identity ## Remote Metro helpers ```ts -import { - buildBundleUrl, - normalizeBaseUrl, - resolveRuntimeTransport, -} from 'agent-device/metro'; +import { prepareRemoteMetro, reloadRemoteMetro, stopMetroTunnel } from 'agent-device/metro'; +import { resolveRemoteConfigProfile } from 'agent-device/remote-config'; -const bridgeBaseUrl = normalizeBaseUrl('https://bridge.example.test/metro'); -const iosBundleUrl = buildBundleUrl(bridgeBaseUrl, 'ios'); -const androidBundleUrl = buildBundleUrl(bridgeBaseUrl, 'android'); +const remoteConfig = resolveRemoteConfigProfile({ + configPath: './agent-device.remote.json', + cwd: process.cwd(), +}); -const transport = resolveRuntimeTransport({ - platform: 'ios', - bundleUrl: iosBundleUrl, +const prepared = await prepareRemoteMetro({ + projectRoot: remoteConfig.profile.metroProjectRoot!, + kind: remoteConfig.profile.metroKind ?? 'auto', + proxyBaseUrl: remoteConfig.profile.metroProxyBaseUrl, + proxyBearerToken: remoteConfig.profile.metroBearerToken, + bridgeScope: { + tenantId: remoteConfig.profile.tenant!, + runId: remoteConfig.profile.runId!, + leaseId: remoteConfig.profile.leaseId!, + }, + profileKey: remoteConfig.resolvedPath, +}); + +console.log(prepared.iosRuntime, prepared.androidRuntime); + +await reloadRemoteMetro({ + runtime: prepared.iosRuntime, }); -console.log(iosBundleUrl, androidBundleUrl, transport); +await stopMetroTunnel({ + projectRoot: remoteConfig.profile.metroProjectRoot!, + profileKey: remoteConfig.resolvedPath, +}); ``` -Use `agent-device/remote-config` for the `RemoteConfigProfile` type, `agent-device/metro` for URL and transport helpers, and `agent-device/contracts` when a server consumer needs daemon request or runtime contract types. For bridged remote Metro, the bridge descriptor supplies cloud iOS wildcard HTTPS hints and Android runtime-route hints. +Use `agent-device/remote-config` for profile loading and path resolution, `agent-device/metro` for Metro preparation, reload, and tunnel lifecycle, and `agent-device/contracts` when a server consumer needs daemon request or runtime contract types. For bridged remote Metro, `proxyBaseUrl` is the bridge origin and `publicBaseUrl` is optional; the bridge descriptor supplies cloud iOS wildcard HTTPS hints and Android runtime-route hints. `reloadRemoteMetro()` calls Metro's `/reload` endpoint, matching the terminal `r` reload path for connected React Native apps. ## Selector helpers diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 87560a642..c7b9044e7 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -955,6 +955,20 @@ agent-device session list --json - `session list` shows active daemon sessions for the caller's implicit workspace scope, or the explicitly named session scope when `--session` / `AGENT_DEVICE_SESSION` is configured. - Use `--json` when you want to inspect or script against the raw session metadata. +## Cloud provider artifacts + +```bash +agent-device artifacts --provider browserstack --provider-session --json +agent-device artifacts --provider aws-device-farm --provider-session --json +``` + +- `artifacts` lists provider-hosted cloud artifacts such as videos, Appium logs, device logs, automation logs, and provider dashboard links. +- The response uses `cloudArtifacts` so it stays separate from daemon-managed local `artifacts` returned by screenshot, recording, install, replay, and remote materialization flows. +- Plain text output prints ready provider URLs. Use `--json` when scripts need the structured `cloudArtifacts` array. +- Historical lookup requires `--provider-session ` plus `--provider `. BrowserStack expects `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY`; AWS Device Farm uses the AWS CLI credential chain and infers the region from the session ARN when possible. +- When a cloud runtime is registered in-process by an embedding host, `artifacts` can infer the active provider session from the current lease before disconnect. +- `disconnect --json` and `close --json` include provider release data when the runtime returns final cloud artifacts after session teardown. Some providers only finalize video/log URLs after the remote session is stopped, so retry `agent-device artifacts --provider --json` if the first response is `pending`. + ## iOS physical-device prerequisites For CLI-discoverable setup guidance, run `agent-device help physical-device`. diff --git a/website/docs/docs/security-trust.md b/website/docs/docs/security-trust.md index 77caa0d8a..36905d74e 100644 --- a/website/docs/docs/security-trust.md +++ b/website/docs/docs/security-trust.md @@ -26,7 +26,9 @@ For remote or cloud deployments, the daemon supports a custom auth hook: `AGENT_ ## Sensitive artifacts -Screenshots, recordings, traces, logs, network dumps, replay files, and reports can contain private UI state, credentials, tokens, request data, or customer information. Store them in a controlled directory, review before sharing, and avoid committing artifacts unless they are intentionally sanitized fixtures. +Screenshots, recordings, traces, logs, network dumps, replay files, provider-hosted cloud videos/logs, and reports can contain private UI state, credentials, tokens, request data, or customer information. Store them in a controlled directory, review before sharing, and avoid committing artifacts unless they are intentionally sanitized fixtures. + +Cloud provider artifact URLs returned by `artifacts`, `close --json`, or `disconnect --json` may be provider dashboard URLs, public share links, or pre-signed download URLs. Treat the URLs themselves as sensitive credentials until you know the provider's sharing and expiry policy. ## Permissions From 50c93adc19f140220439749e1ecaa003f65fde11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 09:53:07 +0200 Subject: [PATCH 02/17] fix: clean up local session after provider release failure --- .../__tests__/session-close-shutdown.test.ts | 52 +++++++++++++++++++ src/daemon/handlers/session-close.ts | 12 +++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index 98c206a77..dbd939162 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -50,6 +50,7 @@ vi.mock('../session-device-utils.ts', async (importOriginal) => { import { handleSessionCommands } from '../session.ts'; import { teardownSessionResources } from '../../session-teardown.ts'; +import { LeaseRegistry } from '../../lease-registry.ts'; import { shutdownSimulator } from '../../../platforms/ios/simulator.ts'; import { runCmd } from '../../../utils/exec.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; @@ -421,6 +422,57 @@ test('close dispatches web session cleanup without a positional target', async ( expect(sessionStore.get(sessionName)).toBeUndefined(); }); +test('close deletes local session when provider lease release fails', async () => { + const sessionStore = makeSessionStore(); + const leaseRegistry = new LeaseRegistry(); + const sessionName = 'provider-release-failure-session'; + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'browserstack', + deviceKey: 'ios:bs-device', + clientId: 'client-a', + }); + sessionStore.set(sessionName, { + ...makeSession(sessionName, WEB_DESKTOP_DEVICE), + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + expiresAt: lease.expiresAt, + }, + }); + + await expect( + handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + leaseRegistry, + leaseLifecycleProvider: { + release: async () => { + throw new AppError('COMMAND_FAILED', 'provider cleanup failed'); + }, + }, + invoke: noopInvoke, + }), + ).rejects.toThrow('provider cleanup failed'); + + expect(sessionStore.get(sessionName)).toBeUndefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + test('daemon session teardown stops active Android native perf capture', async () => { const sessionName = 'android-active-native-perf-teardown-session'; const activeCapture = { diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 55384e580..26763bbd5 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -111,11 +111,13 @@ export async function handleCloseCommand(params: { sessionStore.writeSessionLog(session); await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); } finally { - // Always release the device lease and drop the session, even if teardown - // above threw: a failed close must not strand device ownership until the - // inactivity expiry. The original error still propagates after finally. - providerData = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider }); - sessionStore.delete(sessionName); + // Always drop the local session, even if provider-side release fails: + // a failed close must not strand device ownership until inactivity expiry. + try { + providerData = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider }); + } finally { + sessionStore.delete(sessionName); + } } const shutdownResult = await maybeShutdownSessionTarget({ device: session.device, From 2ef86e24101ec9894a6358832736910800400fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 10:03:24 +0200 Subject: [PATCH 03/17] fix: tag cloud webdriver provider requests --- src/cloud-webdriver/browserstack.ts | 3 ++ src/cloud-webdriver/request-headers.ts | 10 ++++++ src/cloud-webdriver/webdriver-client.ts | 2 ++ .../cloud-webdriver-provider-adapters.test.ts | 36 +++++++++++++++---- .../cloud-webdriver-runtime.test.ts | 13 ++++++- 5 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 src/cloud-webdriver/request-headers.ts diff --git a/src/cloud-webdriver/browserstack.ts b/src/cloud-webdriver/browserstack.ts index f5f0e6bc0..9f91a58a7 100644 --- a/src/cloud-webdriver/browserstack.ts +++ b/src/cloud-webdriver/browserstack.ts @@ -15,6 +15,7 @@ import type { ProviderDeviceRuntime } from '../provider-device-runtime.ts'; import type { DeviceLease } from '../daemon/lease-registry.ts'; import { AppError } from '../kernel/errors.ts'; import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; +import { agentDeviceRequestHeaders } from './request-headers.ts'; const BROWSERSTACK_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.browserStack; const BROWSERSTACK_APP_AUTOMATE_ENDPOINT = 'https://hub-cloud.browserstack.com/wd/hub/'; @@ -136,6 +137,7 @@ export async function uploadBrowserStackApp( const response = await fetch(options.endpoint ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT, { method: 'POST', headers: { + ...agentDeviceRequestHeaders(), Authorization: browserStackAuthHeader(options), }, body: form, @@ -208,6 +210,7 @@ async function fetchBrowserStackSessionDetails( ); const response = await fetch(endpoint, { headers: { + ...agentDeviceRequestHeaders(), Authorization: browserStackAuthHeader(options), }, }); diff --git a/src/cloud-webdriver/request-headers.ts b/src/cloud-webdriver/request-headers.ts new file mode 100644 index 000000000..da4480b7a --- /dev/null +++ b/src/cloud-webdriver/request-headers.ts @@ -0,0 +1,10 @@ +import { readVersion } from '../utils/version.ts'; + +const AGENT_DEVICE_CLIENT_HEADER = 'agent-device-cli'; + +export function agentDeviceRequestHeaders(): Record { + return { + 'x-agent-device-client': AGENT_DEVICE_CLIENT_HEADER, + 'x-agent-device-version': readVersion(), + }; +} diff --git a/src/cloud-webdriver/webdriver-client.ts b/src/cloud-webdriver/webdriver-client.ts index ba82a5782..75c71d82b 100644 --- a/src/cloud-webdriver/webdriver-client.ts +++ b/src/cloud-webdriver/webdriver-client.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import { AppError } from '../kernel/errors.ts'; +import { agentDeviceRequestHeaders } from './request-headers.ts'; export type WebDriverAuth = { username: string; @@ -49,6 +50,7 @@ export class WebDriverClient { constructor(options: WebDriverClientOptions) { this.endpoint = withTrailingSlash(new URL(options.endpoint)); this.headers = { + ...agentDeviceRequestHeaders(), ...(options.auth ? { Authorization: basicAuth(options.auth) } : {}), ...options.headers, }; diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts index 71a588dc8..793389b1e 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import http, { type IncomingMessage, type ServerResponse } from 'node:http'; +import http, { + type IncomingHttpHeaders, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; import { @@ -22,6 +26,7 @@ import { withProviderScenarioResource, withProviderScenarioTempDir } from './har type HttpCall = { method: string; path: string; + headers: IncomingHttpHeaders; body?: unknown; }; @@ -120,6 +125,7 @@ test('AWS Device Farm adapter selects WebDriver endpoint and stops remote access 'list:arn:aws:devicefarm:session/fake:LOG', ]); assert.equal(server.calls[0]?.path, '/wd/hub/session'); + assertAgentDeviceHeaders(server.calls[0]?.headers); assert.equal(server.calls.at(-1)?.path, '/wd/hub/session/wd-1'); }); }, 15_000); @@ -216,6 +222,7 @@ test('BrowserStack upload helper returns uploaded app reference', async () => { }); assert.equal(appUrl, 'bs://uploaded-app'); assert.equal(server.calls[0]?.path, '/app-automate/upload'); + assertAgentDeviceHeaders(server.calls[0]?.headers); }); }); }, 15_000); @@ -326,7 +333,7 @@ test('AWS CLI Device Farm client maps remote access commands', async () => { }); function assertBrowserStackCalls(calls: readonly HttpCall[], lease: DeviceLease): void { - assert.equal(calls[0]?.path, '/wd/hub/session'); + assertCallPathAndHeaders(calls, 0, '/wd/hub/session'); assert.deepEqual(calls[0]?.body, { capabilities: { alwaysMatch: { @@ -343,11 +350,27 @@ function assertBrowserStackCalls(calls: readonly HttpCall[], lease: DeviceLease) }, }, }); - assert.equal(calls[1]?.path, '/app-automate/upload'); - assert.equal(calls[2]?.path, '/wd/hub/session/wd-1/appium/device/install_app'); + assertCallPathAndHeaders(calls, 1, '/app-automate/upload'); + assertCallPathAndHeaders(calls, 2, '/wd/hub/session/wd-1/appium/device/install_app'); assert.deepEqual(calls[2]?.body, { appPath: 'bs://uploaded-app' }); - assert.equal(calls.at(-2)?.path, '/wd/hub/session/wd-1'); - assert.equal(calls.at(-1)?.path, '/app-automate/sessions/wd-1.json'); + assertCallPathAndHeaders(calls, -2, '/wd/hub/session/wd-1'); + assertCallPathAndHeaders(calls, -1, '/app-automate/sessions/wd-1.json'); +} + +function assertCallPathAndHeaders( + calls: readonly HttpCall[], + index: number, + expectedPath: string, +): void { + const call = index < 0 ? calls.at(index) : calls[index]; + assert.equal(call?.path, expectedPath); + assertAgentDeviceHeaders(call?.headers); +} + +function assertAgentDeviceHeaders(headers: IncomingHttpHeaders | undefined): void { + assert.equal(headers?.['x-agent-device-client'], 'agent-device-cli'); + assert.equal(typeof headers?.['x-agent-device-version'], 'string'); + assert.notEqual(headers?.['x-agent-device-version'], ''); } class FakeAwsDeviceFarmClient implements AwsDeviceFarmClient { @@ -456,6 +479,7 @@ class FakeCloudProviderServer { this.calls.push({ method: req.method ?? 'GET', path: req.url ?? '/', + headers: req.headers, ...(body === undefined ? {} : { body }), }); this.respond(req, res); diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 776f3f1a9..73f0fffd1 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import http, { type IncomingMessage, type ServerResponse } from 'node:http'; +import http, { + type IncomingHttpHeaders, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; import { createCloudWebDriverRuntime } from '../../../src/cloud-webdriver.ts'; @@ -20,6 +24,7 @@ const WEBDRIVER_PROVIDER = 'webdriver-fake'; type WebDriverHttpCall = { method: string; path: string; + headers: IncomingHttpHeaders; body?: unknown; }; @@ -267,6 +272,11 @@ function assertWebDriverCalls( args: [{ appId: 'com.example.demo', bundleId: 'com.example.demo' }], }); assert.deepEqual(calls[7]?.body, { text: 'hello cloud', value: Array.from('hello cloud') }); + for (const call of calls) { + assert.equal(call.headers['x-agent-device-client'], 'agent-device-cli'); + assert.equal(typeof call.headers['x-agent-device-version'], 'string'); + assert.notEqual(call.headers['x-agent-device-version'], ''); + } } class FakeWebDriverServer { @@ -302,6 +312,7 @@ class FakeWebDriverServer { const call: WebDriverHttpCall = { method: req.method ?? 'GET', path: req.url ?? '/', + headers: req.headers, }; const body = await readJsonBody(req); if (body !== undefined) call.body = body; From 8f0b257dd815bf871a7b78d39a58e00efc987693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 11:29:57 +0200 Subject: [PATCH 04/17] feat: connect hosted webdriver providers --- src/__tests__/cloud-connect-profile.test.ts | 112 ++++++ src/__tests__/remote-connection.test.ts | 2 +- src/cli.ts | 10 + src/cli/commands/connection-runtime.ts | 3 +- src/cli/commands/connection.ts | 60 ++- src/cli/parser/cli-flags.ts | 90 ++++- src/cli/parser/cli-help.ts | 25 +- src/cli/provider-connection-profile.ts | 159 ++++++++ src/client/client-normalizers.ts | 10 + src/client/client-types.ts | 50 ++- src/cloud-webdriver/provider-runtimes.ts | 361 ++++++++++++++++++ src/cloud-webdriver/providers.ts | 8 + src/daemon-runtime.ts | 36 +- src/daemon/handlers/lease.ts | 25 +- src/provider-device-runtime.ts | 14 +- src/remote/remote-config-schema.ts | 28 ++ src/utils/__tests__/args.test.ts | 74 ++++ src/utils/cli-command-overrides.ts | 13 +- .../cloud-webdriver-runtime.test.ts | 76 ++++ 19 files changed, 1111 insertions(+), 45 deletions(-) create mode 100644 src/cli/provider-connection-profile.ts create mode 100644 src/cloud-webdriver/provider-runtimes.ts diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 5cb04b44b..ac2e02724 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -133,6 +133,77 @@ test('connect without remote config reports unsupported cloud profile keys', asy } }); +test('connect browserstack generates local provider profile without credentials', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-browserstack-')); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('BROWSERSTACK_USERNAME', 'browser-user'); + vi.stubEnv('BROWSERSTACK_ACCESS_KEY', 'browser-key'); + + try { + await connectWithGeneratedProviderProfile({ + stateDir, + positionals: ['browserstack'], + flags: { + platform: 'android', + device: 'Google Pixel 8', + providerOsVersion: '14.0', + providerApp: 'bs://app-id', + providerProject: 'agent-device', + providerBuild: 'build-a', + }, + }); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.tenant, 'browserstack'); + assert.equal(state.leaseProvider, 'browserstack'); + assert.equal(state.daemon?.baseUrl, undefined); + assert.match(state.remoteConfigPath, /generated\/browserstack-[a-f0-9]{16}\.json$/); + const generated = readGeneratedConfig(state.remoteConfigPath); + assert.equal(generated.providerApp, 'bs://app-id'); + assert.equal(generated.providerOsVersion, '14.0'); + assert.equal(generated.providerProject, 'agent-device'); + assert.equal(generated.providerBuild, 'build-a'); + assert.equal(JSON.stringify(generated).includes('browser-key'), false); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect aws-device-farm generates local provider profile from flags', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-aws-')); + const stateDir = path.join(tempRoot, '.state'); + + try { + await connectWithGeneratedProviderProfile({ + stateDir, + positionals: ['aws-device-farm'], + flags: { + platform: 'ios', + device: 'Apple iPhone 15', + awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project:project-a', + awsDeviceArn: 'arn:aws:devicefarm:us-west-2::device:device-a', + awsAppArn: 'arn:aws:devicefarm:us-west-2:123:upload:app-a', + awsRegion: 'us-west-2', + awsInteractionMode: 'INTERACTIVE', + }, + }); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.tenant, 'aws-device-farm'); + assert.equal(state.leaseProvider, 'aws-device-farm'); + assert.equal(state.daemon?.baseUrl, undefined); + assert.match(state.remoteConfigPath, /generated\/aws-device-farm-[a-f0-9]{16}\.json$/); + const generated = readGeneratedConfig(state.remoteConfigPath); + assert.equal(generated.awsProjectArn, 'arn:aws:devicefarm:us-west-2:123:project:project-a'); + assert.equal(generated.awsDeviceArn, 'arn:aws:devicefarm:us-west-2::device:device-a'); + assert.equal(generated.awsAppArn, 'arn:aws:devicefarm:us-west-2:123:upload:app-a'); + assert.equal(generated.awsRegion, 'us-west-2'); + assert.equal(generated.awsInteractionMode, 'INTERACTIVE'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + function mockCloudConnectionProfile(connection: Record): ReturnType { mockedResolveCloudAccessForConnect.mockResolvedValue({ accessToken: 'adc_agent_cloud', @@ -197,15 +268,56 @@ async function connectWithGeneratedCloudProfile(stateDir: string): Promise } } +async function connectWithGeneratedProviderProfile(options: { + stateDir: string; + positionals: string[]; + flags: Partial[0]['flags']>; +}): Promise { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await connectCommand({ + positionals: options.positionals, + flags: { + json: true, + help: false, + version: false, + stateDir: options.stateDir, + ...options.flags, + }, + client: {} as AgentDeviceClient, + }); + } finally { + stdoutWrite.mockRestore(); + } +} + function readGeneratedConfig(configPath: string): { tenant?: string; leaseProvider?: string; clientId?: string; + providerApp?: string; + providerOsVersion?: string; + providerProject?: string; + providerBuild?: string; + awsProjectArn?: string; + awsDeviceArn?: string; + awsAppArn?: string; + awsRegion?: string; + awsInteractionMode?: string; } { return JSON.parse(fs.readFileSync(configPath, 'utf8')) as { tenant?: string; leaseProvider?: string; clientId?: string; + providerApp?: string; + providerOsVersion?: string; + providerProject?: string; + providerBuild?: string; + awsProjectArn?: string; + awsDeviceArn?: string; + awsAppArn?: string; + awsRegion?: string; + awsInteractionMode?: string; }; } diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 1db15781e..c32382116 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -398,7 +398,7 @@ test('connect proxy rejects remote-config and unknown provider combinations', as }, client: createTestClient(), }), - /Supported providers: proxy/, + /Supported providers: cloud, proxy, browserstack, aws-device-farm/, ); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli.ts b/src/cli.ts index a6a1d5652..8d3d54432 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -247,6 +247,16 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): leaseProvider: connection?.leaseProvider, clientId: connection?.clientId, deviceKey: connection?.deviceKey, + providerApp: currentFlags.providerApp, + providerOsVersion: currentFlags.providerOsVersion, + providerProject: currentFlags.providerProject, + providerBuild: currentFlags.providerBuild, + providerSessionName: currentFlags.providerSessionName, + awsProjectArn: currentFlags.awsProjectArn, + awsDeviceArn: currentFlags.awsDeviceArn, + awsAppArn: currentFlags.awsAppArn, + awsRegion: currentFlags.awsRegion, + awsInteractionMode: currentFlags.awsInteractionMode, runtime, lockPolicy: binding.lockPolicy, lockPlatform: binding.defaultPlatform, diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 429a995e5..3a61a2d5c 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -22,6 +22,7 @@ import type { CliFlags } from '../parser/cli-flags.ts'; import type { AgentDeviceClient, Lease } from '../../client/client.ts'; import type { MetroPrepareKind } from '../../metro/client-metro.ts'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { isCloudWebDriverProviderName } from '../../cloud-webdriver/providers.ts'; const leaseDeferredCommands = new Set([ 'connect', @@ -604,7 +605,7 @@ function createRemoteConnectionStateFromFlags( 'remote command requires runId in remote config or via --run-id .', ); } - if (!flags.daemonBaseUrl) { + if (!flags.daemonBaseUrl && !isCloudWebDriverProviderName(profile.leaseProvider)) { throw new AppError( 'INVALID_ARGS', 'remote command requires daemonBaseUrl in remote config, config, env, or --daemon-base-url.', diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 0edac9ac7..7fa97f3ce 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -14,6 +14,12 @@ import { } from '../../remote/remote-connection-state.ts'; import { AppError } from '../../kernel/errors.ts'; import { resolveCloudConnectProfile } from '../cloud-connection-profile.ts'; +import { + CLOUD_WEBDRIVER_PROVIDERS, + isCloudWebDriverProviderName, + type CloudWebDriverKnownProviderName, +} from '../../cloud-webdriver/providers.ts'; +import { resolveCloudWebDriverConnectProfile } from '../provider-connection-profile.ts'; import { resolveProxyConnectProfile } from '../proxy-connection-profile.ts'; import { hasDeferredMetroConfig, @@ -35,7 +41,7 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, const resolved = await resolveConnectProfile({ provider, flags, stateDir }); const connectFlags = resolved.flags; const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath); - const scope = readRequiredConnectScope(connectFlags); + const scope = readRequiredConnectScope(connectFlags, connectionMetadata); const context = resolveConnectContext({ stateDir, flags: connectFlags, @@ -77,13 +83,22 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, }; async function resolveConnectProfile(options: { - provider?: 'proxy'; + provider?: ConnectProvider; flags: CliFlags; stateDir: string; }): Promise<{ flags: CliFlags; remoteConfigPath: string }> { const { provider, flags, stateDir } = options; if (flags.remoteConfig) return resolveRemoteConnectFlags(flags); - if (provider === 'proxy' || shouldUseProxyConnectShortcut(flags)) { + if (isCloudWebDriverProviderName(provider)) { + return resolveCloudWebDriverConnectProfile({ + provider, + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); + } + if (provider === 'proxy' || (!provider && shouldUseProxyConnectShortcut(flags))) { return resolveProxyConnectProfile({ flags, stateDir, @@ -99,7 +114,9 @@ async function resolveConnectProfile(options: { }); } -function assertConnectProviderUsage(provider: 'proxy' | undefined, flags: CliFlags): void { +type ConnectProvider = 'cloud' | 'proxy' | CloudWebDriverKnownProviderName; + +function assertConnectProviderUsage(provider: ConnectProvider | undefined, flags: CliFlags): void { if (!provider || !flags.remoteConfig) return; throw new AppError( 'INVALID_ARGS', @@ -107,7 +124,10 @@ function assertConnectProviderUsage(provider: 'proxy' | undefined, flags: CliFla ); } -function readRequiredConnectScope(flags: CliFlags): { tenant: string; runId: string } { +function readRequiredConnectScope( + flags: CliFlags, + connectionMetadata: RemoteConnectionRequestMetadata | undefined, +): { tenant: string; runId: string } { if (!flags.tenant) { throw new AppError( 'INVALID_ARGS', @@ -120,7 +140,7 @@ function readRequiredConnectScope(flags: CliFlags): { tenant: string; runId: str 'connect requires runId in remote config or via --run-id .', ); } - if (!flags.daemonBaseUrl) { + if (!flags.daemonBaseUrl && !isLocalProviderConnection(connectionMetadata?.leaseProvider)) { throw new AppError( 'INVALID_ARGS', 'connect requires daemonBaseUrl in remote config, config, env, or --daemon-base-url.', @@ -281,6 +301,10 @@ function readRemoteConfigConnectionMetadata( return Object.values(metadata).some((value) => value !== undefined) ? metadata : undefined; } +function isLocalProviderConnection(provider: string | undefined): boolean { + return isCloudWebDriverProviderName(provider); +} + export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { const { session, stateDir, state } = readRequestedConnectionState(flags); if (!state) { @@ -357,16 +381,18 @@ function createRemoteSessionName(stateDir: string): string { return `adc-${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}`; } -function readConnectProvider(positionals: string[]): 'proxy' | undefined { +function readConnectProvider(positionals: string[]): ConnectProvider | undefined { const provider = positionals[0]; if (provider === undefined) return undefined; if (positionals.length > 1) { throw new AppError('INVALID_ARGS', 'connect accepts at most one provider positional.'); } - if (provider === 'proxy') return provider; + if (provider === 'cloud' || provider === 'proxy' || isCloudWebDriverProviderName(provider)) { + return provider; + } throw new AppError( 'INVALID_ARGS', - `Unknown connect provider: ${provider}. Supported providers: proxy.`, + `Unknown connect provider: ${provider}. Supported providers: cloud, proxy, ${CLOUD_WEBDRIVER_PROVIDERS.browserStack}, ${CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm}.`, ); } @@ -510,6 +536,21 @@ function buildLeasePreparationNotice( 'Proxy lease allocation is pending; run open when ready to allocate or refresh the device lease. Devices can inspect inventory but do not allocate a proxy lease.', }; } + if (isLocalProviderConnection(state.leaseProvider)) { + const nextSteps = [ + 'agent-device open --relaunch', + 'agent-device snapshot -i', + 'agent-device artifacts --json', + 'agent-device close', + ]; + return { + status: 'deferred', + nextSteps, + message: + `Hosted ${state.leaseProvider} lease allocation is pending; run open when ready to create the provider session. ` + + `After close, run "agent-device artifacts --json" to fetch provider video/log links when available.`, + }; + } const needsPlatform = state.platform === undefined && state.leaseBackend === undefined ? ' Add --platform ios|android if the profile does not set a platform.' @@ -572,6 +613,7 @@ function serializeConnectionState( leaseAllocated: Boolean(state.leaseId), leaseId: state.leaseId, leaseBackend: state.leaseBackend, + leaseProvider: state.leaseProvider, platform: state.platform, target: state.target, remoteConfig: state.remoteConfigPath, diff --git a/src/cli/parser/cli-flags.ts b/src/cli/parser/cli-flags.ts index 96762c9f9..9dfbbf203 100644 --- a/src/cli/parser/cli-flags.ts +++ b/src/cli/parser/cli-flags.ts @@ -19,7 +19,10 @@ import { type SessionRuntimeHints, type SessionIsolationMode, } from '../../kernel/contracts.ts'; -import type { RemoteConfigMetroOptions } from '../../remote/remote-config-schema.ts'; +import type { + CloudProviderProfileFields, + RemoteConfigMetroOptions, +} from '../../remote/remote-config-schema.ts'; import { SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS, type ScreenshotRequestFlags, @@ -30,7 +33,8 @@ import { formatMaestroSupportedSubsetForCli, } from '../../compat/maestro/support-matrix.ts'; -export type CliFlags = RemoteConfigMetroOptions & +export type CliFlags = CloudProviderProfileFields & + RemoteConfigMetroOptions & ScreenshotRequestFlags & { json: boolean; config?: string; @@ -327,6 +331,78 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--provider-session ', usageDescription: 'Cloud provider session id or ARN', }, + { + key: 'providerApp', + names: ['--provider-app'], + type: 'string', + usageLabel: '--provider-app ', + usageDescription: + 'Cloud provider app reference or local app path used when creating hosted WebDriver sessions', + }, + { + key: 'providerOsVersion', + names: ['--provider-os-version', '--os-version'], + type: 'string', + usageLabel: '--provider-os-version ', + usageDescription: 'Hosted cloud provider OS version, for example 17 or 14.0', + }, + { + key: 'providerProject', + names: ['--provider-project'], + type: 'string', + usageLabel: '--provider-project ', + usageDescription: 'Hosted cloud provider project label', + }, + { + key: 'providerBuild', + names: ['--provider-build'], + type: 'string', + usageLabel: '--provider-build ', + usageDescription: 'Hosted cloud provider build label', + }, + { + key: 'providerSessionName', + names: ['--provider-session-name'], + type: 'string', + usageLabel: '--provider-session-name ', + usageDescription: 'Hosted cloud provider session label', + }, + { + key: 'awsProjectArn', + names: ['--aws-project-arn'], + type: 'string', + usageLabel: '--aws-project-arn ', + usageDescription: 'AWS Device Farm project ARN for hosted WebDriver sessions', + }, + { + key: 'awsDeviceArn', + names: ['--aws-device-arn'], + type: 'string', + usageLabel: '--aws-device-arn ', + usageDescription: 'AWS Device Farm device ARN for hosted WebDriver sessions', + }, + { + key: 'awsAppArn', + names: ['--aws-app-arn'], + type: 'string', + usageLabel: '--aws-app-arn ', + usageDescription: 'AWS Device Farm app ARN attached to hosted remote access sessions', + }, + { + key: 'awsRegion', + names: ['--aws-region'], + type: 'string', + usageLabel: '--aws-region ', + usageDescription: 'AWS region for Device Farm API calls', + }, + { + key: 'awsInteractionMode', + names: ['--aws-interaction-mode'], + type: 'enum', + enumValues: ['INTERACTIVE', 'NO_VIDEO', 'VIDEO_ONLY'], + usageLabel: '--aws-interaction-mode INTERACTIVE|NO_VIDEO|VIDEO_ONLY', + usageDescription: 'AWS Device Farm remote access interaction mode', + }, { key: 'force', names: ['--force'], @@ -1170,6 +1246,16 @@ export const COMMON_COMMAND_SUPPORTED_FLAG_KEYS = flagKeys( 'platform', 'target', 'device', + 'providerApp', + 'providerOsVersion', + 'providerProject', + 'providerBuild', + 'providerSessionName', + 'awsProjectArn', + 'awsDeviceArn', + 'awsAppArn', + 'awsRegion', + 'awsInteractionMode', 'udid', 'serial', 'iosSimulatorDeviceSet', diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 52b49882e..610af59d0 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -653,9 +653,11 @@ Remote connection providers use the same lifecycle: connect -> open -> commands -> close -> disconnect Providers: - Cloud: agent-device connect discovers the cloud profile. + Cloud: agent-device connect or agent-device connect cloud discovers the agent-device cloud profile. Remote config: agent-device connect --remote-config ./remote-config.json uses a local profile. Direct proxy: agent-device connect proxy --daemon-base-url stores the shared proxy profile and client identity. + BrowserStack: agent-device connect browserstack stores a local provider profile and creates the App Automate session on first open. + AWS Device Farm: agent-device connect aws-device-farm stores a local provider profile and creates the remote access session on first open. Direct proxy flow for a remote Mac/simulator: On the Mac with simulator/device access: @@ -675,6 +677,24 @@ Cloud profile flow: agent-device snapshot agent-device disconnect +BrowserStack hosted-device flow: + BROWSERSTACK_USERNAME=... BROWSERSTACK_ACCESS_KEY=... + agent-device connect browserstack --platform android --device "Google Pixel 8" --provider-os-version 14.0 --provider-app bs://app-id + agent-device open com.example.app + agent-device snapshot -i + agent-device close + agent-device artifacts --json + agent-device disconnect + +AWS Device Farm hosted-device flow: + aws login + agent-device connect aws-device-farm --platform android --aws-project-arn --aws-device-arn --aws-app-arn + agent-device open com.example.app + agent-device snapshot -i + agent-device close + agent-device artifacts --json + agent-device disconnect + Local profile flow: agent-device connect --remote-config ./remote-config.json agent-device open com.example.app @@ -691,6 +711,9 @@ Rules: Use connect without --remote-config when the cloud control plane owns the connection profile. Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. + Use BrowserStack and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. + Hosted provider credentials stay in environment variables or the provider CLI. Generated connection profiles store app/device selectors and ARNs, not BrowserStack access keys or AWS credentials. + After closing a hosted provider session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. connect proxy stores the connection profile and client identity. Device leases are acquired on open and expire after five minutes without commands. Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. disconnect releases local connection state; close releases the active session and device lease. diff --git a/src/cli/provider-connection-profile.ts b/src/cli/provider-connection-profile.ts new file mode 100644 index 000000000..ee1d558be --- /dev/null +++ b/src/cli/provider-connection-profile.ts @@ -0,0 +1,159 @@ +import crypto from 'node:crypto'; +import { CLOUD_WEBDRIVER_PROVIDERS } from '../cloud-webdriver/providers.ts'; +import type { CloudWebDriverKnownProviderName } from '../cloud-webdriver/providers.ts'; +import type { RemoteConfigProfile } from '../remote-config-schema.ts'; +import { AppError } from '../kernel/errors.ts'; +import type { PlatformSelector } from '../kernel/device.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; +import type { EnvMap } from '../utils/env-map.ts'; +import { persistAndResolveGeneratedProfile } from './generated-remote-config.ts'; +import { resolveRequestedLeaseBackend } from './commands/connection-runtime.ts'; + +export function resolveCloudWebDriverConnectProfile(options: { + provider: CloudWebDriverKnownProviderName; + flags: CliFlags; + stateDir: string; + cwd: string; + env?: EnvMap; +}): { flags: CliFlags; remoteConfigPath: string } { + const providerConfig = + options.provider === CLOUD_WEBDRIVER_PROVIDERS.browserStack + ? browserStackProfileFields(options) + : awsDeviceFarmProfileFields(options); + const clientId = buildCloudWebDriverClientId( + options.provider, + options.stateDir, + options.flags.session, + providerConfig.device, + ); + const profile: RemoteConfigProfile = { + tenant: options.flags.tenant ?? options.provider, + sessionIsolation: options.flags.sessionIsolation ?? 'tenant', + runId: options.flags.runId ?? `${options.provider}-${clientId}`, + leaseProvider: options.provider, + clientId, + leaseBackend: options.flags.leaseBackend ?? resolveRequestedLeaseBackend(options.flags), + target: options.flags.target ?? 'mobile', + session: options.flags.session, + ...providerConfig, + metroProjectRoot: options.flags.metroProjectRoot, + metroKind: options.flags.metroKind, + metroPublicBaseUrl: options.flags.metroPublicBaseUrl, + metroProxyBaseUrl: options.flags.metroProxyBaseUrl, + metroPreparePort: options.flags.metroPreparePort, + metroListenHost: options.flags.metroListenHost, + metroStatusHost: options.flags.metroStatusHost, + metroStartupTimeoutMs: options.flags.metroStartupTimeoutMs, + metroProbeTimeoutMs: options.flags.metroProbeTimeoutMs, + metroRuntimeFile: options.flags.metroRuntimeFile, + metroNoReuseExisting: options.flags.metroNoReuseExisting, + metroNoInstallDeps: options.flags.metroNoInstallDeps, + }; + return persistAndResolveGeneratedProfile({ + stateDir: options.stateDir, + provider: options.provider, + profile, + cwd: options.cwd, + env: options.env, + flags: options.flags, + }); +} + +function browserStackProfileFields(options: { + flags: CliFlags; + env?: EnvMap; +}): RemoteConfigProfile { + requireEnv(options.env, 'BROWSERSTACK_USERNAME', 'connect browserstack'); + requireEnv(options.env, 'BROWSERSTACK_ACCESS_KEY', 'connect browserstack'); + const platform = requireCloudWebDriverPlatform( + options.flags.platform, + 'connect browserstack requires --platform ios|android.', + ); + const device = requireFlag( + options.flags.device, + 'connect browserstack requires --device .', + ); + const providerOsVersion = requireFlag( + options.flags.providerOsVersion, + 'connect browserstack requires --provider-os-version .', + ); + const providerApp = requireFlag( + options.flags.providerApp, + 'connect browserstack requires --provider-app .', + ); + return { + platform, + device, + providerOsVersion, + providerApp, + providerProject: options.flags.providerProject, + providerBuild: options.flags.providerBuild, + providerSessionName: options.flags.providerSessionName, + }; +} + +function awsDeviceFarmProfileFields(options: { + flags: CliFlags; + env?: EnvMap; +}): RemoteConfigProfile { + const platform = requireCloudWebDriverPlatform( + options.flags.platform, + 'connect aws-device-farm requires --platform ios|android.', + ); + return { + platform, + device: options.flags.device, + awsProjectArn: requireFlag( + options.flags.awsProjectArn ?? + options.env?.AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN ?? + options.env?.AWS_DEVICE_FARM_PROJECT_ARN, + 'connect aws-device-farm requires --aws-project-arn or AWS_DEVICE_FARM_PROJECT_ARN.', + ), + awsDeviceArn: requireFlag( + options.flags.awsDeviceArn ?? + options.env?.AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN ?? + options.env?.AWS_DEVICE_FARM_DEVICE_ARN, + 'connect aws-device-farm requires --aws-device-arn or AWS_DEVICE_FARM_DEVICE_ARN.', + ), + awsAppArn: + options.flags.awsAppArn ?? + options.env?.AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN ?? + options.env?.AWS_DEVICE_FARM_APP_ARN, + awsRegion: + options.flags.awsRegion ?? options.env?.AWS_REGION ?? options.env?.AWS_DEFAULT_REGION, + awsInteractionMode: options.flags.awsInteractionMode, + providerSessionName: options.flags.providerSessionName, + }; +} + +function requireCloudWebDriverPlatform( + platform: PlatformSelector | undefined, + message: string, +): 'android' | 'ios' { + if (platform === 'android' || platform === 'ios') return platform; + throw new AppError('INVALID_ARGS', message); +} + +function requireFlag(value: string | undefined, message: string): string { + if (value) return value; + throw new AppError('INVALID_ARGS', message); +} + +function requireEnv(env: EnvMap | undefined, name: string, command: string): string { + const value = env?.[name]; + if (value) return value; + throw new AppError('INVALID_ARGS', `${command} requires ${name} in the environment.`); +} + +function buildCloudWebDriverClientId( + provider: CloudWebDriverKnownProviderName, + stateDir: string, + session: string | undefined, + device: string | undefined, +): string { + return crypto + .createHash('sha256') + .update(`${provider}\0${stateDir}\0${session ?? ''}\0${device ?? ''}`) + .digest('hex') + .slice(0, 16); +} diff --git a/src/client/client-normalizers.ts b/src/client/client-normalizers.ts index fb2dcfd0e..2b82a1360 100644 --- a/src/client/client-normalizers.ts +++ b/src/client/client-normalizers.ts @@ -274,6 +274,16 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { ...leaseScopeToCommandFlags(leaseScope), provider: options.provider, providerSessionId: options.providerSessionId, + providerApp: options.providerApp, + providerOsVersion: options.providerOsVersion, + providerProject: options.providerProject, + providerBuild: options.providerBuild, + providerSessionName: options.providerSessionName, + awsProjectArn: options.awsProjectArn, + awsDeviceArn: options.awsDeviceArn, + awsAppArn: options.awsAppArn, + awsRegion: options.awsRegion, + awsInteractionMode: options.awsInteractionMode, sessionIsolation: options.sessionIsolation, platform: options.platform, target: options.target, diff --git a/src/client/client-types.ts b/src/client/client-types.ts index a0c8d6d3f..0ccb25ab0 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -44,7 +44,10 @@ export type { TargetShutdownResult } from '../target-shutdown-contract.ts'; import type { PerfAction, PerfArea, PerfKind, PerfSubject } from '../contracts/perf.ts'; import type { AlertAction, AlertInfo } from '../alert-contract.ts'; import type { DebugSymbolsOptions, DebugSymbolsResult } from '../contracts/debug-symbols.ts'; -import type { RemoteConnectionProfileFields } from '../remote/remote-config-schema.ts'; +import type { + CloudProviderProfileFields, + RemoteConnectionProfileFields, +} from '../remote/remote-config-schema.ts'; import type { CommandResult } from '../core/command-descriptor/command-result.ts'; import type { CloudArtifactsResult } from '../cloud-artifacts.ts'; @@ -69,23 +72,24 @@ export type AgentDeviceDaemonTransport = ( req: Omit, ) => Promise; -export type AgentDeviceClientConfig = RemoteConnectionProfileFields & { - session?: string; - lockPolicy?: DaemonLockPolicy; - lockPlatform?: PlatformSelector; - requestId?: string; - sessionIsolation?: SessionIsolationMode; - leaseBackend?: LeaseBackend; - leaseTtlMs?: number; - runtime?: SessionRuntimeHints; - cwd?: string; - debug?: boolean; - cost?: boolean; - responseLevel?: ResponseLevel; - iosXctestrunFile?: string; - iosXctestDerivedDataPath?: string; - iosXctestEnvDir?: string; -}; +export type AgentDeviceClientConfig = RemoteConnectionProfileFields & + CloudProviderProfileFields & { + session?: string; + lockPolicy?: DaemonLockPolicy; + lockPlatform?: PlatformSelector; + requestId?: string; + sessionIsolation?: SessionIsolationMode; + leaseBackend?: LeaseBackend; + leaseTtlMs?: number; + runtime?: SessionRuntimeHints; + cwd?: string; + debug?: boolean; + cost?: boolean; + responseLevel?: ResponseLevel; + iosXctestrunFile?: string; + iosXctestDerivedDataPath?: string; + iosXctestEnvDir?: string; + }; export type AgentDeviceRequestOverrides = Pick< AgentDeviceClientConfig, @@ -105,6 +109,16 @@ export type AgentDeviceRequestOverrides = Pick< | 'leaseProvider' | 'deviceKey' | 'clientId' + | 'providerApp' + | 'providerOsVersion' + | 'providerProject' + | 'providerBuild' + | 'providerSessionName' + | 'awsProjectArn' + | 'awsDeviceArn' + | 'awsAppArn' + | 'awsRegion' + | 'awsInteractionMode' | 'leaseTtlMs' | 'cwd' | 'debug' diff --git a/src/cloud-webdriver/provider-runtimes.ts b/src/cloud-webdriver/provider-runtimes.ts new file mode 100644 index 000000000..be3466563 --- /dev/null +++ b/src/cloud-webdriver/provider-runtimes.ts @@ -0,0 +1,361 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { + CloudArtifactProvider, + CloudArtifactsQuery, + CloudArtifactsResult, +} from '../cloud-artifacts.ts'; +import type { DeviceInventoryProvider } from '../core/dispatch-resolve.ts'; +import type { Interactor } from '../core/interactor-types.ts'; +import type { LeaseLifecycleContext, LeaseLifecycleProvider } from '../daemon/handlers/lease.ts'; +import type { DeviceLease } from '../daemon/lease-registry.ts'; +import type { DaemonRequest } from '../daemon/types.ts'; +import type { DeviceInfo } from '../kernel/device.ts'; +import { AppError } from '../kernel/errors.ts'; +import { + type ProviderDeviceInstallOptions, + type ProviderDeviceInstallResult, + type ProviderDeviceRuntime, + type ProviderPortReverseOptions, +} from '../provider-device-runtime.ts'; +import { createAwsDeviceFarmWebDriverRuntime } from './aws-device-farm.ts'; +import { createBrowserStackWebDriverRuntime, uploadBrowserStackApp } from './browserstack.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts'; +import type { CloudWebDriverPlatform } from './runtime.ts'; + +export type DefaultCloudWebDriverProviderRuntimeEnv = { + BROWSERSTACK_USERNAME?: string; + BROWSERSTACK_ACCESS_KEY?: string; + BROWSERSTACK_WEBDRIVER_ENDPOINT?: string; + BROWSERSTACK_APP_UPLOAD_ENDPOINT?: string; + BROWSERSTACK_SESSION_DETAILS_ENDPOINT?: string; + AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN?: string; + AWS_DEVICE_FARM_PROJECT_ARN?: string; + AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN?: string; + AWS_DEVICE_FARM_DEVICE_ARN?: string; + AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN?: string; + AWS_DEVICE_FARM_APP_ARN?: string; + AWS_REGION?: string; + AWS_DEFAULT_REGION?: string; +}; + +export function createDefaultCloudWebDriverProviderRuntimes( + env: DefaultCloudWebDriverProviderRuntimeEnv = process.env, +): ProviderDeviceRuntime[] { + return [ + new LazyCloudWebDriverProviderRuntime(CLOUD_WEBDRIVER_PROVIDERS.browserStack, env), + new LazyCloudWebDriverProviderRuntime(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, env), + ]; +} + +class LazyCloudWebDriverProviderRuntime implements ProviderDeviceRuntime { + readonly leaseLifecycle: LeaseLifecycleProvider; + readonly cloudArtifacts: CloudArtifactProvider; + readonly deviceInventoryProvider: DeviceInventoryProvider; + readonly provider: CloudWebDriverKnownProviderName; + + private readonly env: DefaultCloudWebDriverProviderRuntimeEnv; + private readonly runtimesByLeaseId = new Map(); + + constructor( + provider: CloudWebDriverKnownProviderName, + env: DefaultCloudWebDriverProviderRuntimeEnv, + ) { + this.provider = provider; + this.env = env; + this.leaseLifecycle = { + allocate: async (lease, context) => await this.allocate(lease, context), + heartbeat: async (lease, context) => await this.heartbeat(lease, context), + release: async (lease, context) => await this.release(lease, context), + }; + this.cloudArtifacts = { + listCloudArtifacts: async (query) => await this.listCloudArtifacts(query), + }; + this.deviceInventoryProvider = async (request) => { + if (request.leaseProvider !== this.provider) return null; + if (!request.leaseId) return []; + return ( + (await this.runtimesByLeaseId.get(request.leaseId)?.deviceInventoryProvider(request)) ?? [] + ); + }; + } + + ownsDevice(device: DeviceInfo): boolean { + return this.findRuntimeForDevice(device) !== undefined; + } + + getInteractor(device: DeviceInfo): Interactor | undefined { + return this.findRuntimeForDevice(device)?.getInteractor(device); + } + + async installApp( + device: DeviceInfo, + app: string, + appPath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + return await this.findRuntimeForDevice(device)?.installApp?.(device, app, appPath, options); + } + + async installInstallablePath( + device: DeviceInfo, + installablePath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + return await this.findRuntimeForDevice(device)?.installInstallablePath?.( + device, + installablePath, + options, + ); + } + + async configurePortReverse( + options: ProviderPortReverseOptions, + ): Promise | undefined> { + return await this.runtimesByLeaseId.get(options.leaseId)?.configurePortReverse?.(options); + } + + async removePortReverse( + options: ProviderPortReverseOptions, + ): Promise | undefined> { + return await this.runtimesByLeaseId.get(options.leaseId)?.removePortReverse?.(options); + } + + async shutdown(): Promise { + const runtimes = [...this.runtimesByLeaseId.values()]; + this.runtimesByLeaseId.clear(); + await Promise.allSettled(runtimes.map(async (runtime) => await runtime.shutdown())); + } + + private async allocate( + lease: DeviceLease, + context: LeaseLifecycleContext | undefined, + ): Promise | undefined> { + if (lease.leaseProvider !== this.provider) return undefined; + const existing = this.runtimesByLeaseId.get(lease.leaseId); + if (existing) return await existing.leaseLifecycle.heartbeat?.(lease, context); + const runtime = await this.createRuntime(context?.req, lease); + this.runtimesByLeaseId.set(lease.leaseId, runtime); + try { + return await runtime.leaseLifecycle.allocate?.(lease, context); + } catch (error) { + this.runtimesByLeaseId.delete(lease.leaseId); + await runtime.shutdown(); + throw error; + } + } + + private async heartbeat( + lease: DeviceLease, + context: LeaseLifecycleContext | undefined, + ): Promise | undefined> { + if (lease.leaseProvider !== this.provider) return undefined; + return await this.runtimesByLeaseId + .get(lease.leaseId) + ?.leaseLifecycle.heartbeat?.(lease, context); + } + + private async release( + lease: DeviceLease, + context: LeaseLifecycleContext | undefined, + ): Promise | undefined> { + if (lease.leaseProvider !== this.provider) return undefined; + const runtime = this.runtimesByLeaseId.get(lease.leaseId); + if (!runtime) return undefined; + this.runtimesByLeaseId.delete(lease.leaseId); + try { + return await runtime.leaseLifecycle.release?.(lease, context); + } finally { + await runtime.shutdown(); + } + } + + private async listCloudArtifacts( + query: CloudArtifactsQuery, + ): Promise { + if (query.provider !== this.provider) return undefined; + if (!query.leaseId) return undefined; + return await this.runtimesByLeaseId + .get(query.leaseId) + ?.cloudArtifacts?.listCloudArtifacts?.(query); + } + + private findRuntimeForDevice(device: DeviceInfo): ProviderDeviceRuntime | undefined { + return [...this.runtimesByLeaseId.values()].find((runtime) => runtime.ownsDevice(device)); + } + + private async createRuntime( + req: DaemonRequest | undefined, + lease: DeviceLease, + ): Promise { + if (!req) { + throw new AppError( + 'INVALID_ARGS', + `${this.provider} lease allocation requires provider profile flags on the request.`, + ); + } + return this.provider === CLOUD_WEBDRIVER_PROVIDERS.browserStack + ? await this.createBrowserStackRuntime(req, lease) + : this.createAwsDeviceFarmRuntime(req, lease); + } + + private async createBrowserStackRuntime( + req: DaemonRequest, + lease: DeviceLease, + ): Promise { + const username = requireEnv(this.env, 'BROWSERSTACK_USERNAME', 'BrowserStack'); + const accessKey = requireEnv(this.env, 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack'); + const platform = requireRequestPlatform(req, 'BrowserStack'); + const deviceName = requireFlag(req, 'device', 'BrowserStack requires --device .'); + const osVersion = requireFlag( + req, + 'providerOsVersion', + 'BrowserStack requires --provider-os-version .', + ); + const app = await resolveBrowserStackAppReference({ + app: requireFlag( + req, + 'providerApp', + 'BrowserStack requires --provider-app .', + ), + cwd: req.meta?.cwd, + username, + accessKey, + uploadEndpoint: this.env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, + }); + return createBrowserStackWebDriverRuntime({ + username, + accessKey, + platform, + deviceName, + osVersion, + app, + projectName: readFlag(req, 'providerProject'), + buildName: readFlag(req, 'providerBuild') ?? lease.runId, + sessionName: readFlag(req, 'providerSessionName') ?? lease.leaseId, + endpoint: this.env.BROWSERSTACK_WEBDRIVER_ENDPOINT, + uploadEndpoint: this.env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, + sessionDetailsEndpoint: this.env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, + }); + } + + private createAwsDeviceFarmRuntime( + req: DaemonRequest, + lease: DeviceLease, + ): ProviderDeviceRuntime { + const platform = requireRequestPlatform(req, 'AWS Device Farm'); + return createAwsDeviceFarmWebDriverRuntime({ + projectArn: requireAwsValue( + req, + this.env, + 'awsProjectArn', + 'AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN', + 'AWS_DEVICE_FARM_PROJECT_ARN', + ), + deviceArn: requireAwsValue( + req, + this.env, + 'awsDeviceArn', + 'AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN', + 'AWS_DEVICE_FARM_DEVICE_ARN', + ), + appArn: + readFlag(req, 'awsAppArn') ?? + this.env.AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN ?? + this.env.AWS_DEVICE_FARM_APP_ARN, + region: readFlag(req, 'awsRegion') ?? this.env.AWS_REGION ?? this.env.AWS_DEFAULT_REGION, + platform, + deviceName: readFlag(req, 'device') ?? 'AWS Device Farm device', + sessionName: readFlag(req, 'providerSessionName') ?? lease.leaseId, + interactionMode: readAwsInteractionMode(req), + }); + } +} + +async function resolveBrowserStackAppReference(options: { + app: string; + cwd?: string; + username: string; + accessKey: string; + uploadEndpoint?: string; +}): Promise { + if (isProviderAppReference(options.app)) return options.app; + const appPath = path.resolve(options.cwd ?? process.cwd(), options.app); + if (!fs.existsSync(appPath)) { + throw new AppError( + 'INVALID_ARGS', + 'BrowserStack --provider-app must be a bs:// app id, URL, or existing local app path.', + { providerApp: options.app }, + ); + } + return await uploadBrowserStackApp(appPath, { + username: options.username, + accessKey: options.accessKey, + endpoint: options.uploadEndpoint, + }); +} + +function isProviderAppReference(value: string): boolean { + return value.startsWith('bs://') || /^https?:\/\//.test(value); +} + +function requireRequestPlatform(req: DaemonRequest, providerLabel: string): CloudWebDriverPlatform { + const platform = req.flags?.platform; + if (platform === 'android' || platform === 'ios') return platform; + throw new AppError('INVALID_ARGS', `${providerLabel} requires --platform ios|android.`); +} + +function requireFlag( + req: DaemonRequest, + key: keyof NonNullable, + message: string, +): string { + const value = readFlag(req, key); + if (value) return value; + throw new AppError('INVALID_ARGS', message); +} + +function readFlag( + req: DaemonRequest, + key: keyof NonNullable, +): string | undefined { + const value = req.flags?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function requireEnv( + env: DefaultCloudWebDriverProviderRuntimeEnv, + key: keyof DefaultCloudWebDriverProviderRuntimeEnv, + providerLabel: string, +): string { + const value = env[key]; + if (value) return value; + throw new AppError('INVALID_ARGS', `${providerLabel} requires ${key} in the environment.`); +} + +function requireAwsValue( + req: DaemonRequest, + env: DefaultCloudWebDriverProviderRuntimeEnv, + flagKey: keyof NonNullable, + primaryEnv: keyof DefaultCloudWebDriverProviderRuntimeEnv, + fallbackEnv: keyof DefaultCloudWebDriverProviderRuntimeEnv, +): string { + const value = readFlag(req, flagKey) ?? env[primaryEnv] ?? env[fallbackEnv]; + if (value) return value; + throw new AppError( + 'INVALID_ARGS', + `AWS Device Farm requires --${dasherize(String(flagKey))} or ${fallbackEnv}.`, + ); +} + +function readAwsInteractionMode( + req: DaemonRequest, +): 'INTERACTIVE' | 'NO_VIDEO' | 'VIDEO_ONLY' | undefined { + const value = readFlag(req, 'awsInteractionMode'); + if (value === 'INTERACTIVE' || value === 'NO_VIDEO' || value === 'VIDEO_ONLY') return value; + return undefined; +} + +function dasherize(value: string): string { + return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} diff --git a/src/cloud-webdriver/providers.ts b/src/cloud-webdriver/providers.ts index 7fb9c493e..814a0c19a 100644 --- a/src/cloud-webdriver/providers.ts +++ b/src/cloud-webdriver/providers.ts @@ -5,3 +5,11 @@ export const CLOUD_WEBDRIVER_PROVIDERS = { export type CloudWebDriverKnownProviderName = (typeof CLOUD_WEBDRIVER_PROVIDERS)[keyof typeof CLOUD_WEBDRIVER_PROVIDERS]; + +const CLOUD_WEBDRIVER_KNOWN_PROVIDERS = new Set(Object.values(CLOUD_WEBDRIVER_PROVIDERS)); + +export function isCloudWebDriverProviderName( + provider: string | undefined, +): provider is CloudWebDriverKnownProviderName { + return provider !== undefined && CLOUD_WEBDRIVER_KNOWN_PROVIDERS.has(provider); +} diff --git a/src/daemon-runtime.ts b/src/daemon-runtime.ts index aaa3bd6f0..fe1243fed 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -6,6 +6,9 @@ import { resolveDaemonPaths, resolveDaemonServerMode } from './daemon/config.ts' import { createDaemonHttpServer } from './daemon/http-server.ts'; import { trackDownloadableArtifact } from './daemon/artifact-tracking.ts'; import { createDefaultCloudArtifactProvider } from './default-cloud-artifact-provider.ts'; +import { createDefaultCloudWebDriverProviderRuntimes } from './cloud-webdriver/provider-runtimes.ts'; +import { createProviderDeviceRuntimeRequestProviders } from './provider-device-runtime.ts'; +import type { CloudArtifactProvider } from './cloud-artifacts.ts'; import { LeaseRegistry } from './daemon/lease-registry.ts'; import { createRequestHandler } from './daemon/request-router.ts'; import { teardownSessionResources } from './daemon/session-teardown.ts'; @@ -83,6 +86,13 @@ export async function startDaemonRuntime( const token = crypto.randomBytes(24).toString('hex'); const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; const daemonCodeSignature = resolveDaemonCodeSignature(); + const providerDeviceRuntimes = createDefaultCloudWebDriverProviderRuntimes(env); + const providerRuntimeProviders = + createProviderDeviceRuntimeRequestProviders(providerDeviceRuntimes); + const cloudArtifactProvider = composeCloudArtifactProviders( + providerRuntimeProviders.cloudArtifactProvider, + createDefaultCloudArtifactProvider(env), + ); const handleRequest = createRequestHandler({ logPath, @@ -90,7 +100,10 @@ export async function startDaemonRuntime( token, sessionStore, leaseRegistry, - cloudArtifactProvider: createDefaultCloudArtifactProvider(env), + leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, + cloudArtifactProvider, + deviceInventoryProvider: providerRuntimeProviders.deviceInventoryProvider, + providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, trackDownloadableArtifact, }); @@ -225,6 +238,9 @@ export async function startDaemonRuntime( } await closeDaemonServers(servers); await teardownDaemonSessions(); + await Promise.allSettled( + providerDeviceRuntimes.map(async (runtime) => await runtime.shutdown()), + ); const { stopAllIosRunnerSessions } = await import('./platforms/ios/runner-client.ts'); await stopAllIosRunnerSessions(); // Best effort: stop the PNG worker so an in-flight job cannot delay exit. @@ -268,3 +284,21 @@ export async function startDaemonRuntime( token, }; } + +function composeCloudArtifactProviders( + ...providers: Array +): CloudArtifactProvider | undefined { + const activeProviders = providers.filter( + (provider): provider is CloudArtifactProvider => provider !== undefined, + ); + if (activeProviders.length === 0) return undefined; + return { + listCloudArtifacts: async (query) => { + for (const provider of activeProviders) { + const result = await provider.listCloudArtifacts?.(query); + if (result) return result; + } + return undefined; + }, + }; +} diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index cbe78acf9..83927577e 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -12,9 +12,22 @@ import { import { AppError } from '../../kernel/errors.ts'; export type LeaseLifecycleProvider = { - allocate?: (lease: DeviceLease) => Promise | undefined>; - heartbeat?: (lease: DeviceLease) => Promise | undefined>; - release?: (lease: DeviceLease) => Promise | undefined>; + allocate?: ( + lease: DeviceLease, + context?: LeaseLifecycleContext, + ) => Promise | undefined>; + heartbeat?: ( + lease: DeviceLease, + context?: LeaseLifecycleContext, + ) => Promise | undefined>; + release?: ( + lease: DeviceLease, + context?: LeaseLifecycleContext, + ) => Promise | undefined>; +}; + +export type LeaseLifecycleContext = { + req: DaemonRequest; }; type LeaseHandlerArgs = { @@ -48,7 +61,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise | undefined; try { - providerData = await leaseLifecycleProvider?.allocate?.(lease); + providerData = await leaseLifecycleProvider?.allocate?.(lease, { req }); } catch (error) { leaseRegistry.releaseLease( leaseScopeToReleaseRequest({ @@ -70,7 +83,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise await firstProviderResult(runtimes, 'allocate', lease), - heartbeat: async (lease) => await firstProviderResult(runtimes, 'heartbeat', lease), - release: async (lease) => await firstProviderResult(runtimes, 'release', lease), + allocate: async (lease, context) => + await firstProviderResult(runtimes, 'allocate', lease, context), + heartbeat: async (lease, context) => + await firstProviderResult(runtimes, 'heartbeat', lease, context), + release: async (lease, context) => + await firstProviderResult(runtimes, 'release', lease, context), }; } @@ -216,11 +219,12 @@ async function firstProviderResult( runtimes: ProviderDeviceRuntime[], method: keyof LeaseLifecycleProvider, lease: DeviceLease, + context?: LeaseLifecycleContext, ): Promise | undefined> { for (const runtime of runtimes) { if (!runtimeMatchesProvider(runtime, lease.leaseProvider)) continue; const handler = runtime.leaseLifecycle[method]; - const result = handler ? await handler(lease) : undefined; + const result = handler ? await handler(lease, context) : undefined; if (result) return result; } return undefined; diff --git a/src/remote/remote-config-schema.ts b/src/remote/remote-config-schema.ts index 325559dc7..25c66ef22 100644 --- a/src/remote/remote-config-schema.ts +++ b/src/remote/remote-config-schema.ts @@ -40,7 +40,21 @@ export type RemoteConnectionProfileFields = { clientId?: string; }; +export type CloudProviderProfileFields = { + providerApp?: string; + providerOsVersion?: string; + providerProject?: string; + providerBuild?: string; + providerSessionName?: string; + awsProjectArn?: string; + awsDeviceArn?: string; + awsAppArn?: string; + awsRegion?: string; + awsInteractionMode?: 'INTERACTIVE' | 'NO_VIDEO' | 'VIDEO_ONLY'; +}; + export type RemoteConfigProfile = RemoteConfigMetroOptions & + CloudProviderProfileFields & RemoteConnectionProfileFields & { platform?: PlatformSelector; target?: DeviceTarget; @@ -101,6 +115,20 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ }, { key: 'androidDeviceAllowlist', type: 'string' }, { key: 'session', type: 'string' }, + { key: 'providerApp', type: 'string' }, + { key: 'providerOsVersion', type: 'string' }, + { key: 'providerProject', type: 'string' }, + { key: 'providerBuild', type: 'string' }, + { key: 'providerSessionName', type: 'string' }, + { key: 'awsProjectArn', type: 'string' }, + { key: 'awsDeviceArn', type: 'string' }, + { key: 'awsAppArn', type: 'string' }, + { key: 'awsRegion', type: 'string' }, + { + key: 'awsInteractionMode', + type: 'enum', + enumValues: ['INTERACTIVE', 'NO_VIDEO', 'VIDEO_ONLY'], + }, { key: 'metroProjectRoot', type: 'string', path: true }, { key: 'metroKind', type: 'enum', enumValues: ['auto', 'react-native', 'expo'] }, { key: 'metroPublicBaseUrl', type: 'string' }, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index f3f4af0b4..7fbdd9c1c 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -726,6 +726,72 @@ test('parseArgs preserves connect proxy provider positional', () => { assert.equal(parsed.flags.daemonBaseUrl, 'http://host:4310/agent-device'); }); +test('parseArgs preserves connect cloud provider positional', () => { + const parsed = parseArgs(['connect', 'cloud'], { strictFlags: true }); + assert.equal(parsed.command, 'connect'); + assert.deepEqual(parsed.positionals, ['cloud']); +}); + +test('parseArgs recognizes connect browserstack provider flags', () => { + const parsed = parseArgs( + [ + 'connect', + 'browserstack', + '--platform', + 'android', + '--device', + 'Google Pixel 8', + '--provider-os-version', + '14.0', + '--provider-app', + 'bs://app-id', + '--provider-project', + 'agent-device', + '--provider-build', + 'build-a', + '--provider-session-name', + 'session-a', + ], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'connect'); + assert.deepEqual(parsed.positionals, ['browserstack']); + assert.equal(parsed.flags.providerApp, 'bs://app-id'); + assert.equal(parsed.flags.providerOsVersion, '14.0'); + assert.equal(parsed.flags.providerProject, 'agent-device'); + assert.equal(parsed.flags.providerBuild, 'build-a'); + assert.equal(parsed.flags.providerSessionName, 'session-a'); +}); + +test('parseArgs recognizes connect aws-device-farm provider flags', () => { + const parsed = parseArgs( + [ + 'connect', + 'aws-device-farm', + '--platform', + 'ios', + '--aws-project-arn', + 'project-arn', + '--aws-device-arn', + 'device-arn', + '--aws-app-arn', + 'app-arn', + '--aws-region', + 'us-west-2', + '--aws-interaction-mode', + 'INTERACTIVE', + ], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'connect'); + assert.deepEqual(parsed.positionals, ['aws-device-farm']); + assert.equal(parsed.flags.awsProjectArn, 'project-arn'); + assert.equal(parsed.flags.awsDeviceArn, 'device-arn'); + assert.equal(parsed.flags.awsAppArn, 'app-arn'); + assert.equal(parsed.flags.awsRegion, 'us-west-2'); + assert.equal(parsed.flags.awsInteractionMode, 'INTERACTIVE'); +}); + test('parseArgs accepts auth management subcommands', () => { const status = parseArgs(['auth', 'status'], { strictFlags: true }); assert.equal(status.command, 'auth'); @@ -1621,10 +1687,16 @@ test('usageForCommand resolves remote help topic', () => { assert.match(help, /agent-device connect/); assert.match(help, /Remote connection providers use the same lifecycle/); assert.match(help, /connect -> open -> commands -> close -> disconnect/); + assert.match(help, /agent-device connect cloud discovers the agent-device cloud profile/); assert.match(help, /Direct proxy: agent-device connect proxy/); assert.match(help, /stores the shared proxy profile and client identity/); + assert.match(help, /BrowserStack: agent-device connect browserstack/); + assert.match(help, /AWS Device Farm: agent-device connect aws-device-farm/); assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/); assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); + assert.match(help, /connect browserstack --platform android/); + assert.match(help, /connect aws-device-farm --platform android/); + assert.match(help, /agent-device artifacts --json/); assert.match(help, /Script flow, per-command config/); assert.match(help, /Direct proxy flow for a remote Mac/); assert.match(help, /agent-device proxy --port 4310/); @@ -1640,6 +1712,8 @@ test('usageForCommand resolves remote help topic', () => { assert.match(help, /Multiple agents can share one proxy/); assert.match(help, /disconnect releases local connection state/); assert.match(help, /A busy direct-proxy device error means another agent owns the device/); + assert.match(help, /BrowserStack and AWS Device Farm through local provider profiles/); + assert.match(help, /Generated connection profiles store app\/device selectors and ARNs/); assert.match(help, /local\/proxy iOS reports that the runner is already owned/); assert.match(help, /same --remote-config to every operational command/); assert.match(help, /Do not use --config as a remote profile flag/); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 676a4678f..a22b0df02 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -31,7 +31,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { }, connect: { usageOverride: - 'connect [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', + 'connect [cloud|proxy|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', helpDescription: 'Connect to a remote daemon, authenticate when needed, and save remote session state. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', listUsageOverride: 'connect', @@ -44,6 +44,16 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'runId', 'leaseId', 'leaseBackend', + 'providerApp', + 'providerOsVersion', + 'providerProject', + 'providerBuild', + 'providerSessionName', + 'awsProjectArn', + 'awsDeviceArn', + 'awsAppArn', + 'awsRegion', + 'awsInteractionMode', 'force', 'noLogin', ], @@ -52,6 +62,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'daemonAuthToken', 'session', 'platform', + 'device', ...METRO_PREPARE_FLAGS, 'launchUrl', ], diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 73f0fffd1..4c13316d0 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -8,6 +8,8 @@ import http, { import path from 'node:path'; import { test } from 'vitest'; import { createCloudWebDriverRuntime } from '../../../src/cloud-webdriver.ts'; +import { createDefaultCloudWebDriverProviderRuntimes } from '../../../src/cloud-webdriver/provider-runtimes.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS } from '../../../src/cloud-webdriver/providers.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; import type { DeviceLease } from '../../../src/daemon/lease-registry.ts'; import type { DaemonRequest } from '../../../src/daemon/types.ts'; @@ -74,6 +76,80 @@ test('Cloud WebDriver runtime drives provider devices through daemon commands', }); }, 15_000); +test('default BrowserStack provider runtime builds sessions from daemon request profile flags', async () => { + const server = await FakeWebDriverServer.start(); + const runtimes = createDefaultCloudWebDriverProviderRuntimes({ + BROWSERSTACK_USERNAME: 'browser-user', + BROWSERSTACK_ACCESS_KEY: 'browser-key', + BROWSERSTACK_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`, + }); + const providers = createProviderDeviceRuntimeRequestProviders(runtimes); + const daemon = await createProviderScenarioHarness({ + ...providers, + deviceInventoryProvider: providers.deviceInventoryProvider!, + }); + try { + const allocate = await daemon.callCommand( + 'lease_allocate', + [], + { + tenant: 'team-a', + runId: 'run-a', + platform: 'android', + device: 'Google Pixel 8', + providerApp: 'bs://app-id', + providerOsVersion: '14.0', + providerProject: 'agent-device', + providerBuild: 'build-a', + providerSessionName: 'session-a', + }, + { + meta: { + tenantId: 'team-a', + runId: 'run-a', + leaseBackend: 'android-instance', + leaseProvider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + clientId: 'client-a', + }, + }, + ); + const data = assertRpcOk<{ + lease?: DeviceLease; + provider?: { + provider?: string; + sessionId?: string; + providerSessionId?: string; + }; + }>(allocate); + assert.equal(data.provider?.provider, CLOUD_WEBDRIVER_PROVIDERS.browserStack); + assert.equal(data.provider?.providerSessionId, 'wd-1'); + assert.deepEqual(server.calls[0]?.body, { + capabilities: { + alwaysMatch: { + platformName: 'Android', + 'appium:deviceName': 'Google Pixel 8', + device: 'Google Pixel 8', + os_version: '14.0', + app: 'bs://app-id', + 'bstack:options': { + projectName: 'agent-device', + buildName: 'build-a', + sessionName: 'session-a', + }, + }, + }, + }); + assert.equal( + server.calls[0]?.headers.authorization, + `Basic ${Buffer.from('browser-user:browser-key').toString('base64')}`, + ); + } finally { + await daemon.close(); + await Promise.allSettled(runtimes.map(async (runtime) => await runtime.shutdown())); + await server.close(); + } +}, 15_000); + async function createCloudWebDriverWorld() { const server = await FakeWebDriverServer.start(); let artifactFailuresRemaining = 0; From f10e07dcc6ee9dbb1ff5125f944ee795fb422b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 11:35:29 +0200 Subject: [PATCH 05/17] docs: document hosted provider credentials --- src/cli/parser/cli-help.ts | 5 +- src/utils/__tests__/args.test.ts | 4 + website/docs/docs/_meta.json | 5 + website/docs/docs/commands.md | 3 +- website/docs/docs/hosted-device-providers.md | 130 +++++++++++++++++++ 5 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 website/docs/docs/hosted-device-providers.md diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 610af59d0..9cac7420c 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -687,7 +687,7 @@ BrowserStack hosted-device flow: agent-device disconnect AWS Device Farm hosted-device flow: - aws login + AWS_REGION=us-west-2 AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... agent-device connect aws-device-farm --platform android --aws-project-arn --aws-device-arn --aws-app-arn agent-device open com.example.app agent-device snapshot -i @@ -712,7 +712,8 @@ Rules: Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. Use BrowserStack and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. - Hosted provider credentials stay in environment variables or the provider CLI. Generated connection profiles store app/device selectors and ARNs, not BrowserStack access keys or AWS credentials. + Hosted provider credentials must be available before the command starts. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. + Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not BrowserStack access keys or AWS credentials. After closing a hosted provider session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. connect proxy stores the connection profile and client identity. Device leases are acquired on open and expire after five minutes without commands. Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 7fbdd9c1c..ea76bebf0 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1696,6 +1696,9 @@ test('usageForCommand resolves remote help topic', () => { assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); assert.match(help, /connect browserstack --platform android/); assert.match(help, /connect aws-device-farm --platform android/); + assert.match(help, /AWS_REGION=us-west-2 AWS_ACCESS_KEY_ID/); + assert.match(help, /AWS Device Farm uses the AWS CLI credential chain/); + assert.match(help, /Prefer short-lived AWS role credentials in CI/); assert.match(help, /agent-device artifacts --json/); assert.match(help, /Script flow, per-command config/); assert.match(help, /Direct proxy flow for a remote Mac/); @@ -1713,6 +1716,7 @@ test('usageForCommand resolves remote help topic', () => { assert.match(help, /disconnect releases local connection state/); assert.match(help, /A busy direct-proxy device error means another agent owns the device/); assert.match(help, /BrowserStack and AWS Device Farm through local provider profiles/); + assert.match(help, /BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY/); assert.match(help, /Generated connection profiles store app\/device selectors and ARNs/); assert.match(help, /local\/proxy iOS reports that the runner is already owned/); assert.match(help, /same --remote-config to every operational command/); diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index ef721c318..42b55206d 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -44,6 +44,11 @@ "type": "file", "label": "Remote Proxy" }, + { + "name": "hosted-device-providers", + "type": "file", + "label": "Hosted Device Providers" + }, { "name": "security-trust", "type": "file", diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index c7b9044e7..d7b76fac6 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -87,6 +87,7 @@ agent-device app-switcher - Remote daemon clients can pass `--daemon-base-url http(s)://host:port[/base-path]` to skip local daemon discovery/startup and call a remote HTTP daemon directly. - Use `--daemon-auth-token ` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) for explicit service/API-token automation against non-loopback remote daemon URLs; the client sends it in both the JSON-RPC request token and HTTP auth headers. - Use [Remote Proxy](/docs/remote-proxy) when you need to run `agent-device proxy` on a Mac with simulator/device access and drive it from another machine through cloudflared, ngrok, or another HTTP tunnel. +- Use [Hosted Device Providers](/docs/hosted-device-providers) when agents need BrowserStack or AWS Device Farm sessions from CI without interactive login. - For human cloud access, `connect` can discover a cloud connection profile, while `connect --remote-config ...` uses a local profile. Both refresh a stored CLI session into a short-lived `adc_agent_...` token when needed. If no CLI session exists, interactive shells start login automatically; CI and non-interactive shells fail with API-token setup instructions. Use `--no-login` to disable implicit login. `AGENT_DEVICE_CLOUD_BASE_URL` is the bridge/control-plane API origin; its `/api-keys` route may redirect to the dashboard for token creation. - For remote `connect` and `connect --remote-config` flows, see [Remote Metro workflow](#remote-metro-workflow). - Android React Native relaunch flows require an installed package name for `open --relaunch`; install/reinstall the APK first, then relaunch by package. `open --relaunch` is rejected because runtime hints are written through the installed app sandbox. @@ -965,7 +966,7 @@ agent-device artifacts --provider aws-device-farm --provider-session ` plus `--provider `. BrowserStack expects `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY`; AWS Device Farm uses the AWS CLI credential chain and infers the region from the session ARN when possible. +- Historical lookup requires `--provider-session ` plus `--provider `. BrowserStack expects `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY`; AWS Device Farm uses the AWS CLI credential chain and infers the region from the session ARN when possible. See [Hosted Device Providers](/docs/hosted-device-providers) for autonomous CI credential setup. - When a cloud runtime is registered in-process by an embedding host, `artifacts` can infer the active provider session from the current lease before disconnect. - `disconnect --json` and `close --json` include provider release data when the runtime returns final cloud artifacts after session teardown. Some providers only finalize video/log URLs after the remote session is stopped, so retry `agent-device artifacts --provider --json` if the first response is `pending`. diff --git a/website/docs/docs/hosted-device-providers.md b/website/docs/docs/hosted-device-providers.md new file mode 100644 index 000000000..c17d5cb63 --- /dev/null +++ b/website/docs/docs/hosted-device-providers.md @@ -0,0 +1,130 @@ +--- +title: Hosted Device Providers +description: Configure BrowserStack and AWS Device Farm so agents can connect without interactive login. +--- + +# Hosted Device Providers + +Use hosted provider connections when the agent should drive BrowserStack App Automate or AWS Device Farm remote access through the local `agent-device` daemon: + +```bash +agent-device connect browserstack ... +agent-device connect aws-device-farm ... +``` + +These providers are not remote `agent-device` daemons. `connect browserstack` and `connect aws-device-farm` write a local generated profile, then the first lease-allocating command such as `open` creates the hosted WebDriver session. + +## Autonomous Agent Requirements + +Agents can connect autonomously when all required credentials and selectors are present before the command starts. + +- Do not rely on browser-based login inside the agent workflow. +- Put provider credentials in CI secrets, a local ignored env file, or the CI platform's secret store. +- Keep generated remote profiles non-secret. They may contain provider app ids, Device Farm ARNs, device names, OS versions, and labels; they must not contain BrowserStack access keys or AWS secret keys. +- Run `agent-device artifacts --json` after `close` when the provider has video/log URLs to fetch. + +## BrowserStack + +Required environment: + +```bash +export BROWSERSTACK_USERNAME=... +export BROWSERSTACK_ACCESS_KEY=... +``` + +Required connection selectors: + +```bash +agent-device connect browserstack \ + --platform android \ + --device "Google Pixel 8" \ + --provider-os-version 14.0 \ + --provider-app bs://app-id +``` + +`--provider-app` accepts a BrowserStack app reference such as `bs://...`, an HTTP(S) app URL, or an existing local app path. Local paths are uploaded to BrowserStack when the hosted session is allocated. + +Optional labels: + +```bash +--provider-project agent-device +--provider-build "$GITHUB_RUN_ID" +--provider-session-name "$GITHUB_JOB" +``` + +## AWS Device Farm + +AWS Device Farm uses the AWS CLI credential provider chain. `agent-device` does not require `aws login`; it shells out to `aws devicefarm ...`, so any non-interactive AWS CLI credential source that works in CI works here. The AWS CLI documents environment variables such as `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`, `AWS_PROFILE`, `AWS_ROLE_ARN`, and `AWS_WEB_IDENTITY_TOKEN_FILE` in the [AWS CLI environment variable reference](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html). + +Prefer short-lived CI credentials over long-lived IAM user keys. In GitHub Actions, use OIDC to assume an IAM role and let the action export the standard AWS environment variables; AWS documents IAM OIDC providers in the [IAM OIDC provider guide](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html), and the official `aws-actions/configure-aws-credentials` action documents the GitHub Actions setup in its [configure-aws-credentials repository](https://github.com/aws-actions/configure-aws-credentials). For other CI systems, use the platform's AWS federation support when available. If static keys are unavoidable, store them as CI secrets and scope their IAM policy to the needed Device Farm project/actions. + +Typical CI environment after federation or secret injection: + +```bash +export AWS_REGION=us-west-2 +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... +export AWS_SESSION_TOKEN=... # present for temporary credentials +``` + +AWS web identity flows can also use the AWS CLI's environment variables: + +```bash +export AWS_ROLE_ARN=arn:aws:iam:::role/ +export AWS_WEB_IDENTITY_TOKEN_FILE=/path/to/token +export AWS_REGION=us-west-2 +``` + +Required connection selectors: + +```bash +agent-device connect aws-device-farm \ + --platform android \ + --aws-project-arn arn:aws:devicefarm:us-west-2::project: \ + --aws-device-arn arn:aws:devicefarm:us-west-2::device: \ + --aws-app-arn arn:aws:devicefarm:us-west-2::upload: +``` + +`--aws-app-arn` is optional when the remote access session does not need an uploaded app attached. You can also provide ARNs through environment variables: + +```bash +export AWS_DEVICE_FARM_PROJECT_ARN=... +export AWS_DEVICE_FARM_DEVICE_ARN=... +export AWS_DEVICE_FARM_APP_ARN=... +``` + +`AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN`, `AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN`, and `AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN` are accepted as agent-device-specific aliases. + +## Minimal CI Shape + +```bash +# BrowserStack +BROWSERSTACK_USERNAME=... +BROWSERSTACK_ACCESS_KEY=... +agent-device connect browserstack --platform android --device "Google Pixel 8" --provider-os-version 14.0 --provider-app bs://app-id +agent-device open com.example.app +agent-device snapshot -i +agent-device close +agent-device artifacts --json +agent-device disconnect +``` + +```bash +# AWS Device Farm +AWS_REGION=us-west-2 +AWS_ACCESS_KEY_ID=... +AWS_SECRET_ACCESS_KEY=... +AWS_SESSION_TOKEN=... +agent-device connect aws-device-farm --platform android --aws-project-arn "$AWS_DEVICE_FARM_PROJECT_ARN" --aws-device-arn "$AWS_DEVICE_FARM_DEVICE_ARN" --aws-app-arn "$AWS_DEVICE_FARM_APP_ARN" +agent-device open com.example.app +agent-device snapshot -i +agent-device close +agent-device artifacts --json +agent-device disconnect +``` + +## Troubleshooting + +- If BrowserStack connect fails before opening a session, check `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `--provider-app`, `--provider-os-version`, and `--device`. +- If AWS allocation fails, first run `aws sts get-caller-identity` in the same CI step to confirm the AWS CLI credential chain is active, then verify the Device Farm ARNs and region. +- If artifact lookup is pending immediately after `close`, retry `agent-device artifacts --json`. Some providers finalize video/log URLs asynchronously after the hosted session stops. From e5af54f4365b6df5e4dc0cdbbe61aff4b04aa030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 11:56:37 +0200 Subject: [PATCH 06/17] refactor: tighten cloud webdriver provider internals --- src/cloud-webdriver/aws-device-farm.ts | 52 ++- src/cloud-webdriver/browserstack.ts | 69 ++-- src/cloud-webdriver/provider-registry.ts | 77 ++++ src/cloud-webdriver/provider-runtimes.ts | 8 +- src/cloud-webdriver/runtime.ts | 8 +- src/cloud-webdriver/webdriver-client.ts | 65 +++- src/cloud-webdriver/webdriver-gestures.ts | 12 +- src/cloud-webdriver/webdriver-interactor.ts | 5 +- src/cloud-webdriver/webdriver-source.ts | 88 ++--- src/cloud-webdriver/webdriver-utils.ts | 29 ++ src/default-cloud-artifact-provider.ts | 73 +--- src/platforms/android/ui-hierarchy.ts | 2 +- src/platforms/ios/xml.ts | 359 +----------------- src/utils/xml.ts | 358 +++++++++++++++++ .../cloud-webdriver-runtime.test.ts | 44 +++ 15 files changed, 659 insertions(+), 590 deletions(-) create mode 100644 src/cloud-webdriver/provider-registry.ts create mode 100644 src/cloud-webdriver/webdriver-utils.ts create mode 100644 src/utils/xml.ts diff --git a/src/cloud-webdriver/aws-device-farm.ts b/src/cloud-webdriver/aws-device-farm.ts index d20cb05b6..6895c2f21 100644 --- a/src/cloud-webdriver/aws-device-farm.ts +++ b/src/cloud-webdriver/aws-device-farm.ts @@ -1,5 +1,6 @@ import { createCloudWebDriverCapabilities, + type CloudWebDriverCapabilityOverrides, type CloudWebDriverProviderCapabilities, } from './capabilities.ts'; import { createCloudWebDriverRuntime } from './runtime.ts'; @@ -17,10 +18,26 @@ import type { import type { DeviceLease } from '../daemon/lease-registry.ts'; import type { ProviderDeviceRuntime } from '../provider-device-runtime.ts'; import { runCmd } from '../utils/exec.ts'; +import { sleep } from '../utils/timeouts.ts'; import { AppError } from '../kernel/errors.ts'; import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; +import { resolveLeaseValue, type LeaseValue } from './webdriver-utils.ts'; const AWS_DEVICE_FARM_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm; +const AWS_DEVICE_FARM_CAPABILITY_OVERRIDES = { + install: { + support: 'unsupported', + note: 'Pass appArn when creating the remote access session; local artifact upload/install is not implemented.', + }, + portReverse: { + support: 'unsupported', + note: 'AWS Device Farm remote access does not expose agent-device port reverse.', + }, + artifacts: { + support: 'supported', + note: 'AWS Device Farm remote access exposes provider-hosted video, Appium logs, and device logs after session completion.', + }, +} as const satisfies CloudWebDriverCapabilityOverrides; export { listAwsDeviceFarmCloudArtifacts, @@ -69,7 +86,7 @@ export type AwsDeviceFarmWebDriverRuntimeOptions = { platform?: CloudWebDriverPlatform; deviceName?: string; appArn?: string; - sessionName?: string | ((lease: DeviceLease) => string); + sessionName?: LeaseValue; webdriverCapabilities?: | Record | ((lease: DeviceLease) => Record); @@ -88,20 +105,7 @@ export function getAwsDeviceFarmWebDriverCapabilities( return createCloudWebDriverCapabilities({ provider: AWS_DEVICE_FARM_PROVIDER, platform, - overrides: { - install: { - support: 'unsupported', - note: 'Pass appArn when creating the remote access session; local artifact upload/install is not implemented.', - }, - portReverse: { - support: 'unsupported', - note: 'AWS Device Farm remote access does not expose agent-device port reverse.', - }, - artifacts: { - support: 'supported', - note: 'AWS Device Farm remote access exposes provider-hosted video, Appium logs, and device logs after session completion.', - }, - }, + overrides: AWS_DEVICE_FARM_CAPABILITY_OVERRIDES, }); } @@ -125,7 +129,7 @@ export function createAwsDeviceFarmWebDriverRuntime( }), deviceId: options.deviceId, requestPolicy: options.requestPolicy, - capabilityOverrides: getAwsDeviceFarmWebDriverCapabilities(platform).operations, + capabilityOverrides: AWS_DEVICE_FARM_CAPABILITY_OVERRIDES, }); } @@ -215,7 +219,7 @@ function createAwsDeviceFarmPrepareSession( projectArn: options.projectArn, deviceArn: options.deviceArn, appArn: options.appArn, - name: resolveAwsSessionName(options.sessionName, lease), + name: resolveLeaseValue(options.sessionName, lease) ?? `agent-device-${lease.leaseId}`, interactionMode: options.interactionMode, configuration: options.configuration, }); @@ -289,7 +293,7 @@ async function waitForRunningRemoteAccessSession( result: last.result, }); } - await delay(pollIntervalMs); + await sleep(pollIntervalMs); last = await options.client.getRemoteAccessSession(arn); } throw new AppError('COMMAND_FAILED', 'Timed out waiting for AWS Device Farm remote access.', { @@ -300,14 +304,6 @@ async function waitForRunningRemoteAccessSession( }); } -function resolveAwsSessionName( - value: string | ((lease: DeviceLease) => string) | undefined, - lease: DeviceLease, -): string { - if (typeof value === 'function') return value(lease); - return value ?? `agent-device-${lease.leaseId}`; -} - async function runAwsJson(command: string, args: string[]): Promise { const result = await runCmd(command, args, { maxBuffer: 10 * 1024 * 1024 }); return JSON.parse(result.stdout) as unknown; @@ -327,7 +323,3 @@ function readRemoteAccessSession(value: unknown): AwsDeviceFarmRemoteAccessSessi } return session as AwsDeviceFarmRemoteAccessSession; } - -async function delay(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/cloud-webdriver/browserstack.ts b/src/cloud-webdriver/browserstack.ts index 9f91a58a7..c4374277e 100644 --- a/src/cloud-webdriver/browserstack.ts +++ b/src/cloud-webdriver/browserstack.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import type { CloudArtifact, CloudArtifactsResult } from '../cloud-artifacts.ts'; import { createCloudWebDriverCapabilities, + type CloudWebDriverCapabilityOverrides, type CloudWebDriverProviderCapabilities, } from './capabilities.ts'; import { @@ -16,12 +17,32 @@ import type { DeviceLease } from '../daemon/lease-registry.ts'; import { AppError } from '../kernel/errors.ts'; import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; import { agentDeviceRequestHeaders } from './request-headers.ts'; +import { + basicAuthHeader, + resolveLeaseValue, + trimTrailingSlash, + type LeaseValue, +} from './webdriver-utils.ts'; const BROWSERSTACK_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.browserStack; const BROWSERSTACK_APP_AUTOMATE_ENDPOINT = 'https://hub-cloud.browserstack.com/wd/hub/'; const BROWSERSTACK_APP_UPLOAD_ENDPOINT = 'https://api-cloud.browserstack.com/app-automate/upload'; const BROWSERSTACK_SESSION_DETAILS_ENDPOINT = 'https://api-cloud.browserstack.com/app-automate/sessions'; +const BROWSERSTACK_CAPABILITY_OVERRIDES = { + install: { + support: 'partial', + note: 'Local app artifacts are uploaded to BrowserStack App Automate, then installed with Appium.', + }, + portReverse: { + support: 'unsupported', + note: 'Use BrowserStack Local for network tunneling; agent-device port reverse is not available.', + }, + artifacts: { + support: 'supported', + note: 'BrowserStack session details expose provider-hosted video, Appium logs, device logs, and dashboard links.', + }, +} as const satisfies CloudWebDriverCapabilityOverrides; export type BrowserStackWebDriverRuntimeOptions = { username: string; @@ -31,8 +52,8 @@ export type BrowserStackWebDriverRuntimeOptions = { osVersion: string; app?: string; projectName?: string; - buildName?: string | ((lease: DeviceLease) => string); - sessionName?: string | ((lease: DeviceLease) => string); + buildName?: LeaseValue; + sessionName?: LeaseValue; webdriverCapabilities?: | Record | ((lease: DeviceLease) => Record); @@ -49,20 +70,7 @@ export function getBrowserStackWebDriverCapabilities( return createCloudWebDriverCapabilities({ provider: BROWSERSTACK_PROVIDER, platform, - overrides: { - install: { - support: 'partial', - note: 'Local app artifacts are uploaded to BrowserStack App Automate, then installed with Appium.', - }, - portReverse: { - support: 'unsupported', - note: 'Use BrowserStack Local for network tunneling; agent-device port reverse is not available.', - }, - artifacts: { - support: 'supported', - note: 'BrowserStack session details expose provider-hosted video, Appium logs, device logs, and dashboard links.', - }, - }, + overrides: BROWSERSTACK_CAPABILITY_OVERRIDES, }); } @@ -94,7 +102,7 @@ export function createBrowserStackWebDriverRuntime( await listBrowserStackCloudArtifacts(provider, providerSessionId, artifactOptions), deviceId: options.deviceId, requestPolicy: options.requestPolicy, - capabilityOverrides: getBrowserStackWebDriverCapabilities(options.platform).operations, + capabilityOverrides: BROWSERSTACK_CAPABILITY_OVERRIDES, }); } @@ -138,7 +146,7 @@ export async function uploadBrowserStackApp( method: 'POST', headers: { ...agentDeviceRequestHeaders(), - Authorization: browserStackAuthHeader(options), + Authorization: basicAuthHeader(options), }, body: form, }); @@ -181,26 +189,13 @@ function browserStackCapabilities( ...(options.app ? { app: options.app } : {}), 'bstack:options': { ...(options.projectName ? { projectName: options.projectName } : {}), - buildName: resolveBrowserStackLabel(options.buildName, lease) ?? lease.runId, - sessionName: resolveBrowserStackLabel(options.sessionName, lease) ?? lease.leaseId, + buildName: resolveLeaseValue(options.buildName, lease) ?? lease.runId, + sessionName: resolveLeaseValue(options.sessionName, lease) ?? lease.leaseId, }, ...configured, }; } -function resolveBrowserStackLabel( - value: string | ((lease: DeviceLease) => string) | undefined, - lease: DeviceLease, -): string | undefined { - return typeof value === 'function' ? value(lease) : value; -} - -function browserStackAuthHeader( - options: Pick, -): string { - return `Basic ${Buffer.from(`${options.username}:${options.accessKey}`).toString('base64')}`; -} - async function fetchBrowserStackSessionDetails( sessionId: string, options: BrowserStackSessionDetailsOptions, @@ -211,7 +206,7 @@ async function fetchBrowserStackSessionDetails( const response = await fetch(endpoint, { headers: { ...agentDeviceRequestHeaders(), - Authorization: browserStackAuthHeader(options), + Authorization: basicAuthHeader(options), }, }); const json = (await response.json()) as unknown; @@ -287,12 +282,6 @@ function browserStackUrlArtifact( return { provider, providerSessionId, kind, name, url, availability: 'ready' }; } -function trimTrailingSlash(value: string): string { - let end = value.length; - while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1; - return value.slice(0, end); -} - function readBrowserStackAppUrl(value: unknown): string | undefined { if (!value || typeof value !== 'object') return undefined; const appUrl = (value as { app_url?: unknown }).app_url; diff --git a/src/cloud-webdriver/provider-registry.ts b/src/cloud-webdriver/provider-registry.ts new file mode 100644 index 000000000..2ef35bc6d --- /dev/null +++ b/src/cloud-webdriver/provider-registry.ts @@ -0,0 +1,77 @@ +import type { CloudArtifactsQuery, CloudArtifactsResult } from '../cloud-artifacts.ts'; +import { createAwsCliDeviceFarmClient, listAwsDeviceFarmCloudArtifacts } from './aws-device-farm.ts'; +import { listBrowserStackCloudArtifacts } from './browserstack.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; +import { AppError } from '../kernel/errors.ts'; + +export type DefaultCloudWebDriverArtifactEnv = { + BROWSERSTACK_USERNAME?: string; + BROWSERSTACK_ACCESS_KEY?: string; + BROWSERSTACK_SESSION_DETAILS_ENDPOINT?: string; + AWS_REGION?: string; + AWS_DEFAULT_REGION?: string; +}; + +type CloudWebDriverProviderRegistryEntry = { + provider: string; + listArtifactsFromEnv: ( + providerSessionId: string, + env: DefaultCloudWebDriverArtifactEnv, + ) => Promise; +}; + +const CLOUD_WEBDRIVER_PROVIDER_REGISTRY: readonly CloudWebDriverProviderRegistryEntry[] = [ + { + provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + listArtifactsFromEnv: async (providerSessionId, env) => { + const username = env.BROWSERSTACK_USERNAME; + const accessKey = env.BROWSERSTACK_ACCESS_KEY; + if (!username || !accessKey) { + throw new AppError( + 'INVALID_ARGS', + 'BrowserStack artifact lookup requires BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.', + ); + } + return await listBrowserStackCloudArtifacts( + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + providerSessionId, + { + username, + accessKey, + endpoint: env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, + }, + ); + }, + }, + { + provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + listArtifactsFromEnv: async (providerSessionId, env) => { + const client = createAwsCliDeviceFarmClient({ + region: + env.AWS_REGION ?? + env.AWS_DEFAULT_REGION ?? + readAwsRegionFromDeviceFarmArn(providerSessionId), + }); + return await listAwsDeviceFarmCloudArtifacts( + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + providerSessionId, + client, + ); + }, + }, +]; + +export async function listCloudWebDriverArtifactsFromEnv( + query: CloudArtifactsQuery, + env: DefaultCloudWebDriverArtifactEnv, +): Promise { + if (!query.providerSessionId) return undefined; + const entry = CLOUD_WEBDRIVER_PROVIDER_REGISTRY.find( + (candidate) => candidate.provider === query.provider, + ); + return await entry?.listArtifactsFromEnv(query.providerSessionId, env); +} + +function readAwsRegionFromDeviceFarmArn(arn: string): string | undefined { + return /^arn:[^:]+:devicefarm:([^:]+):/.exec(arn)?.[1]; +} diff --git a/src/cloud-webdriver/provider-runtimes.ts b/src/cloud-webdriver/provider-runtimes.ts index be3466563..fb7eefc80 100644 --- a/src/cloud-webdriver/provider-runtimes.ts +++ b/src/cloud-webdriver/provider-runtimes.ts @@ -21,22 +21,18 @@ import { import { createAwsDeviceFarmWebDriverRuntime } from './aws-device-farm.ts'; import { createBrowserStackWebDriverRuntime, uploadBrowserStackApp } from './browserstack.ts'; import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts'; +import type { DefaultCloudWebDriverArtifactEnv } from './provider-registry.ts'; import type { CloudWebDriverPlatform } from './runtime.ts'; -export type DefaultCloudWebDriverProviderRuntimeEnv = { - BROWSERSTACK_USERNAME?: string; - BROWSERSTACK_ACCESS_KEY?: string; +export type DefaultCloudWebDriverProviderRuntimeEnv = DefaultCloudWebDriverArtifactEnv & { BROWSERSTACK_WEBDRIVER_ENDPOINT?: string; BROWSERSTACK_APP_UPLOAD_ENDPOINT?: string; - BROWSERSTACK_SESSION_DETAILS_ENDPOINT?: string; AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN?: string; AWS_DEVICE_FARM_PROJECT_ARN?: string; AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN?: string; AWS_DEVICE_FARM_DEVICE_ARN?: string; AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN?: string; AWS_DEVICE_FARM_APP_ARN?: string; - AWS_REGION?: string; - AWS_DEFAULT_REGION?: string; }; export function createDefaultCloudWebDriverProviderRuntimes( diff --git a/src/cloud-webdriver/runtime.ts b/src/cloud-webdriver/runtime.ts index d95c36607..af3b9129e 100644 --- a/src/cloud-webdriver/runtime.ts +++ b/src/cloud-webdriver/runtime.ts @@ -65,7 +65,6 @@ export type CloudWebDriverPreparedSession = CloudWebDriverBaseSession & { export type CloudWebDriverListArtifacts = (params: { provider: string; - phase: 'active' | 'released' | 'lookup'; lease?: DeviceLease; device?: DeviceInfo; webDriverSessionId?: string; @@ -241,7 +240,7 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { if (!session) return undefined; this.sessionsByLeaseId.delete(lease.leaseId); const cleanup = await this.closeSession(session); - const artifacts = await this.safeListArtifacts(session, 'released'); + const artifacts = await this.safeListArtifacts(session); return { provider: this.provider, providerSessionId: session.providerSessionId, @@ -255,11 +254,10 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { ): Promise { if (query.provider !== this.provider) return undefined; const session = query.leaseId ? this.sessionsByLeaseId.get(query.leaseId) : undefined; - if (session) return await this.safeListArtifacts(session, 'active'); + if (session) return await this.safeListArtifacts(session); if (!query.providerSessionId || !this.options.listArtifacts) return undefined; return await this.options.listArtifacts({ provider: this.provider, - phase: 'lookup', providerSessionId: query.providerSessionId, }); } @@ -347,14 +345,12 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { private async safeListArtifacts( session: WebDriverProviderSession, - phase: 'active' | 'released', ): Promise { const listArtifacts = session.prepared.listArtifacts ?? this.options.listArtifacts; if (!listArtifacts) return undefined; try { return await listArtifacts({ provider: this.provider, - phase, lease: session.lease, device: session.device, webDriverSessionId: session.webDriverSessionId, diff --git a/src/cloud-webdriver/webdriver-client.ts b/src/cloud-webdriver/webdriver-client.ts index 75c71d82b..ba28fdb41 100644 --- a/src/cloud-webdriver/webdriver-client.ts +++ b/src/cloud-webdriver/webdriver-client.ts @@ -1,6 +1,8 @@ import fs from 'node:fs/promises'; import { AppError } from '../kernel/errors.ts'; +import { sleep } from '../utils/timeouts.ts'; import { agentDeviceRequestHeaders } from './request-headers.ts'; +import { basicAuthHeader, trimLeadingSlash, withTrailingSlash } from './webdriver-utils.ts'; export type WebDriverAuth = { username: string; @@ -25,6 +27,13 @@ export type WebDriverSession = { capabilities: Record; }; +export type WebDriverWindowRect = { + x: number; + y: number; + width: number; + height: number; +}; + export type W3CActionSequence = { type: 'pointer' | 'key' | 'wheel'; id: string; @@ -51,7 +60,7 @@ export class WebDriverClient { this.endpoint = withTrailingSlash(new URL(options.endpoint)); this.headers = { ...agentDeviceRequestHeaders(), - ...(options.auth ? { Authorization: basicAuth(options.auth) } : {}), + ...(options.auth ? { Authorization: basicAuthHeader(options.auth) } : {}), ...options.headers, }; this.requestPolicy = { @@ -129,6 +138,10 @@ export class WebDriverClient { await fs.writeFile(outPath, Buffer.from(value, 'base64')); } + async windowRect(): Promise { + return readWindowRect(await this.sessionRequest('GET', '/window/rect')); + } + async executeScript(script: string, args: unknown[] = []): Promise { return await this.sessionRequest('POST', '/execute/sync', { script, args }); } @@ -166,7 +179,7 @@ export class WebDriverClient { if (!isRetriableWebDriverError(error) || attempt >= retryAttempts) { throw error; } - await delay(this.requestPolicy.retryDelayMs); + await sleep(this.requestPolicy.retryDelayMs); } } throw lastError; @@ -192,6 +205,35 @@ export class WebDriverClient { } } +function readWindowRect(value: unknown): WebDriverWindowRect { + if (!value || typeof value !== 'object') { + throw new AppError('COMMAND_FAILED', 'WebDriver window rect response was empty.'); + } + const record = value as Record; + const rect = { + x: readFiniteNumber(record, 'x'), + y: readFiniteNumber(record, 'y'), + width: readFiniteNumber(record, 'width'), + height: readFiniteNumber(record, 'height'), + }; + if ( + rect.x === undefined || + rect.y === undefined || + rect.width === undefined || + rect.height === undefined + ) { + throw new AppError('COMMAND_FAILED', 'WebDriver window rect response was invalid.', { + response: record, + }); + } + return rect as WebDriverWindowRect; +} + +function readFiniteNumber(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + function normalizeCapabilities(capabilities: Record): Record { if ('alwaysMatch' in capabilities || 'firstMatch' in capabilities) return capabilities; return { alwaysMatch: capabilities }; @@ -256,22 +298,3 @@ function isRetriableWebDriverError(error: unknown): boolean { } return error instanceof TypeError || (error instanceof Error && error.name === 'TimeoutError'); } - -async function delay(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -function trimLeadingSlash(path: string): string { - return path.replace(/^\/+/, ''); -} - -function withTrailingSlash(url: URL): URL { - if (url.pathname.endsWith('/')) return url; - const copy = new URL(url); - copy.pathname = `${copy.pathname}/`; - return copy; -} - -function basicAuth(auth: WebDriverAuth): string { - return `Basic ${Buffer.from(`${auth.username}:${auth.accessKey}`).toString('base64')}`; -} diff --git a/src/cloud-webdriver/webdriver-gestures.ts b/src/cloud-webdriver/webdriver-gestures.ts index e9ce11ae2..9489c093b 100644 --- a/src/cloud-webdriver/webdriver-gestures.ts +++ b/src/cloud-webdriver/webdriver-gestures.ts @@ -1,4 +1,4 @@ -import { centerOfRect } from '../kernel/snapshot.ts'; +import { centerOfRect, type Rect } from '../kernel/snapshot.ts'; import type { ScrollDirection } from '../core/scroll-gesture.ts'; import type { W3CActionSequence } from './webdriver-client.ts'; @@ -14,8 +14,8 @@ export function touchPointer(name: string, actions: Record[]): export function scrollStart( direction: ScrollDirection, distance: number, + rect: Rect, ): { x: number; y: number } { - const rect = { x: 0, y: 0, width: 400, height: 800 }; const center = centerOfRect(rect); switch (direction) { case 'up': @@ -29,8 +29,12 @@ export function scrollStart( } } -export function scrollEnd(direction: ScrollDirection, distance: number): { x: number; y: number } { - const start = scrollStart(direction, distance); +export function scrollEnd( + direction: ScrollDirection, + distance: number, + rect: Rect, +): { x: number; y: number } { + const start = scrollStart(direction, distance, rect); switch (direction) { case 'up': return { ...start, y: start.y - distance }; diff --git a/src/cloud-webdriver/webdriver-interactor.ts b/src/cloud-webdriver/webdriver-interactor.ts index 1fbdc496e..45e3bb603 100644 --- a/src/cloud-webdriver/webdriver-interactor.ts +++ b/src/cloud-webdriver/webdriver-interactor.ts @@ -174,8 +174,9 @@ class WebDriverInteractor implements Interactor { this.requireSupport('scroll'); const distance = options?.pixels ?? options?.amount ?? 300; const durationMs = options?.durationMs ?? 350; - const start = scrollStart(direction, distance); - const end = scrollEnd(direction, distance); + const rect = await this.client.windowRect(); + const start = scrollStart(direction, distance, rect); + const end = scrollEnd(direction, distance, rect); await this.swipe(start.x, start.y, end.x, end.y, durationMs); return { backend: 'webdriver', direction, distance, durationMs }; } diff --git a/src/cloud-webdriver/webdriver-source.ts b/src/cloud-webdriver/webdriver-source.ts index d12a26fc3..1f86b7f47 100644 --- a/src/cloud-webdriver/webdriver-source.ts +++ b/src/cloud-webdriver/webdriver-source.ts @@ -1,35 +1,43 @@ import type { RawSnapshotNode } from '../kernel/snapshot.ts'; +import { parseBounds } from '../platforms/android/ui-hierarchy.ts'; +import { parseXmlDocumentSync, type XmlNode } from '../utils/xml.ts'; export function parseWebDriverSource(source: string): RawSnapshotNode[] { + const roots = parseXmlDocumentSync(source); const nodes: RawSnapshotNode[] = []; - const stack: number[] = []; - const tagPattern = /<\s*(\/?)([A-Za-z][\w:.-]*)([^>]*?)(\/?)\s*>/g; - let match: RegExpExecArray | null; - while ((match = tagPattern.exec(source))) { - const closing = match[1] === '/'; - const tagName = match[2] ?? 'Element'; - const attributes = match[3] ?? ''; - if (closing) { - stack.pop(); - continue; - } - const attrs = parseXmlAttributes(attributes); - const selfClosing = match[4] === '/' || attributes.trimEnd().endsWith('/'); - if (Object.keys(attrs).length === 0) continue; - const parentIndex = stack.at(-1); - const node = sourceNodeFromAttributes( - nodes.length, - tagName, - attrs, - parentIndex, - parentIndex === undefined ? 0 : (nodes[parentIndex]?.depth ?? 0) + 1, - ); - nodes.push(node); - if (!selfClosing) stack.push(node.index); + for (const root of roots) { + appendSourceNodes(nodes, root); } return nodes; } +function appendSourceNodes( + nodes: RawSnapshotNode[], + xmlNode: XmlNode, + parentIndex?: number, + depth = 0, +): void { + const currentIndex = + Object.keys(xmlNode.attributes).length === 0 + ? parentIndex + : appendSourceNode(nodes, xmlNode, parentIndex, depth); + const childDepth = currentIndex === parentIndex ? depth : depth + 1; + for (const child of xmlNode.children) { + appendSourceNodes(nodes, child, currentIndex, childDepth); + } +} + +function appendSourceNode( + nodes: RawSnapshotNode[], + xmlNode: XmlNode, + parentIndex: number | undefined, + depth: number, +): number { + const index = nodes.length; + nodes.push(sourceNodeFromAttributes(index, xmlNode.name, xmlNode.attributes, parentIndex, depth)); + return index; +} + function sourceNodeFromAttributes( index: number, type: string, @@ -58,18 +66,8 @@ function sourceNodeFromAttributes( }; } -function parseXmlAttributes(input: string): Record { - const attrs: Record = {}; - const attrPattern = /([:\w.-]+)\s*=\s*"([^"]*)"/g; - let match: RegExpExecArray | null; - while ((match = attrPattern.exec(input))) { - attrs[match[1] ?? ''] = decodeXmlEntities(match[2] ?? ''); - } - return attrs; -} - function rectFromAttributes(attrs: Record): RawSnapshotNode['rect'] | undefined { - const bounds = parseAndroidBounds(attrs.bounds); + const bounds = parseBounds(attrs.bounds ?? null); if (bounds) return bounds; const x = numberAttribute(attrs.x); const y = numberAttribute(attrs.y); @@ -81,17 +79,6 @@ function rectFromAttributes(attrs: Record): RawSnapshotNode['rec return { x, y, width, height }; } -function parseAndroidBounds(value: string | undefined): RawSnapshotNode['rect'] | undefined { - if (!value) return undefined; - const match = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/.exec(value); - if (!match) return undefined; - const [x1, y1, x2, y2] = match.slice(1).map(Number); - if (x1 === undefined || y1 === undefined || x2 === undefined || y2 === undefined) { - return undefined; - } - return { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) }; -} - function firstAttribute( attrs: Record, names: readonly string[], @@ -121,12 +108,3 @@ function numberAttribute(value: string | undefined): number | undefined { function roleFromType(type: string, attrs: Record): string | undefined { return nonEmpty(attrs.class) ?? nonEmpty(type.replace(/^XCUIElementType/, '').toLowerCase()); } - -function decodeXmlEntities(value: string): string { - return value - .replaceAll('"', '"') - .replaceAll(''', "'") - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('&', '&'); -} diff --git a/src/cloud-webdriver/webdriver-utils.ts b/src/cloud-webdriver/webdriver-utils.ts new file mode 100644 index 000000000..36c1e8476 --- /dev/null +++ b/src/cloud-webdriver/webdriver-utils.ts @@ -0,0 +1,29 @@ +import type { DeviceLease } from '../daemon/lease-registry.ts'; + +export type LeaseValue = T | ((lease: DeviceLease) => T); + +export function resolveLeaseValue( + value: LeaseValue | undefined, + lease: DeviceLease, +): T | undefined { + return typeof value === 'function' ? (value as (lease: DeviceLease) => T)(lease) : value; +} + +export function basicAuthHeader(credentials: { username: string; accessKey: string }): string { + return `Basic ${Buffer.from(`${credentials.username}:${credentials.accessKey}`).toString('base64')}`; +} + +export function trimLeadingSlash(value: string): string { + return value.replace(/^\/+/, ''); +} + +export function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} + +export function withTrailingSlash(url: URL): URL { + if (url.pathname.endsWith('/')) return url; + const copy = new URL(url); + copy.pathname = `${copy.pathname}/`; + return copy; +} diff --git a/src/default-cloud-artifact-provider.ts b/src/default-cloud-artifact-provider.ts index abc382ede..a324b7446 100644 --- a/src/default-cloud-artifact-provider.ts +++ b/src/default-cloud-artifact-provider.ts @@ -1,76 +1,15 @@ -import type { CloudArtifactProvider, CloudArtifactsResult } from './cloud-artifacts.ts'; +import type { CloudArtifactProvider } from './cloud-artifacts.ts'; import { - createAwsCliDeviceFarmClient, - listAwsDeviceFarmCloudArtifacts, -} from './cloud-webdriver/aws-device-farm.ts'; -import { listBrowserStackCloudArtifacts } from './cloud-webdriver/browserstack.ts'; -import { CLOUD_WEBDRIVER_PROVIDERS } from './cloud-webdriver/providers.ts'; -import { AppError } from './kernel/errors.ts'; + listCloudWebDriverArtifactsFromEnv, + type DefaultCloudWebDriverArtifactEnv, +} from './cloud-webdriver/provider-registry.ts'; -export type DefaultCloudArtifactProviderEnv = { - BROWSERSTACK_USERNAME?: string; - BROWSERSTACK_ACCESS_KEY?: string; - BROWSERSTACK_SESSION_DETAILS_ENDPOINT?: string; - AWS_REGION?: string; - AWS_DEFAULT_REGION?: string; -}; +export type DefaultCloudArtifactProviderEnv = DefaultCloudWebDriverArtifactEnv; export function createDefaultCloudArtifactProvider( env: DefaultCloudArtifactProviderEnv = process.env, ): CloudArtifactProvider { return { - listCloudArtifacts: async (query) => { - if (!query.providerSessionId) return undefined; - switch (query.provider) { - case CLOUD_WEBDRIVER_PROVIDERS.browserStack: - return await listBrowserStackArtifacts(query.providerSessionId, env); - case CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm: - return await listAwsArtifacts(query.providerSessionId, env); - default: - return undefined; - } - }, + listCloudArtifacts: async (query) => await listCloudWebDriverArtifactsFromEnv(query, env), }; } - -async function listBrowserStackArtifacts( - providerSessionId: string, - env: DefaultCloudArtifactProviderEnv, -): Promise { - const username = env.BROWSERSTACK_USERNAME; - const accessKey = env.BROWSERSTACK_ACCESS_KEY; - if (!username || !accessKey) { - throw new AppError( - 'INVALID_ARGS', - 'BrowserStack artifact lookup requires BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.', - ); - } - return await listBrowserStackCloudArtifacts( - CLOUD_WEBDRIVER_PROVIDERS.browserStack, - providerSessionId, - { - username, - accessKey, - endpoint: env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, - }, - ); -} - -async function listAwsArtifacts( - providerSessionId: string, - env: DefaultCloudArtifactProviderEnv, -): Promise { - const client = createAwsCliDeviceFarmClient({ - region: - env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? readAwsRegionFromDeviceFarmArn(providerSessionId), - }); - return await listAwsDeviceFarmCloudArtifacts( - CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - providerSessionId, - client, - ); -} - -function readAwsRegionFromDeviceFarmArn(arn: string): string | undefined { - return /^arn:[^:]+:devicefarm:([^:]+):/.exec(arn)?.[1]; -} diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index c710d80ab..b3d7f01f9 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -411,7 +411,7 @@ function readXmlAttr(attrs: Map, name: string): string | null { return attrs.get(name) ?? null; } -function parseBounds(bounds: string | null): Rect | undefined { +export function parseBounds(bounds: string | null): Rect | undefined { if (!bounds) return undefined; const match = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(bounds); if (!match) return undefined; diff --git a/src/platforms/ios/xml.ts b/src/platforms/ios/xml.ts index 84f80e108..ce8c6855a 100644 --- a/src/platforms/ios/xml.ts +++ b/src/platforms/ios/xml.ts @@ -1,358 +1 @@ -export type XmlNode = { - name: string; - attributes: Record; - text: string | null; - children: XmlNode[]; -}; - -const MAX_XML_NESTING_DEPTH = 256; -const MAX_XML_DOCUMENT_CHARS = 128 * 1024 * 1024; -const XML_NAME_CHARS = new Set( - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-', -); -const XML_WHITESPACE_CHARS = new Set([' ', '\t', '\n', '\r']); -const UNSAFE_XML_ATTRIBUTE_NAMES = new Set([ - '__defineGetter__', - '__defineSetter__', - '__proto__', - 'constructor', - 'prototype', -]); - -export function parseXmlDocumentSync( - xml: string, - options: { maxDocumentChars?: number } = {}, -): XmlNode[] { - const maxDocumentChars = options.maxDocumentChars ?? MAX_XML_DOCUMENT_CHARS; - if (xml.length > maxDocumentChars) { - throw new Error( - `XML document exceeds maximum supported size of ${maxDocumentChars} characters.`, - ); - } - return new LimitedXmlParser(xml).parse(); -} - -export function visitXmlPlistEntries( - nodes: XmlNode[], - visitor: (key: string, valueNode: XmlNode) => void, -): void { - for (const node of nodes) { - if (node.name === 'dict') { - for (let index = 0; index < node.children.length - 1; index += 1) { - const entry = node.children[index]; - const nextEntry = node.children[index + 1]; - if (entry?.name === 'key' && entry.text && nextEntry) { - visitor(entry.text, nextEntry); - } - } - } - visitXmlPlistEntries(node.children, visitor); - } -} - -class LimitedXmlParser { - private readonly roots: XmlNode[] = []; - private readonly stack: XmlNode[] = []; - private index = 0; - private readonly xml: string; - - constructor(xml: string) { - this.xml = xml; - } - - parse(): XmlNode[] { - this.skipByteOrderMark(); - while (this.index < this.xml.length) { - this.readNextToken(); - } - this.assertFullyClosed(); - return this.roots; - } - - private readNextToken(): void { - if (this.xml[this.index] !== '<') { - this.readText(); - return; - } - - const reader = this.resolveMarkupReader(); - reader(); - } - - private resolveMarkupReader(): () => void { - if (this.startsWith('', 'Comment is not closed.'); - if (this.startsWith(' this.skipUntil('?>', 'Processing instruction is not closed.'); - if (this.startsWith(' this.readCdata(); - if (this.startsWith(' this.skipDeclaration(); - if (this.startsWith(' this.readClosingTag(); - return () => this.readOpeningTag(); - } - - private assertFullyClosed(): void { - if (this.stack.length > 0) { - const node = this.stack[this.stack.length - 1]; - throw new Error(`Unclosed XML tag <${node?.name ?? 'unknown'}>.`); - } - } - - private skipByteOrderMark(): void { - if (this.xml.charCodeAt(0) === 0xfeff) { - this.index = 1; - } - } - - private readOpeningTag(): void { - this.index += 1; - this.skipWhitespace(); - const name = this.readRequiredName(`Missing XML tag name at offset ${this.index}.`); - const { attributes, selfClosing } = this.readOpeningTagBody(); - - const node: XmlNode = { name, attributes, text: null, children: [] }; - this.addNode(node); - if (!selfClosing) { - this.pushOpenNode(node); - } - } - - private readOpeningTagBody(): { attributes: Record; selfClosing: boolean } { - const attributes: Record = {}; - while (true) { - this.skipWhitespace(); - const tagEnd = this.readOpeningTagEnd(); - if (tagEnd) return { attributes, selfClosing: tagEnd === 'self-closing' }; - const attribute = this.readAttribute(); - attributes[attribute.name] = attribute.value; - } - } - - private readOpeningTagEnd(): 'open' | 'self-closing' | null { - if (this.index >= this.xml.length) throw new Error('Opening XML tag is not closed.'); - if (this.xml[this.index] === '>') { - this.index += 1; - return 'open'; - } - if (this.xml[this.index] === '/' && this.xml[this.index + 1] === '>') { - this.index += 2; - return 'self-closing'; - } - return null; - } - - private readAttribute(): { name: string; value: string } { - const name = this.readRequiredName(`Invalid XML attribute at offset ${this.index}.`); - assertSafeXmlAttributeName(name); - this.skipWhitespace(); - if (this.xml[this.index] !== '=') { - throw new Error(`Missing value for XML attribute "${name}".`); - } - this.index += 1; - this.skipWhitespace(); - return { name, value: this.readAttributeValue(name) }; - } - - private pushOpenNode(node: XmlNode): void { - if (this.stack.length >= MAX_XML_NESTING_DEPTH) { - throw new Error(`Maximum XML nesting depth of ${MAX_XML_NESTING_DEPTH} exceeded.`); - } - this.stack.push(node); - } - - private readClosingTag(): void { - this.index += 2; - this.skipWhitespace(); - const name = this.readName(); - this.skipWhitespace(); - if (this.xml[this.index] !== '>') { - throw new Error(`Closing XML tag is not closed.`); - } - this.index += 1; - - const node = this.stack.pop(); - if (!node) { - throw new Error(`Unexpected closing XML tag .`); - } - if (node.name !== name) { - throw new Error(`Expected before .`); - } - } - - private readText(): void { - const nextTagIndex = this.xml.indexOf('<', this.index); - const endIndex = nextTagIndex === -1 ? this.xml.length : nextTagIndex; - this.appendText(this.xml.slice(this.index, endIndex), true); - this.index = endIndex; - } - - private readCdata(): void { - const startIndex = this.index + '', startIndex); - if (endIndex === -1) throw new Error('CDATA section is not closed.'); - this.appendText(this.xml.slice(startIndex, endIndex), false); - this.index = endIndex + ']]>'.length; - } - - private appendText(text: string, decodeEntities: boolean): void { - const trimmed = text.trim(); - if (!trimmed) return; - const node = this.stack[this.stack.length - 1]; - if (!node) return; - // Preserve fast-xml-parser's trimValues behavior for each text segment we keep. - node.text = `${node.text ?? ''}${decodeEntities ? decodeXmlEntities(trimmed) : trimmed}`; - } - - private addNode(node: XmlNode): void { - const parent = this.stack[this.stack.length - 1]; - if (parent) { - parent.children.push(node); - } else { - this.roots.push(node); - } - } - - private readName(): string { - const startIndex = this.index; - while (this.index < this.xml.length && isXmlNameChar(this.xml[this.index])) { - this.index += 1; - } - return this.xml.slice(startIndex, this.index); - } - - private readRequiredName(errorMessage: string): string { - const name = this.readName(); - if (!name) throw new Error(errorMessage); - return name; - } - - private readAttributeValue(attributeName: string): string { - const quote = this.xml[this.index]; - if (quote !== '"' && quote !== "'") { - throw new Error(`XML attribute "${attributeName}" must use a quoted value.`); - } - this.index += 1; - const startIndex = this.index; - const endIndex = this.xml.indexOf(quote, startIndex); - if (endIndex === -1) { - throw new Error(`XML attribute "${attributeName}" is not closed.`); - } - this.index = endIndex + 1; - return decodeXmlEntities(this.xml.slice(startIndex, endIndex).trim()); - } - - private skipDeclaration(): void { - const state: DeclarationScanState = { quote: null, bracketDepth: 0 }; - for (let cursor = this.index + 2; cursor < this.xml.length; cursor += 1) { - if (updateDeclarationScan(state, this.xml[cursor])) { - this.index = cursor + 1; - return; - } - } - throw new Error('XML declaration is not closed.'); - } - - private skipUntil(token: string, errorMessage: string): void { - // Opening markup tokens are longer than or equal to their closing tokens here, so - // this skips past the opening token without missing a valid overlapping close. - const endIndex = this.xml.indexOf(token, this.index + token.length); - if (endIndex === -1) throw new Error(errorMessage); - this.index = endIndex + token.length; - } - - private skipWhitespace(): void { - while (this.index < this.xml.length && isXmlWhitespace(this.xml[this.index])) { - this.index += 1; - } - } - - private startsWith(token: string): boolean { - return this.xml.startsWith(token, this.index); - } -} - -type DeclarationScanState = { - quote: string | null; - bracketDepth: number; -}; - -function isXmlNameChar(char: string | undefined): boolean { - return char !== undefined && XML_NAME_CHARS.has(char); -} - -function isXmlWhitespace(char: string | undefined): boolean { - return char !== undefined && XML_WHITESPACE_CHARS.has(char); -} - -function updateDeclarationScan(state: DeclarationScanState, char: string | undefined): boolean { - if (char === undefined) return false; - if (updateDeclarationQuote(state, char)) return false; - updateDeclarationBracketDepth(state, char); - return isDeclarationEnd(state, char); -} - -function updateDeclarationQuote(state: DeclarationScanState, char: string): boolean { - if (state.quote) { - if (char === state.quote) state.quote = null; - return true; - } - if (char === '"' || char === "'") { - state.quote = char; - return true; - } - return false; -} - -function updateDeclarationBracketDepth(state: DeclarationScanState, char: string): void { - if (char === '[') { - state.bracketDepth += 1; - return; - } - if (char === ']' && state.bracketDepth > 0) { - state.bracketDepth -= 1; - } -} - -function isDeclarationEnd(state: DeclarationScanState, char: string): boolean { - return char === '>' && state.bracketDepth === 0; -} - -function assertSafeXmlAttributeName(name: string): void { - if (UNSAFE_XML_ATTRIBUTE_NAMES.has(name)) { - throw new Error(`Unsupported XML attribute name "${name}".`); - } -} - -function decodeXmlEntities(value: string): string { - return value.replace( - /&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g, - (entity, body: string) => { - switch (body) { - case 'amp': - return '&'; - case 'lt': - return '<'; - case 'gt': - return '>'; - case 'quot': - return '"'; - case 'apos': - return "'"; - default: - return decodeNumericXmlEntity(entity, body); - } - }, - ); -} - -function decodeNumericXmlEntity(entity: string, body: string): string { - const codePoint = body.startsWith('#x') - ? Number.parseInt(body.slice(2), 16) - : Number(body.slice(1)); - if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { - return entity; - } - try { - return String.fromCodePoint(codePoint); - } catch { - return entity; - } -} +export * from '../../utils/xml.ts'; diff --git a/src/utils/xml.ts b/src/utils/xml.ts new file mode 100644 index 000000000..84f80e108 --- /dev/null +++ b/src/utils/xml.ts @@ -0,0 +1,358 @@ +export type XmlNode = { + name: string; + attributes: Record; + text: string | null; + children: XmlNode[]; +}; + +const MAX_XML_NESTING_DEPTH = 256; +const MAX_XML_DOCUMENT_CHARS = 128 * 1024 * 1024; +const XML_NAME_CHARS = new Set( + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-', +); +const XML_WHITESPACE_CHARS = new Set([' ', '\t', '\n', '\r']); +const UNSAFE_XML_ATTRIBUTE_NAMES = new Set([ + '__defineGetter__', + '__defineSetter__', + '__proto__', + 'constructor', + 'prototype', +]); + +export function parseXmlDocumentSync( + xml: string, + options: { maxDocumentChars?: number } = {}, +): XmlNode[] { + const maxDocumentChars = options.maxDocumentChars ?? MAX_XML_DOCUMENT_CHARS; + if (xml.length > maxDocumentChars) { + throw new Error( + `XML document exceeds maximum supported size of ${maxDocumentChars} characters.`, + ); + } + return new LimitedXmlParser(xml).parse(); +} + +export function visitXmlPlistEntries( + nodes: XmlNode[], + visitor: (key: string, valueNode: XmlNode) => void, +): void { + for (const node of nodes) { + if (node.name === 'dict') { + for (let index = 0; index < node.children.length - 1; index += 1) { + const entry = node.children[index]; + const nextEntry = node.children[index + 1]; + if (entry?.name === 'key' && entry.text && nextEntry) { + visitor(entry.text, nextEntry); + } + } + } + visitXmlPlistEntries(node.children, visitor); + } +} + +class LimitedXmlParser { + private readonly roots: XmlNode[] = []; + private readonly stack: XmlNode[] = []; + private index = 0; + private readonly xml: string; + + constructor(xml: string) { + this.xml = xml; + } + + parse(): XmlNode[] { + this.skipByteOrderMark(); + while (this.index < this.xml.length) { + this.readNextToken(); + } + this.assertFullyClosed(); + return this.roots; + } + + private readNextToken(): void { + if (this.xml[this.index] !== '<') { + this.readText(); + return; + } + + const reader = this.resolveMarkupReader(); + reader(); + } + + private resolveMarkupReader(): () => void { + if (this.startsWith('', 'Comment is not closed.'); + if (this.startsWith(' this.skipUntil('?>', 'Processing instruction is not closed.'); + if (this.startsWith(' this.readCdata(); + if (this.startsWith(' this.skipDeclaration(); + if (this.startsWith(' this.readClosingTag(); + return () => this.readOpeningTag(); + } + + private assertFullyClosed(): void { + if (this.stack.length > 0) { + const node = this.stack[this.stack.length - 1]; + throw new Error(`Unclosed XML tag <${node?.name ?? 'unknown'}>.`); + } + } + + private skipByteOrderMark(): void { + if (this.xml.charCodeAt(0) === 0xfeff) { + this.index = 1; + } + } + + private readOpeningTag(): void { + this.index += 1; + this.skipWhitespace(); + const name = this.readRequiredName(`Missing XML tag name at offset ${this.index}.`); + const { attributes, selfClosing } = this.readOpeningTagBody(); + + const node: XmlNode = { name, attributes, text: null, children: [] }; + this.addNode(node); + if (!selfClosing) { + this.pushOpenNode(node); + } + } + + private readOpeningTagBody(): { attributes: Record; selfClosing: boolean } { + const attributes: Record = {}; + while (true) { + this.skipWhitespace(); + const tagEnd = this.readOpeningTagEnd(); + if (tagEnd) return { attributes, selfClosing: tagEnd === 'self-closing' }; + const attribute = this.readAttribute(); + attributes[attribute.name] = attribute.value; + } + } + + private readOpeningTagEnd(): 'open' | 'self-closing' | null { + if (this.index >= this.xml.length) throw new Error('Opening XML tag is not closed.'); + if (this.xml[this.index] === '>') { + this.index += 1; + return 'open'; + } + if (this.xml[this.index] === '/' && this.xml[this.index + 1] === '>') { + this.index += 2; + return 'self-closing'; + } + return null; + } + + private readAttribute(): { name: string; value: string } { + const name = this.readRequiredName(`Invalid XML attribute at offset ${this.index}.`); + assertSafeXmlAttributeName(name); + this.skipWhitespace(); + if (this.xml[this.index] !== '=') { + throw new Error(`Missing value for XML attribute "${name}".`); + } + this.index += 1; + this.skipWhitespace(); + return { name, value: this.readAttributeValue(name) }; + } + + private pushOpenNode(node: XmlNode): void { + if (this.stack.length >= MAX_XML_NESTING_DEPTH) { + throw new Error(`Maximum XML nesting depth of ${MAX_XML_NESTING_DEPTH} exceeded.`); + } + this.stack.push(node); + } + + private readClosingTag(): void { + this.index += 2; + this.skipWhitespace(); + const name = this.readName(); + this.skipWhitespace(); + if (this.xml[this.index] !== '>') { + throw new Error(`Closing XML tag is not closed.`); + } + this.index += 1; + + const node = this.stack.pop(); + if (!node) { + throw new Error(`Unexpected closing XML tag .`); + } + if (node.name !== name) { + throw new Error(`Expected before .`); + } + } + + private readText(): void { + const nextTagIndex = this.xml.indexOf('<', this.index); + const endIndex = nextTagIndex === -1 ? this.xml.length : nextTagIndex; + this.appendText(this.xml.slice(this.index, endIndex), true); + this.index = endIndex; + } + + private readCdata(): void { + const startIndex = this.index + '', startIndex); + if (endIndex === -1) throw new Error('CDATA section is not closed.'); + this.appendText(this.xml.slice(startIndex, endIndex), false); + this.index = endIndex + ']]>'.length; + } + + private appendText(text: string, decodeEntities: boolean): void { + const trimmed = text.trim(); + if (!trimmed) return; + const node = this.stack[this.stack.length - 1]; + if (!node) return; + // Preserve fast-xml-parser's trimValues behavior for each text segment we keep. + node.text = `${node.text ?? ''}${decodeEntities ? decodeXmlEntities(trimmed) : trimmed}`; + } + + private addNode(node: XmlNode): void { + const parent = this.stack[this.stack.length - 1]; + if (parent) { + parent.children.push(node); + } else { + this.roots.push(node); + } + } + + private readName(): string { + const startIndex = this.index; + while (this.index < this.xml.length && isXmlNameChar(this.xml[this.index])) { + this.index += 1; + } + return this.xml.slice(startIndex, this.index); + } + + private readRequiredName(errorMessage: string): string { + const name = this.readName(); + if (!name) throw new Error(errorMessage); + return name; + } + + private readAttributeValue(attributeName: string): string { + const quote = this.xml[this.index]; + if (quote !== '"' && quote !== "'") { + throw new Error(`XML attribute "${attributeName}" must use a quoted value.`); + } + this.index += 1; + const startIndex = this.index; + const endIndex = this.xml.indexOf(quote, startIndex); + if (endIndex === -1) { + throw new Error(`XML attribute "${attributeName}" is not closed.`); + } + this.index = endIndex + 1; + return decodeXmlEntities(this.xml.slice(startIndex, endIndex).trim()); + } + + private skipDeclaration(): void { + const state: DeclarationScanState = { quote: null, bracketDepth: 0 }; + for (let cursor = this.index + 2; cursor < this.xml.length; cursor += 1) { + if (updateDeclarationScan(state, this.xml[cursor])) { + this.index = cursor + 1; + return; + } + } + throw new Error('XML declaration is not closed.'); + } + + private skipUntil(token: string, errorMessage: string): void { + // Opening markup tokens are longer than or equal to their closing tokens here, so + // this skips past the opening token without missing a valid overlapping close. + const endIndex = this.xml.indexOf(token, this.index + token.length); + if (endIndex === -1) throw new Error(errorMessage); + this.index = endIndex + token.length; + } + + private skipWhitespace(): void { + while (this.index < this.xml.length && isXmlWhitespace(this.xml[this.index])) { + this.index += 1; + } + } + + private startsWith(token: string): boolean { + return this.xml.startsWith(token, this.index); + } +} + +type DeclarationScanState = { + quote: string | null; + bracketDepth: number; +}; + +function isXmlNameChar(char: string | undefined): boolean { + return char !== undefined && XML_NAME_CHARS.has(char); +} + +function isXmlWhitespace(char: string | undefined): boolean { + return char !== undefined && XML_WHITESPACE_CHARS.has(char); +} + +function updateDeclarationScan(state: DeclarationScanState, char: string | undefined): boolean { + if (char === undefined) return false; + if (updateDeclarationQuote(state, char)) return false; + updateDeclarationBracketDepth(state, char); + return isDeclarationEnd(state, char); +} + +function updateDeclarationQuote(state: DeclarationScanState, char: string): boolean { + if (state.quote) { + if (char === state.quote) state.quote = null; + return true; + } + if (char === '"' || char === "'") { + state.quote = char; + return true; + } + return false; +} + +function updateDeclarationBracketDepth(state: DeclarationScanState, char: string): void { + if (char === '[') { + state.bracketDepth += 1; + return; + } + if (char === ']' && state.bracketDepth > 0) { + state.bracketDepth -= 1; + } +} + +function isDeclarationEnd(state: DeclarationScanState, char: string): boolean { + return char === '>' && state.bracketDepth === 0; +} + +function assertSafeXmlAttributeName(name: string): void { + if (UNSAFE_XML_ATTRIBUTE_NAMES.has(name)) { + throw new Error(`Unsupported XML attribute name "${name}".`); + } +} + +function decodeXmlEntities(value: string): string { + return value.replace( + /&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g, + (entity, body: string) => { + switch (body) { + case 'amp': + return '&'; + case 'lt': + return '<'; + case 'gt': + return '>'; + case 'quot': + return '"'; + case 'apos': + return "'"; + default: + return decodeNumericXmlEntity(entity, body); + } + }, + ); +} + +function decodeNumericXmlEntity(entity: string, body: string): string { + const codePoint = body.startsWith('#x') + ? Number.parseInt(body.slice(2), 16) + : Number(body.slice(1)); + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return entity; + } + try { + return String.fromCodePoint(codePoint); + } catch { + return entity; + } +} diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 4c13316d0..61d6020ae 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import { test } from 'vitest'; import { createCloudWebDriverRuntime } from '../../../src/cloud-webdriver.ts'; import { createDefaultCloudWebDriverProviderRuntimes } from '../../../src/cloud-webdriver/provider-runtimes.ts'; +import { parseWebDriverSource } from '../../../src/cloud-webdriver/webdriver-source.ts'; import { CLOUD_WEBDRIVER_PROVIDERS } from '../../../src/cloud-webdriver/providers.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; import type { DeviceLease } from '../../../src/daemon/lease-registry.ts'; @@ -150,6 +151,20 @@ test('default BrowserStack provider runtime builds sessions from daemon request } }, 15_000); +test('WebDriver source parser reuses hardened XML parsing', () => { + const nodes = parseWebDriverSource( + '', + ); + + assert.equal(nodes[0]?.label, 'A > B'); + assert.equal(nodes[0]?.identifier, 'login'); + assert.deepEqual(nodes[0]?.rect, { x: 0, y: 0, width: 10, height: 10 }); + assert.throws( + () => parseWebDriverSource(''), + /Unsupported XML attribute name "__proto__"/, + ); +}); + async function createCloudWebDriverWorld() { const server = await FakeWebDriverServer.start(); let artifactFailuresRemaining = 0; @@ -275,6 +290,13 @@ function cloudWebDriverScenarioSteps(appPath: string, lease: DeviceLease): Provi assert.equal(data.nodes?.[1]?.hittable, true); }, }, + { + name: 'scroll', + command: 'scroll', + positionals: ['down'], + flags: { pixels: 200 }, + expectData: { direction: 'down', distance: 200 }, + }, { name: 'artifacts', command: 'artifacts', @@ -326,6 +348,9 @@ function assertWebDriverCalls( 'DELETE /wd/hub/session/wd-1/actions', 'POST /wd/hub/session/wd-1/keys', 'GET /wd/hub/session/wd-1/source', + 'GET /wd/hub/session/wd-1/window/rect', + 'POST /wd/hub/session/wd-1/actions', + 'DELETE /wd/hub/session/wd-1/actions', 'DELETE /wd/hub/session/wd-1', ], ); @@ -348,6 +373,21 @@ function assertWebDriverCalls( args: [{ appId: 'com.example.demo', bundleId: 'com.example.demo' }], }); assert.deepEqual(calls[7]?.body, { text: 'hello cloud', value: Array.from('hello cloud') }); + assert.deepEqual(calls[10]?.body, { + actions: [ + { + type: 'pointer', + id: 'swipe', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: 540, y: 860 }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerMove', duration: 350, x: 540, y: 1060 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ], + }); for (const call of calls) { assert.equal(call.headers['x-agent-device-client'], 'agent-device-cli'); assert.equal(typeof call.headers['x-agent-device-version'], 'string'); @@ -415,6 +455,10 @@ class FakeWebDriverServer { }); return; } + if (call.method === 'GET' && call.path === '/wd/hub/session/wd-1/window/rect') { + writeJson(res, { value: { x: 0, y: 0, width: 1080, height: 1920 } }); + return; + } if (call.method === 'DELETE' && call.path === '/wd/hub/session/wd-1/actions') { writeJson( res, From b65bc0ab95483b6d56728f6099d1b3ddc43b376e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 12:06:04 +0200 Subject: [PATCH 07/17] refactor: consolidate cloud webdriver provider definitions --- src/cloud-webdriver/provider-definitions.ts | 248 ++++++++++++++++++++ src/cloud-webdriver/provider-registry.ts | 75 +----- src/cloud-webdriver/provider-runtimes.ts | 202 ++-------------- 3 files changed, 271 insertions(+), 254 deletions(-) create mode 100644 src/cloud-webdriver/provider-definitions.ts diff --git a/src/cloud-webdriver/provider-definitions.ts b/src/cloud-webdriver/provider-definitions.ts new file mode 100644 index 000000000..afd1fc0da --- /dev/null +++ b/src/cloud-webdriver/provider-definitions.ts @@ -0,0 +1,248 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { CloudArtifactsResult } from '../cloud-artifacts.ts'; +import type { DeviceLease } from '../daemon/lease-registry.ts'; +import type { DaemonRequest } from '../daemon/types.ts'; +import { AppError } from '../kernel/errors.ts'; +import type { ProviderDeviceRuntime } from '../provider-device-runtime.ts'; +import { + createAwsCliDeviceFarmClient, + createAwsDeviceFarmWebDriverRuntime, + listAwsDeviceFarmCloudArtifacts, +} from './aws-device-farm.ts'; +import { + createBrowserStackWebDriverRuntime, + listBrowserStackCloudArtifacts, + uploadBrowserStackApp, +} from './browserstack.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts'; +import type { CloudWebDriverPlatform } from './runtime.ts'; + +export type DefaultCloudWebDriverArtifactEnv = { + BROWSERSTACK_USERNAME?: string; + BROWSERSTACK_ACCESS_KEY?: string; + BROWSERSTACK_SESSION_DETAILS_ENDPOINT?: string; + AWS_REGION?: string; + AWS_DEFAULT_REGION?: string; +}; + +export type DefaultCloudWebDriverProviderRuntimeEnv = DefaultCloudWebDriverArtifactEnv & { + BROWSERSTACK_WEBDRIVER_ENDPOINT?: string; + BROWSERSTACK_APP_UPLOAD_ENDPOINT?: string; + AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN?: string; + AWS_DEVICE_FARM_PROJECT_ARN?: string; + AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN?: string; + AWS_DEVICE_FARM_DEVICE_ARN?: string; + AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN?: string; + AWS_DEVICE_FARM_APP_ARN?: string; +}; + +export type CloudWebDriverProviderDefinition = { + provider: CloudWebDriverKnownProviderName; + createRuntime: (params: { + req: DaemonRequest; + lease: DeviceLease; + env: DefaultCloudWebDriverProviderRuntimeEnv; + }) => Promise | ProviderDeviceRuntime; + listArtifactsFromEnv: ( + providerSessionId: string, + env: DefaultCloudWebDriverArtifactEnv, + ) => Promise; +}; + +export const CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS: readonly CloudWebDriverProviderDefinition[] = [ + { + provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + createRuntime: async ({ req, lease, env }) => { + const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack'); + const accessKey = requireEnv(env, 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack'); + const platform = requireRequestPlatform(req, 'BrowserStack'); + const deviceName = requireFlag(req, 'device', 'BrowserStack requires --device .'); + const osVersion = requireFlag( + req, + 'providerOsVersion', + 'BrowserStack requires --provider-os-version .', + ); + const app = await resolveBrowserStackAppReference({ + app: requireFlag( + req, + 'providerApp', + 'BrowserStack requires --provider-app .', + ), + cwd: req.meta?.cwd, + username, + accessKey, + uploadEndpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, + }); + return createBrowserStackWebDriverRuntime({ + username, + accessKey, + platform, + deviceName, + osVersion, + app, + projectName: readFlag(req, 'providerProject'), + buildName: readFlag(req, 'providerBuild') ?? lease.runId, + sessionName: readFlag(req, 'providerSessionName') ?? lease.leaseId, + endpoint: env.BROWSERSTACK_WEBDRIVER_ENDPOINT, + uploadEndpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, + sessionDetailsEndpoint: env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, + }); + }, + listArtifactsFromEnv: async (providerSessionId, env) => { + const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack artifact lookup'); + const accessKey = requireEnv(env, 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack artifact lookup'); + return await listBrowserStackCloudArtifacts( + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + providerSessionId, + { + username, + accessKey, + endpoint: env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, + }, + ); + }, + }, + { + provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + createRuntime: ({ req, lease, env }) => { + const platform = requireRequestPlatform(req, 'AWS Device Farm'); + return createAwsDeviceFarmWebDriverRuntime({ + projectArn: requireAwsValue( + req, + env, + 'awsProjectArn', + 'AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN', + 'AWS_DEVICE_FARM_PROJECT_ARN', + ), + deviceArn: requireAwsValue( + req, + env, + 'awsDeviceArn', + 'AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN', + 'AWS_DEVICE_FARM_DEVICE_ARN', + ), + appArn: + readFlag(req, 'awsAppArn') ?? + env.AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN ?? + env.AWS_DEVICE_FARM_APP_ARN, + region: readFlag(req, 'awsRegion') ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION, + platform, + deviceName: readFlag(req, 'device') ?? 'AWS Device Farm device', + sessionName: readFlag(req, 'providerSessionName') ?? lease.leaseId, + interactionMode: readAwsInteractionMode(req), + }); + }, + listArtifactsFromEnv: async (providerSessionId, env) => { + const client = createAwsCliDeviceFarmClient({ + region: + env.AWS_REGION ?? + env.AWS_DEFAULT_REGION ?? + readAwsRegionFromDeviceFarmArn(providerSessionId), + }); + return await listAwsDeviceFarmCloudArtifacts( + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + providerSessionId, + client, + ); + }, + }, +]; + +export function findCloudWebDriverProviderDefinition( + provider: string | undefined, +): CloudWebDriverProviderDefinition | undefined { + return CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS.find((entry) => entry.provider === provider); +} + +async function resolveBrowserStackAppReference(options: { + app: string; + cwd?: string; + username: string; + accessKey: string; + uploadEndpoint?: string; +}): Promise { + if (isProviderAppReference(options.app)) return options.app; + const appPath = path.resolve(options.cwd ?? process.cwd(), options.app); + if (!fs.existsSync(appPath)) { + throw new AppError( + 'INVALID_ARGS', + 'BrowserStack --provider-app must be a bs:// app id, URL, or existing local app path.', + { providerApp: options.app }, + ); + } + return await uploadBrowserStackApp(appPath, { + username: options.username, + accessKey: options.accessKey, + endpoint: options.uploadEndpoint, + }); +} + +function isProviderAppReference(value: string): boolean { + return value.startsWith('bs://') || /^https?:\/\//.test(value); +} + +function requireRequestPlatform(req: DaemonRequest, providerLabel: string): CloudWebDriverPlatform { + const platform = req.flags?.platform; + if (platform === 'android' || platform === 'ios') return platform; + throw new AppError('INVALID_ARGS', `${providerLabel} requires --platform ios|android.`); +} + +function requireFlag( + req: DaemonRequest, + key: keyof NonNullable, + message: string, +): string { + const value = readFlag(req, key); + if (value) return value; + throw new AppError('INVALID_ARGS', message); +} + +function readFlag( + req: DaemonRequest, + key: keyof NonNullable, +): string | undefined { + const value = req.flags?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function requireEnv( + env: DefaultCloudWebDriverProviderRuntimeEnv, + key: keyof DefaultCloudWebDriverProviderRuntimeEnv, + providerLabel: string, +): string { + const value = env[key]; + if (value) return value; + throw new AppError('INVALID_ARGS', `${providerLabel} requires ${key} in the environment.`); +} + +function requireAwsValue( + req: DaemonRequest, + env: DefaultCloudWebDriverProviderRuntimeEnv, + flagKey: keyof NonNullable, + primaryEnv: keyof DefaultCloudWebDriverProviderRuntimeEnv, + fallbackEnv: keyof DefaultCloudWebDriverProviderRuntimeEnv, +): string { + const value = readFlag(req, flagKey) ?? env[primaryEnv] ?? env[fallbackEnv]; + if (value) return value; + throw new AppError( + 'INVALID_ARGS', + `AWS Device Farm requires --${dasherize(String(flagKey))} or ${fallbackEnv}.`, + ); +} + +function readAwsInteractionMode( + req: DaemonRequest, +): 'INTERACTIVE' | 'NO_VIDEO' | 'VIDEO_ONLY' | undefined { + const value = readFlag(req, 'awsInteractionMode'); + if (value === 'INTERACTIVE' || value === 'NO_VIDEO' || value === 'VIDEO_ONLY') return value; + return undefined; +} + +function readAwsRegionFromDeviceFarmArn(arn: string): string | undefined { + return /^arn:[^:]+:devicefarm:([^:]+):/.exec(arn)?.[1]; +} + +function dasherize(value: string): string { + return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} diff --git a/src/cloud-webdriver/provider-registry.ts b/src/cloud-webdriver/provider-registry.ts index 2ef35bc6d..d0f6d839b 100644 --- a/src/cloud-webdriver/provider-registry.ts +++ b/src/cloud-webdriver/provider-registry.ts @@ -1,77 +1,18 @@ import type { CloudArtifactsQuery, CloudArtifactsResult } from '../cloud-artifacts.ts'; -import { createAwsCliDeviceFarmClient, listAwsDeviceFarmCloudArtifacts } from './aws-device-farm.ts'; -import { listBrowserStackCloudArtifacts } from './browserstack.ts'; -import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; -import { AppError } from '../kernel/errors.ts'; +import { + findCloudWebDriverProviderDefinition, + type DefaultCloudWebDriverArtifactEnv, +} from './provider-definitions.ts'; -export type DefaultCloudWebDriverArtifactEnv = { - BROWSERSTACK_USERNAME?: string; - BROWSERSTACK_ACCESS_KEY?: string; - BROWSERSTACK_SESSION_DETAILS_ENDPOINT?: string; - AWS_REGION?: string; - AWS_DEFAULT_REGION?: string; -}; - -type CloudWebDriverProviderRegistryEntry = { - provider: string; - listArtifactsFromEnv: ( - providerSessionId: string, - env: DefaultCloudWebDriverArtifactEnv, - ) => Promise; -}; - -const CLOUD_WEBDRIVER_PROVIDER_REGISTRY: readonly CloudWebDriverProviderRegistryEntry[] = [ - { - provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, - listArtifactsFromEnv: async (providerSessionId, env) => { - const username = env.BROWSERSTACK_USERNAME; - const accessKey = env.BROWSERSTACK_ACCESS_KEY; - if (!username || !accessKey) { - throw new AppError( - 'INVALID_ARGS', - 'BrowserStack artifact lookup requires BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.', - ); - } - return await listBrowserStackCloudArtifacts( - CLOUD_WEBDRIVER_PROVIDERS.browserStack, - providerSessionId, - { - username, - accessKey, - endpoint: env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, - }, - ); - }, - }, - { - provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - listArtifactsFromEnv: async (providerSessionId, env) => { - const client = createAwsCliDeviceFarmClient({ - region: - env.AWS_REGION ?? - env.AWS_DEFAULT_REGION ?? - readAwsRegionFromDeviceFarmArn(providerSessionId), - }); - return await listAwsDeviceFarmCloudArtifacts( - CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - providerSessionId, - client, - ); - }, - }, -]; +export type { DefaultCloudWebDriverArtifactEnv } from './provider-definitions.ts'; export async function listCloudWebDriverArtifactsFromEnv( query: CloudArtifactsQuery, env: DefaultCloudWebDriverArtifactEnv, ): Promise { if (!query.providerSessionId) return undefined; - const entry = CLOUD_WEBDRIVER_PROVIDER_REGISTRY.find( - (candidate) => candidate.provider === query.provider, + return await findCloudWebDriverProviderDefinition(query.provider)?.listArtifactsFromEnv( + query.providerSessionId, + env, ); - return await entry?.listArtifactsFromEnv(query.providerSessionId, env); -} - -function readAwsRegionFromDeviceFarmArn(arn: string): string | undefined { - return /^arn:[^:]+:devicefarm:([^:]+):/.exec(arn)?.[1]; } diff --git a/src/cloud-webdriver/provider-runtimes.ts b/src/cloud-webdriver/provider-runtimes.ts index fb7eefc80..c8a1fe8c3 100644 --- a/src/cloud-webdriver/provider-runtimes.ts +++ b/src/cloud-webdriver/provider-runtimes.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; import type { CloudArtifactProvider, CloudArtifactsQuery, @@ -18,46 +16,38 @@ import { type ProviderDeviceRuntime, type ProviderPortReverseOptions, } from '../provider-device-runtime.ts'; -import { createAwsDeviceFarmWebDriverRuntime } from './aws-device-farm.ts'; -import { createBrowserStackWebDriverRuntime, uploadBrowserStackApp } from './browserstack.ts'; -import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts'; -import type { DefaultCloudWebDriverArtifactEnv } from './provider-registry.ts'; -import type { CloudWebDriverPlatform } from './runtime.ts'; +import { + CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS, + type CloudWebDriverProviderDefinition, + type DefaultCloudWebDriverProviderRuntimeEnv, +} from './provider-definitions.ts'; -export type DefaultCloudWebDriverProviderRuntimeEnv = DefaultCloudWebDriverArtifactEnv & { - BROWSERSTACK_WEBDRIVER_ENDPOINT?: string; - BROWSERSTACK_APP_UPLOAD_ENDPOINT?: string; - AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN?: string; - AWS_DEVICE_FARM_PROJECT_ARN?: string; - AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN?: string; - AWS_DEVICE_FARM_DEVICE_ARN?: string; - AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN?: string; - AWS_DEVICE_FARM_APP_ARN?: string; -}; +export type { DefaultCloudWebDriverProviderRuntimeEnv } from './provider-definitions.ts'; export function createDefaultCloudWebDriverProviderRuntimes( env: DefaultCloudWebDriverProviderRuntimeEnv = process.env, ): ProviderDeviceRuntime[] { - return [ - new LazyCloudWebDriverProviderRuntime(CLOUD_WEBDRIVER_PROVIDERS.browserStack, env), - new LazyCloudWebDriverProviderRuntime(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, env), - ]; + return CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS.map( + (definition) => new LazyCloudWebDriverProviderRuntime(definition, env), + ); } class LazyCloudWebDriverProviderRuntime implements ProviderDeviceRuntime { readonly leaseLifecycle: LeaseLifecycleProvider; readonly cloudArtifacts: CloudArtifactProvider; readonly deviceInventoryProvider: DeviceInventoryProvider; - readonly provider: CloudWebDriverKnownProviderName; + readonly provider: CloudWebDriverProviderDefinition['provider']; + private readonly definition: CloudWebDriverProviderDefinition; private readonly env: DefaultCloudWebDriverProviderRuntimeEnv; private readonly runtimesByLeaseId = new Map(); constructor( - provider: CloudWebDriverKnownProviderName, + definition: CloudWebDriverProviderDefinition, env: DefaultCloudWebDriverProviderRuntimeEnv, ) { - this.provider = provider; + this.definition = definition; + this.provider = definition.provider; this.env = env; this.leaseLifecycle = { allocate: async (lease, context) => await this.allocate(lease, context), @@ -190,168 +180,6 @@ class LazyCloudWebDriverProviderRuntime implements ProviderDeviceRuntime { `${this.provider} lease allocation requires provider profile flags on the request.`, ); } - return this.provider === CLOUD_WEBDRIVER_PROVIDERS.browserStack - ? await this.createBrowserStackRuntime(req, lease) - : this.createAwsDeviceFarmRuntime(req, lease); - } - - private async createBrowserStackRuntime( - req: DaemonRequest, - lease: DeviceLease, - ): Promise { - const username = requireEnv(this.env, 'BROWSERSTACK_USERNAME', 'BrowserStack'); - const accessKey = requireEnv(this.env, 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack'); - const platform = requireRequestPlatform(req, 'BrowserStack'); - const deviceName = requireFlag(req, 'device', 'BrowserStack requires --device .'); - const osVersion = requireFlag( - req, - 'providerOsVersion', - 'BrowserStack requires --provider-os-version .', - ); - const app = await resolveBrowserStackAppReference({ - app: requireFlag( - req, - 'providerApp', - 'BrowserStack requires --provider-app .', - ), - cwd: req.meta?.cwd, - username, - accessKey, - uploadEndpoint: this.env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, - }); - return createBrowserStackWebDriverRuntime({ - username, - accessKey, - platform, - deviceName, - osVersion, - app, - projectName: readFlag(req, 'providerProject'), - buildName: readFlag(req, 'providerBuild') ?? lease.runId, - sessionName: readFlag(req, 'providerSessionName') ?? lease.leaseId, - endpoint: this.env.BROWSERSTACK_WEBDRIVER_ENDPOINT, - uploadEndpoint: this.env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, - sessionDetailsEndpoint: this.env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, - }); - } - - private createAwsDeviceFarmRuntime( - req: DaemonRequest, - lease: DeviceLease, - ): ProviderDeviceRuntime { - const platform = requireRequestPlatform(req, 'AWS Device Farm'); - return createAwsDeviceFarmWebDriverRuntime({ - projectArn: requireAwsValue( - req, - this.env, - 'awsProjectArn', - 'AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN', - 'AWS_DEVICE_FARM_PROJECT_ARN', - ), - deviceArn: requireAwsValue( - req, - this.env, - 'awsDeviceArn', - 'AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN', - 'AWS_DEVICE_FARM_DEVICE_ARN', - ), - appArn: - readFlag(req, 'awsAppArn') ?? - this.env.AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN ?? - this.env.AWS_DEVICE_FARM_APP_ARN, - region: readFlag(req, 'awsRegion') ?? this.env.AWS_REGION ?? this.env.AWS_DEFAULT_REGION, - platform, - deviceName: readFlag(req, 'device') ?? 'AWS Device Farm device', - sessionName: readFlag(req, 'providerSessionName') ?? lease.leaseId, - interactionMode: readAwsInteractionMode(req), - }); - } -} - -async function resolveBrowserStackAppReference(options: { - app: string; - cwd?: string; - username: string; - accessKey: string; - uploadEndpoint?: string; -}): Promise { - if (isProviderAppReference(options.app)) return options.app; - const appPath = path.resolve(options.cwd ?? process.cwd(), options.app); - if (!fs.existsSync(appPath)) { - throw new AppError( - 'INVALID_ARGS', - 'BrowserStack --provider-app must be a bs:// app id, URL, or existing local app path.', - { providerApp: options.app }, - ); + return await this.definition.createRuntime({ req, lease, env: this.env }); } - return await uploadBrowserStackApp(appPath, { - username: options.username, - accessKey: options.accessKey, - endpoint: options.uploadEndpoint, - }); -} - -function isProviderAppReference(value: string): boolean { - return value.startsWith('bs://') || /^https?:\/\//.test(value); -} - -function requireRequestPlatform(req: DaemonRequest, providerLabel: string): CloudWebDriverPlatform { - const platform = req.flags?.platform; - if (platform === 'android' || platform === 'ios') return platform; - throw new AppError('INVALID_ARGS', `${providerLabel} requires --platform ios|android.`); -} - -function requireFlag( - req: DaemonRequest, - key: keyof NonNullable, - message: string, -): string { - const value = readFlag(req, key); - if (value) return value; - throw new AppError('INVALID_ARGS', message); -} - -function readFlag( - req: DaemonRequest, - key: keyof NonNullable, -): string | undefined { - const value = req.flags?.[key]; - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - -function requireEnv( - env: DefaultCloudWebDriverProviderRuntimeEnv, - key: keyof DefaultCloudWebDriverProviderRuntimeEnv, - providerLabel: string, -): string { - const value = env[key]; - if (value) return value; - throw new AppError('INVALID_ARGS', `${providerLabel} requires ${key} in the environment.`); -} - -function requireAwsValue( - req: DaemonRequest, - env: DefaultCloudWebDriverProviderRuntimeEnv, - flagKey: keyof NonNullable, - primaryEnv: keyof DefaultCloudWebDriverProviderRuntimeEnv, - fallbackEnv: keyof DefaultCloudWebDriverProviderRuntimeEnv, -): string { - const value = readFlag(req, flagKey) ?? env[primaryEnv] ?? env[fallbackEnv]; - if (value) return value; - throw new AppError( - 'INVALID_ARGS', - `AWS Device Farm requires --${dasherize(String(flagKey))} or ${fallbackEnv}.`, - ); -} - -function readAwsInteractionMode( - req: DaemonRequest, -): 'INTERACTIVE' | 'NO_VIDEO' | 'VIDEO_ONLY' | undefined { - const value = readFlag(req, 'awsInteractionMode'); - if (value === 'INTERACTIVE' || value === 'NO_VIDEO' || value === 'VIDEO_ONLY') return value; - return undefined; -} - -function dasherize(value: string): string { - return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); } From dec0948689ddcc3758e33e01f5105d64cb231774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 12:59:14 +0200 Subject: [PATCH 08/17] refactor: collapse hosted webdriver runtime wrapper --- src/cli/commands/connection-runtime.ts | 3 +- src/cli/provider-connection-profile.ts | 31 ++- src/client/client-types.ts | 6 +- src/cloud-artifacts.ts | 6 + src/cloud-webdriver/aws-device-farm.ts | 19 +- src/cloud-webdriver/browserstack.ts | 11 +- src/cloud-webdriver/provider-definitions.ts | 229 +++++++++++++------- src/cloud-webdriver/provider-runtimes.ts | 176 +-------------- src/cloud-webdriver/runtime.ts | 42 +++- src/daemon-runtime.ts | 24 +- src/provider-device-runtime.ts | 18 ++ 11 files changed, 269 insertions(+), 296 deletions(-) diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 3a61a2d5c..ee99421ed 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -20,6 +20,7 @@ import { AppError } from '../../kernel/errors.ts'; import type { LeaseBackend, SessionRuntimeHints } from '../../kernel/contracts.ts'; import type { CliFlags } from '../parser/cli-flags.ts'; import type { AgentDeviceClient, Lease } from '../../client/client.ts'; +import type { CloudProviderSessionResult } from '../../cloud-artifacts.ts'; import type { MetroPrepareKind } from '../../metro/client-metro.ts'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { isCloudWebDriverProviderName } from '../../cloud-webdriver/providers.ts'; @@ -458,7 +459,7 @@ export async function stopReactDevtoolsCleanup(options: { export async function releaseRemoteConnectionLease( client: AgentDeviceClient, state: RemoteConnectionState, -): Promise<{ released: boolean; provider?: Record }> { +): Promise<{ released: boolean; provider?: CloudProviderSessionResult }> { if (!state.leaseId) return { released: false }; const result = await client.leases.release({ tenant: state.tenant, diff --git a/src/cli/provider-connection-profile.ts b/src/cli/provider-connection-profile.ts index ee1d558be..8f3ecdd76 100644 --- a/src/cli/provider-connection-profile.ts +++ b/src/cli/provider-connection-profile.ts @@ -16,10 +16,7 @@ export function resolveCloudWebDriverConnectProfile(options: { cwd: string; env?: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const providerConfig = - options.provider === CLOUD_WEBDRIVER_PROVIDERS.browserStack - ? browserStackProfileFields(options) - : awsDeviceFarmProfileFields(options); + const providerConfig = requireConnectProfileBuilder(options.provider)(options); const clientId = buildCloudWebDriverClientId( options.provider, options.stateDir, @@ -59,6 +56,32 @@ export function resolveCloudWebDriverConnectProfile(options: { }); } +type ConnectProfileBuilder = (options: { flags: CliFlags; env?: EnvMap }) => RemoteConfigProfile; + +const CLOUD_WEBDRIVER_CONNECT_PROFILE_BUILDERS: readonly { + provider: CloudWebDriverKnownProviderName; + buildProfileFields: ConnectProfileBuilder; +}[] = [ + { + provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + buildProfileFields: browserStackProfileFields, + }, + { + provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + buildProfileFields: awsDeviceFarmProfileFields, + }, +]; + +function requireConnectProfileBuilder( + provider: CloudWebDriverKnownProviderName, +): ConnectProfileBuilder { + const builder = CLOUD_WEBDRIVER_CONNECT_PROFILE_BUILDERS.find( + (entry) => entry.provider === provider, + )?.buildProfileFields; + if (builder) return builder; + throw new AppError('INVALID_ARGS', `Unsupported cloud WebDriver provider "${provider}".`); +} + function browserStackProfileFields(options: { flags: CliFlags; env?: EnvMap; diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 0ccb25ab0..c40f1c6e1 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -49,7 +49,7 @@ import type { RemoteConnectionProfileFields, } from '../remote/remote-config-schema.ts'; import type { CommandResult } from '../core/command-descriptor/command-result.ts'; -import type { CloudArtifactsResult } from '../cloud-artifacts.ts'; +import type { CloudArtifactsResult, CloudProviderSessionResult } from '../cloud-artifacts.ts'; export type { FindLocator } from '../utils/finders.ts'; export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; @@ -201,7 +201,7 @@ export type StartupPerfSample = { export type SessionCloseResult = { session: string; shutdown?: TargetShutdownResult; - provider?: Record; + provider?: CloudProviderSessionResult; identifiers: AgentDeviceIdentifiers; }; @@ -960,7 +960,7 @@ export type AgentDeviceClient = { heartbeat: (options: LeaseScopedOptions) => Promise; release: ( options: LeaseScopedOptions, - ) => Promise<{ released: boolean; provider?: Record }>; + ) => Promise<{ released: boolean; provider?: CloudProviderSessionResult }>; }; metro: { prepare: (options: MetroPrepareOptions) => Promise; diff --git a/src/cloud-artifacts.ts b/src/cloud-artifacts.ts index b3c480ceb..565b3c9c5 100644 --- a/src/cloud-artifacts.ts +++ b/src/cloud-artifacts.ts @@ -40,6 +40,12 @@ export type CloudArtifactsQuery = { providerSessionId?: string; }; +export type CloudProviderSessionResult = { + provider?: string; + providerSessionId?: string; + cloudArtifacts?: CloudArtifactsResult; +} & Record; + /** * Return undefined only when this provider implementation does not handle the query. * Return a CloudArtifactsResult with status "unavailable" when the provider handled the diff --git a/src/cloud-webdriver/aws-device-farm.ts b/src/cloud-webdriver/aws-device-farm.ts index 6895c2f21..7dbd2c037 100644 --- a/src/cloud-webdriver/aws-device-farm.ts +++ b/src/cloud-webdriver/aws-device-farm.ts @@ -24,7 +24,7 @@ import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; import { resolveLeaseValue, type LeaseValue } from './webdriver-utils.ts'; const AWS_DEVICE_FARM_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm; -const AWS_DEVICE_FARM_CAPABILITY_OVERRIDES = { +export const AWS_DEVICE_FARM_CAPABILITY_OVERRIDES = { install: { support: 'unsupported', note: 'Pass appArn when creating the remote access session; local artifact upload/install is not implemented.', @@ -97,6 +97,7 @@ export type AwsDeviceFarmWebDriverRuntimeOptions = { configuration?: AwsCreateRemoteAccessSessionInput['configuration']; deviceId?: CloudWebDriverRuntimeOptions['deviceId']; requestPolicy?: CloudWebDriverRuntimeOptions['requestPolicy']; + prepareSession?: CloudWebDriverRuntimeOptions['prepareSession']; }; export function getAwsDeviceFarmWebDriverCapabilities( @@ -121,12 +122,14 @@ export function createAwsDeviceFarmWebDriverRuntime( platform, deviceName, webdriverCapabilities: options.webdriverCapabilities, - prepareSession: createAwsDeviceFarmPrepareSession({ - ...options, - platform, - deviceName, - client, - }), + prepareSession: + options.prepareSession ?? + createAwsDeviceFarmPrepareSession({ + ...options, + platform, + deviceName, + client, + }), deviceId: options.deviceId, requestPolicy: options.requestPolicy, capabilityOverrides: AWS_DEVICE_FARM_CAPABILITY_OVERRIDES, @@ -205,7 +208,7 @@ export function createAwsCliDeviceFarmClient( }; } -function createAwsDeviceFarmPrepareSession( +export function createAwsDeviceFarmPrepareSession( options: Required< Pick< AwsDeviceFarmWebDriverRuntimeOptions, diff --git a/src/cloud-webdriver/browserstack.ts b/src/cloud-webdriver/browserstack.ts index c4374277e..4b14b16e5 100644 --- a/src/cloud-webdriver/browserstack.ts +++ b/src/cloud-webdriver/browserstack.ts @@ -25,11 +25,12 @@ import { } from './webdriver-utils.ts'; const BROWSERSTACK_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.browserStack; -const BROWSERSTACK_APP_AUTOMATE_ENDPOINT = 'https://hub-cloud.browserstack.com/wd/hub/'; -const BROWSERSTACK_APP_UPLOAD_ENDPOINT = 'https://api-cloud.browserstack.com/app-automate/upload'; +export const BROWSERSTACK_APP_AUTOMATE_ENDPOINT = 'https://hub-cloud.browserstack.com/wd/hub/'; +export const BROWSERSTACK_APP_UPLOAD_ENDPOINT = + 'https://api-cloud.browserstack.com/app-automate/upload'; const BROWSERSTACK_SESSION_DETAILS_ENDPOINT = 'https://api-cloud.browserstack.com/app-automate/sessions'; -const BROWSERSTACK_CAPABILITY_OVERRIDES = { +export const BROWSERSTACK_CAPABILITY_OVERRIDES = { install: { support: 'partial', note: 'Local app artifacts are uploaded to BrowserStack App Automate, then installed with Appium.', @@ -62,6 +63,7 @@ export type BrowserStackWebDriverRuntimeOptions = { sessionDetailsEndpoint?: string | URL; deviceId?: CloudWebDriverRuntimeOptions['deviceId']; requestPolicy?: CloudWebDriverRuntimeOptions['requestPolicy']; + prepareSession?: CloudWebDriverRuntimeOptions['prepareSession']; }; export function getBrowserStackWebDriverCapabilities( @@ -101,6 +103,7 @@ export function createBrowserStackWebDriverRuntime( listArtifacts: async ({ provider, providerSessionId }) => await listBrowserStackCloudArtifacts(provider, providerSessionId, artifactOptions), deviceId: options.deviceId, + prepareSession: options.prepareSession, requestPolicy: options.requestPolicy, capabilityOverrides: BROWSERSTACK_CAPABILITY_OVERRIDES, }); @@ -161,7 +164,7 @@ export async function uploadBrowserStackApp( return appUrl; } -function createBrowserStackUploadApp( +export function createBrowserStackUploadApp( options: Required, ): CloudWebDriverUploadApp { return async ({ appPath, options: installOptions }) => { diff --git a/src/cloud-webdriver/provider-definitions.ts b/src/cloud-webdriver/provider-definitions.ts index afd1fc0da..696d76be2 100644 --- a/src/cloud-webdriver/provider-definitions.ts +++ b/src/cloud-webdriver/provider-definitions.ts @@ -1,22 +1,25 @@ import fs from 'node:fs'; import path from 'node:path'; import type { CloudArtifactsResult } from '../cloud-artifacts.ts'; -import type { DeviceLease } from '../daemon/lease-registry.ts'; import type { DaemonRequest } from '../daemon/types.ts'; import { AppError } from '../kernel/errors.ts'; import type { ProviderDeviceRuntime } from '../provider-device-runtime.ts'; import { + AWS_DEVICE_FARM_CAPABILITY_OVERRIDES, createAwsCliDeviceFarmClient, - createAwsDeviceFarmWebDriverRuntime, + createAwsDeviceFarmPrepareSession, listAwsDeviceFarmCloudArtifacts, } from './aws-device-farm.ts'; import { - createBrowserStackWebDriverRuntime, + BROWSERSTACK_APP_AUTOMATE_ENDPOINT, + BROWSERSTACK_APP_UPLOAD_ENDPOINT, + BROWSERSTACK_CAPABILITY_OVERRIDES, + createBrowserStackUploadApp, listBrowserStackCloudArtifacts, uploadBrowserStackApp, } from './browserstack.ts'; import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts'; -import type { CloudWebDriverPlatform } from './runtime.ts'; +import { createCloudWebDriverRuntime, type CloudWebDriverPlatform } from './runtime.ts'; export type DefaultCloudWebDriverArtifactEnv = { BROWSERSTACK_USERNAME?: string; @@ -39,11 +42,7 @@ export type DefaultCloudWebDriverProviderRuntimeEnv = DefaultCloudWebDriverArtif export type CloudWebDriverProviderDefinition = { provider: CloudWebDriverKnownProviderName; - createRuntime: (params: { - req: DaemonRequest; - lease: DeviceLease; - env: DefaultCloudWebDriverProviderRuntimeEnv; - }) => Promise | ProviderDeviceRuntime; + createRuntime: (env: DefaultCloudWebDriverProviderRuntimeEnv) => ProviderDeviceRuntime; listArtifactsFromEnv: ( providerSessionId: string, env: DefaultCloudWebDriverArtifactEnv, @@ -53,42 +52,74 @@ export type CloudWebDriverProviderDefinition = { export const CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS: readonly CloudWebDriverProviderDefinition[] = [ { provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, - createRuntime: async ({ req, lease, env }) => { - const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack'); - const accessKey = requireEnv(env, 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack'); - const platform = requireRequestPlatform(req, 'BrowserStack'); - const deviceName = requireFlag(req, 'device', 'BrowserStack requires --device .'); - const osVersion = requireFlag( - req, - 'providerOsVersion', - 'BrowserStack requires --provider-os-version .', - ); - const app = await resolveBrowserStackAppReference({ - app: requireFlag( - req, - 'providerApp', - 'BrowserStack requires --provider-app .', - ), - cwd: req.meta?.cwd, - username, - accessKey, - uploadEndpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, - }); - return createBrowserStackWebDriverRuntime({ - username, - accessKey, - platform, - deviceName, - osVersion, - app, - projectName: readFlag(req, 'providerProject'), - buildName: readFlag(req, 'providerBuild') ?? lease.runId, - sessionName: readFlag(req, 'providerSessionName') ?? lease.leaseId, - endpoint: env.BROWSERSTACK_WEBDRIVER_ENDPOINT, - uploadEndpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, - sessionDetailsEndpoint: env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, - }); - }, + createRuntime: (env) => + createCloudWebDriverRuntime({ + provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + platform: 'android', + deviceName: 'BrowserStack device', + endpoint: env.BROWSERSTACK_WEBDRIVER_ENDPOINT ?? BROWSERSTACK_APP_AUTOMATE_ENDPOINT, + capabilityOverrides: BROWSERSTACK_CAPABILITY_OVERRIDES, + listArtifacts: async ({ provider, providerSessionId }) => { + const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack artifact lookup'); + const accessKey = requireEnv( + env, + 'BROWSERSTACK_ACCESS_KEY', + 'BrowserStack artifact lookup', + ); + return await listBrowserStackCloudArtifacts(provider, providerSessionId, { + username, + accessKey, + endpoint: env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, + }); + }, + prepareSession: async ({ req, lease, base }) => { + const request = requireRequest(req, 'BrowserStack'); + const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack'); + const accessKey = requireEnv(env, 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack'); + const platform = requireRequestPlatform(request, 'BrowserStack'); + const deviceName = requireFlag( + request, + 'device', + 'BrowserStack requires --device .', + ); + const osVersion = requireFlag( + request, + 'providerOsVersion', + 'BrowserStack requires --provider-os-version .', + ); + const app = await resolveBrowserStackAppReference({ + app: requireFlag( + request, + 'providerApp', + 'BrowserStack requires --provider-app .', + ), + cwd: request.meta?.cwd, + username, + accessKey, + uploadEndpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, + }); + return { + ...base, + platform, + deviceName, + auth: { username, accessKey }, + uploadApp: createBrowserStackUploadApp({ + username, + accessKey, + endpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT, + }), + webdriverCapabilities: browserStackCapabilities({ + platform, + deviceName, + osVersion, + app, + projectName: readFlag(request, 'providerProject'), + buildName: readFlag(request, 'providerBuild') ?? lease.runId, + sessionName: readFlag(request, 'providerSessionName') ?? lease.leaseId, + }), + }; + }, + }), listArtifactsFromEnv: async (providerSessionId, env) => { const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack artifact lookup'); const accessKey = requireEnv(env, 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack artifact lookup'); @@ -105,34 +136,55 @@ export const CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS: readonly CloudWebDriverProvid }, { provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - createRuntime: ({ req, lease, env }) => { - const platform = requireRequestPlatform(req, 'AWS Device Farm'); - return createAwsDeviceFarmWebDriverRuntime({ - projectArn: requireAwsValue( - req, - env, - 'awsProjectArn', - 'AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN', - 'AWS_DEVICE_FARM_PROJECT_ARN', - ), - deviceArn: requireAwsValue( - req, - env, - 'awsDeviceArn', - 'AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN', - 'AWS_DEVICE_FARM_DEVICE_ARN', - ), - appArn: - readFlag(req, 'awsAppArn') ?? - env.AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN ?? - env.AWS_DEVICE_FARM_APP_ARN, - region: readFlag(req, 'awsRegion') ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION, - platform, - deviceName: readFlag(req, 'device') ?? 'AWS Device Farm device', - sessionName: readFlag(req, 'providerSessionName') ?? lease.leaseId, - interactionMode: readAwsInteractionMode(req), - }); - }, + createRuntime: (env) => + createCloudWebDriverRuntime({ + provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + endpoint: 'http://127.0.0.1/', + platform: 'android', + deviceName: 'AWS Device Farm device', + capabilityOverrides: AWS_DEVICE_FARM_CAPABILITY_OVERRIDES, + listArtifacts: async ({ provider, providerSessionId }) => { + const client = createAwsCliDeviceFarmClient({ + region: + env.AWS_REGION ?? + env.AWS_DEFAULT_REGION ?? + readAwsRegionFromDeviceFarmArn(providerSessionId ?? ''), + }); + return await listAwsDeviceFarmCloudArtifacts(provider, providerSessionId, client); + }, + prepareSession: async ({ req, lease, base }) => { + const request = requireRequest(req, 'AWS Device Farm'); + const platform = requireRequestPlatform(request, 'AWS Device Farm'); + const sessionOptions = { + client: createAwsCliDeviceFarmClient({ + region: readFlag(request, 'awsRegion') ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION, + }), + projectArn: requireAwsValue( + request, + env, + 'awsProjectArn', + 'AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN', + 'AWS_DEVICE_FARM_PROJECT_ARN', + ), + deviceArn: requireAwsValue( + request, + env, + 'awsDeviceArn', + 'AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN', + 'AWS_DEVICE_FARM_DEVICE_ARN', + ), + appArn: + readFlag(request, 'awsAppArn') ?? + env.AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN ?? + env.AWS_DEVICE_FARM_APP_ARN, + platform, + deviceName: readFlag(request, 'device') ?? 'AWS Device Farm device', + sessionName: readFlag(request, 'providerSessionName') ?? lease.leaseId, + interactionMode: readAwsInteractionMode(request), + }; + return await createAwsDeviceFarmPrepareSession(sessionOptions)({ lease, req, base }); + }, + }), listArtifactsFromEnv: async (providerSessionId, env) => { const client = createAwsCliDeviceFarmClient({ region: @@ -182,6 +234,37 @@ function isProviderAppReference(value: string): boolean { return value.startsWith('bs://') || /^https?:\/\//.test(value); } +function requireRequest(req: DaemonRequest | undefined, providerLabel: string): DaemonRequest { + if (req) return req; + throw new AppError( + 'INVALID_ARGS', + `${providerLabel} lease allocation requires provider profile flags on the request.`, + ); +} + +function browserStackCapabilities(options: { + platform: CloudWebDriverPlatform; + deviceName: string; + osVersion: string; + app: string; + projectName?: string; + buildName: string; + sessionName: string; +}): Record { + return { + platformName: options.platform === 'ios' ? 'iOS' : 'Android', + 'appium:deviceName': options.deviceName, + device: options.deviceName, + os_version: options.osVersion, + app: options.app, + 'bstack:options': { + ...(options.projectName ? { projectName: options.projectName } : {}), + buildName: options.buildName, + sessionName: options.sessionName, + }, + }; +} + function requireRequestPlatform(req: DaemonRequest, providerLabel: string): CloudWebDriverPlatform { const platform = req.flags?.platform; if (platform === 'android' || platform === 'ios') return platform; diff --git a/src/cloud-webdriver/provider-runtimes.ts b/src/cloud-webdriver/provider-runtimes.ts index c8a1fe8c3..7b79f085e 100644 --- a/src/cloud-webdriver/provider-runtimes.ts +++ b/src/cloud-webdriver/provider-runtimes.ts @@ -1,24 +1,6 @@ -import type { - CloudArtifactProvider, - CloudArtifactsQuery, - CloudArtifactsResult, -} from '../cloud-artifacts.ts'; -import type { DeviceInventoryProvider } from '../core/dispatch-resolve.ts'; -import type { Interactor } from '../core/interactor-types.ts'; -import type { LeaseLifecycleContext, LeaseLifecycleProvider } from '../daemon/handlers/lease.ts'; -import type { DeviceLease } from '../daemon/lease-registry.ts'; -import type { DaemonRequest } from '../daemon/types.ts'; -import type { DeviceInfo } from '../kernel/device.ts'; -import { AppError } from '../kernel/errors.ts'; -import { - type ProviderDeviceInstallOptions, - type ProviderDeviceInstallResult, - type ProviderDeviceRuntime, - type ProviderPortReverseOptions, -} from '../provider-device-runtime.ts'; +import type { ProviderDeviceRuntime } from '../provider-device-runtime.ts'; import { CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS, - type CloudWebDriverProviderDefinition, type DefaultCloudWebDriverProviderRuntimeEnv, } from './provider-definitions.ts'; @@ -27,159 +9,5 @@ export type { DefaultCloudWebDriverProviderRuntimeEnv } from './provider-definit export function createDefaultCloudWebDriverProviderRuntimes( env: DefaultCloudWebDriverProviderRuntimeEnv = process.env, ): ProviderDeviceRuntime[] { - return CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS.map( - (definition) => new LazyCloudWebDriverProviderRuntime(definition, env), - ); -} - -class LazyCloudWebDriverProviderRuntime implements ProviderDeviceRuntime { - readonly leaseLifecycle: LeaseLifecycleProvider; - readonly cloudArtifacts: CloudArtifactProvider; - readonly deviceInventoryProvider: DeviceInventoryProvider; - readonly provider: CloudWebDriverProviderDefinition['provider']; - - private readonly definition: CloudWebDriverProviderDefinition; - private readonly env: DefaultCloudWebDriverProviderRuntimeEnv; - private readonly runtimesByLeaseId = new Map(); - - constructor( - definition: CloudWebDriverProviderDefinition, - env: DefaultCloudWebDriverProviderRuntimeEnv, - ) { - this.definition = definition; - this.provider = definition.provider; - this.env = env; - this.leaseLifecycle = { - allocate: async (lease, context) => await this.allocate(lease, context), - heartbeat: async (lease, context) => await this.heartbeat(lease, context), - release: async (lease, context) => await this.release(lease, context), - }; - this.cloudArtifacts = { - listCloudArtifacts: async (query) => await this.listCloudArtifacts(query), - }; - this.deviceInventoryProvider = async (request) => { - if (request.leaseProvider !== this.provider) return null; - if (!request.leaseId) return []; - return ( - (await this.runtimesByLeaseId.get(request.leaseId)?.deviceInventoryProvider(request)) ?? [] - ); - }; - } - - ownsDevice(device: DeviceInfo): boolean { - return this.findRuntimeForDevice(device) !== undefined; - } - - getInteractor(device: DeviceInfo): Interactor | undefined { - return this.findRuntimeForDevice(device)?.getInteractor(device); - } - - async installApp( - device: DeviceInfo, - app: string, - appPath: string, - options?: ProviderDeviceInstallOptions, - ): Promise { - return await this.findRuntimeForDevice(device)?.installApp?.(device, app, appPath, options); - } - - async installInstallablePath( - device: DeviceInfo, - installablePath: string, - options?: ProviderDeviceInstallOptions, - ): Promise { - return await this.findRuntimeForDevice(device)?.installInstallablePath?.( - device, - installablePath, - options, - ); - } - - async configurePortReverse( - options: ProviderPortReverseOptions, - ): Promise | undefined> { - return await this.runtimesByLeaseId.get(options.leaseId)?.configurePortReverse?.(options); - } - - async removePortReverse( - options: ProviderPortReverseOptions, - ): Promise | undefined> { - return await this.runtimesByLeaseId.get(options.leaseId)?.removePortReverse?.(options); - } - - async shutdown(): Promise { - const runtimes = [...this.runtimesByLeaseId.values()]; - this.runtimesByLeaseId.clear(); - await Promise.allSettled(runtimes.map(async (runtime) => await runtime.shutdown())); - } - - private async allocate( - lease: DeviceLease, - context: LeaseLifecycleContext | undefined, - ): Promise | undefined> { - if (lease.leaseProvider !== this.provider) return undefined; - const existing = this.runtimesByLeaseId.get(lease.leaseId); - if (existing) return await existing.leaseLifecycle.heartbeat?.(lease, context); - const runtime = await this.createRuntime(context?.req, lease); - this.runtimesByLeaseId.set(lease.leaseId, runtime); - try { - return await runtime.leaseLifecycle.allocate?.(lease, context); - } catch (error) { - this.runtimesByLeaseId.delete(lease.leaseId); - await runtime.shutdown(); - throw error; - } - } - - private async heartbeat( - lease: DeviceLease, - context: LeaseLifecycleContext | undefined, - ): Promise | undefined> { - if (lease.leaseProvider !== this.provider) return undefined; - return await this.runtimesByLeaseId - .get(lease.leaseId) - ?.leaseLifecycle.heartbeat?.(lease, context); - } - - private async release( - lease: DeviceLease, - context: LeaseLifecycleContext | undefined, - ): Promise | undefined> { - if (lease.leaseProvider !== this.provider) return undefined; - const runtime = this.runtimesByLeaseId.get(lease.leaseId); - if (!runtime) return undefined; - this.runtimesByLeaseId.delete(lease.leaseId); - try { - return await runtime.leaseLifecycle.release?.(lease, context); - } finally { - await runtime.shutdown(); - } - } - - private async listCloudArtifacts( - query: CloudArtifactsQuery, - ): Promise { - if (query.provider !== this.provider) return undefined; - if (!query.leaseId) return undefined; - return await this.runtimesByLeaseId - .get(query.leaseId) - ?.cloudArtifacts?.listCloudArtifacts?.(query); - } - - private findRuntimeForDevice(device: DeviceInfo): ProviderDeviceRuntime | undefined { - return [...this.runtimesByLeaseId.values()].find((runtime) => runtime.ownsDevice(device)); - } - - private async createRuntime( - req: DaemonRequest | undefined, - lease: DeviceLease, - ): Promise { - if (!req) { - throw new AppError( - 'INVALID_ARGS', - `${this.provider} lease allocation requires provider profile flags on the request.`, - ); - } - return await this.definition.createRuntime({ req, lease, env: this.env }); - } + return CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS.map((definition) => definition.createRuntime(env)); } diff --git a/src/cloud-webdriver/runtime.ts b/src/cloud-webdriver/runtime.ts index af3b9129e..d13c0c5a8 100644 --- a/src/cloud-webdriver/runtime.ts +++ b/src/cloud-webdriver/runtime.ts @@ -7,6 +7,7 @@ import type { DeviceInventoryProvider } from '../core/dispatch-resolve.ts'; import type { Interactor } from '../core/interactor-types.ts'; import type { LeaseLifecycleProvider } from '../daemon/handlers/lease.ts'; import type { DeviceLease } from '../daemon/lease-registry.ts'; +import type { DaemonRequest } from '../daemon/types.ts'; import type { ProviderDeviceInstallOptions, ProviderDeviceInstallResult, @@ -73,6 +74,7 @@ export type CloudWebDriverListArtifacts = (params: { export type CloudWebDriverPrepareSession = (params: { lease: DeviceLease; + req?: DaemonRequest; base: CloudWebDriverBaseSession; }) => Promise; @@ -100,6 +102,7 @@ type WebDriverProviderSession = { client: WebDriverClient; interactor: Interactor; prepared: CloudWebDriverPreparedSession; + capabilities: CloudWebDriverProviderCapabilities; webDriverSessionId: string; providerSessionId: string; }; @@ -129,7 +132,7 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { overrides: options.capabilityOverrides, }); this.leaseLifecycle = { - allocate: async (lease) => await this.allocate(lease), + allocate: async (lease, context) => await this.allocate(lease, context?.req), heartbeat: async (lease) => this.heartbeat(lease), release: async (lease) => await this.release(lease), }; @@ -181,9 +184,13 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { this.sessionsByLeaseId.clear(); } - private async allocate(lease: DeviceLease): Promise | undefined> { + private async allocate( + lease: DeviceLease, + req?: DaemonRequest, + ): Promise | undefined> { if (lease.leaseProvider !== this.provider) return undefined; - const prepared = await this.prepareSession(lease); + if (this.sessionsByLeaseId.has(lease.leaseId)) return this.heartbeat(lease); + const prepared = await this.prepareSession(lease, req); const client = new WebDriverClient({ endpoint: prepared.endpoint, auth: prepared.auth, @@ -193,17 +200,19 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { const session = await this.createSessionWithPreparedCleanup(client, prepared); const device = this.deviceForLease(lease, prepared); const providerSessionId = prepared.providerSessionId ?? session.sessionId; + const capabilities = this.capabilitiesForPlatform(prepared.platform); this.sessionsByLeaseId.set(lease.leaseId, { lease, device, client, prepared, + capabilities, webDriverSessionId: session.sessionId, providerSessionId, interactor: createWebDriverInteractor({ client, backend: snapshotBackendForPlatform(prepared.platform), - capabilities: this.capabilities, + capabilities, }), }); return { @@ -211,7 +220,7 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { deviceId: device.id, sessionId: session.sessionId, providerSessionId, - capabilities: this.capabilities, + capabilities, ...prepared.providerData, }; } @@ -262,9 +271,24 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { }); } - private async prepareSession(lease: DeviceLease): Promise { + private async prepareSession( + lease: DeviceLease, + req: DaemonRequest | undefined, + ): Promise { const base = this.baseSessionForLease(lease); - return this.options.prepareSession ? await this.options.prepareSession({ lease, base }) : base; + return this.options.prepareSession + ? await this.options.prepareSession({ lease, req, base }) + : base; + } + + private capabilitiesForPlatform( + platform: CloudWebDriverPlatform, + ): CloudWebDriverProviderCapabilities { + return createCloudWebDriverCapabilities({ + provider: this.provider, + platform, + overrides: this.options.capabilityOverrides, + }); } private baseSessionForLease(lease: DeviceLease): CloudWebDriverBaseSession { @@ -312,10 +336,10 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { appPath: string, options?: ProviderDeviceInstallOptions, ): Promise { - if (!capabilitySupported(this.capabilities, 'install')) { + if (!capabilitySupported(session.capabilities, 'install')) { throw new AppError( 'UNSUPPORTED_OPERATION', - unsupportedCapabilityMessage(this.capabilities, 'install'), + unsupportedCapabilityMessage(session.capabilities, 'install'), { provider: this.provider, deviceId: device.id, platform: device.platform }, ); } diff --git a/src/daemon-runtime.ts b/src/daemon-runtime.ts index fe1243fed..4a9f2a088 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -7,8 +7,10 @@ import { createDaemonHttpServer } from './daemon/http-server.ts'; import { trackDownloadableArtifact } from './daemon/artifact-tracking.ts'; import { createDefaultCloudArtifactProvider } from './default-cloud-artifact-provider.ts'; import { createDefaultCloudWebDriverProviderRuntimes } from './cloud-webdriver/provider-runtimes.ts'; -import { createProviderDeviceRuntimeRequestProviders } from './provider-device-runtime.ts'; -import type { CloudArtifactProvider } from './cloud-artifacts.ts'; +import { + composeCloudArtifactProviders, + createProviderDeviceRuntimeRequestProviders, +} from './provider-device-runtime.ts'; import { LeaseRegistry } from './daemon/lease-registry.ts'; import { createRequestHandler } from './daemon/request-router.ts'; import { teardownSessionResources } from './daemon/session-teardown.ts'; @@ -284,21 +286,3 @@ export async function startDaemonRuntime( token, }; } - -function composeCloudArtifactProviders( - ...providers: Array -): CloudArtifactProvider | undefined { - const activeProviders = providers.filter( - (provider): provider is CloudArtifactProvider => provider !== undefined, - ); - if (activeProviders.length === 0) return undefined; - return { - listCloudArtifacts: async (query) => { - for (const provider of activeProviders) { - const result = await provider.listCloudArtifacts?.(query); - if (result) return result; - } - return undefined; - }, - }; -} diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 94df038ae..466f0fa1c 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -166,6 +166,24 @@ export function createProviderDeviceRuntimeRequestProviders( }; } +export function composeCloudArtifactProviders( + ...providers: Array +): CloudArtifactProvider | undefined { + const activeProviders = providers.filter( + (provider): provider is CloudArtifactProvider => provider !== undefined, + ); + if (activeProviders.length === 0) return undefined; + return { + listCloudArtifacts: async (query) => { + for (const provider of activeProviders) { + const result = await provider.listCloudArtifacts?.(query); + if (result) return result; + } + return undefined; + }, + }; +} + function composeLeaseProvider( runtimes: ProviderDeviceRuntime[], ): LeaseLifecycleProvider | undefined { From 0ef634d5a63c35f2593647490f042656b144eb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 13:44:40 +0200 Subject: [PATCH 09/17] refactor: reduce cloud webdriver smell surface --- .../default-cloud-artifact-provider.test.ts | 10 +++ src/cli/connection-profile-fields.ts | 19 ++++++ src/cli/provider-connection-profile.ts | 68 +++++++++++-------- src/cli/proxy-connection-profile.ts | 14 +--- src/cloud-webdriver.ts | 54 --------------- src/cloud-webdriver/aws-device-farm.ts | 61 ++++++----------- src/cloud-webdriver/browserstack.ts | 45 +++++++++--- src/cloud-webdriver/provider-definitions.ts | 34 +++------- src/cloud-webdriver/runtime.ts | 22 ++++-- .../cloud-webdriver-provider-adapters.test.ts | 12 ++-- .../cloud-webdriver-runtime.test.ts | 2 +- 11 files changed, 158 insertions(+), 183 deletions(-) create mode 100644 src/cli/connection-profile-fields.ts delete mode 100644 src/cloud-webdriver.ts diff --git a/src/__tests__/default-cloud-artifact-provider.test.ts b/src/__tests__/default-cloud-artifact-provider.test.ts index 802c84188..b95ef3236 100644 --- a/src/__tests__/default-cloud-artifact-provider.test.ts +++ b/src/__tests__/default-cloud-artifact-provider.test.ts @@ -72,6 +72,16 @@ test('default cloud artifact provider maps AWS Device Farm historical sessions v assert.equal(calls[1]?.includes('LOG'), true); }); +test('default cloud artifact provider ignores lookups without a provider session id', async () => { + const provider = createDefaultCloudArtifactProvider({}); + + const result = await provider.listCloudArtifacts?.({ + provider: 'browserstack', + }); + + assert.equal(result, undefined); +}); + test('default cloud artifact provider does not treat broad aws as a provider name', async () => { const provider = createDefaultCloudArtifactProvider({}); diff --git a/src/cli/connection-profile-fields.ts b/src/cli/connection-profile-fields.ts new file mode 100644 index 000000000..e1d974d3a --- /dev/null +++ b/src/cli/connection-profile-fields.ts @@ -0,0 +1,19 @@ +import type { RemoteConfigMetroOptions } from '../remote-config-schema.ts'; +import type { CliFlags } from '../utils/cli-flags.ts'; + +export function readMetroProfileFields(flags: CliFlags): RemoteConfigMetroOptions { + return { + metroProjectRoot: flags.metroProjectRoot, + metroKind: flags.metroKind, + metroPublicBaseUrl: flags.metroPublicBaseUrl, + metroProxyBaseUrl: flags.metroProxyBaseUrl, + metroPreparePort: flags.metroPreparePort, + metroListenHost: flags.metroListenHost, + metroStatusHost: flags.metroStatusHost, + metroStartupTimeoutMs: flags.metroStartupTimeoutMs, + metroProbeTimeoutMs: flags.metroProbeTimeoutMs, + metroRuntimeFile: flags.metroRuntimeFile, + metroNoReuseExisting: flags.metroNoReuseExisting, + metroNoInstallDeps: flags.metroNoInstallDeps, + }; +} diff --git a/src/cli/provider-connection-profile.ts b/src/cli/provider-connection-profile.ts index 8f3ecdd76..63177aa99 100644 --- a/src/cli/provider-connection-profile.ts +++ b/src/cli/provider-connection-profile.ts @@ -6,6 +6,7 @@ import { AppError } from '../kernel/errors.ts'; import type { PlatformSelector } from '../kernel/device.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; import type { EnvMap } from '../utils/env-map.ts'; +import { readMetroProfileFields } from './connection-profile-fields.ts'; import { persistAndResolveGeneratedProfile } from './generated-remote-config.ts'; import { resolveRequestedLeaseBackend } from './commands/connection-runtime.ts'; @@ -33,18 +34,7 @@ export function resolveCloudWebDriverConnectProfile(options: { target: options.flags.target ?? 'mobile', session: options.flags.session, ...providerConfig, - metroProjectRoot: options.flags.metroProjectRoot, - metroKind: options.flags.metroKind, - metroPublicBaseUrl: options.flags.metroPublicBaseUrl, - metroProxyBaseUrl: options.flags.metroProxyBaseUrl, - metroPreparePort: options.flags.metroPreparePort, - metroListenHost: options.flags.metroListenHost, - metroStatusHost: options.flags.metroStatusHost, - metroStartupTimeoutMs: options.flags.metroStartupTimeoutMs, - metroProbeTimeoutMs: options.flags.metroProbeTimeoutMs, - metroRuntimeFile: options.flags.metroRuntimeFile, - metroNoReuseExisting: options.flags.metroNoReuseExisting, - metroNoInstallDeps: options.flags.metroNoInstallDeps, + ...readMetroProfileFields(options.flags), }; return persistAndResolveGeneratedProfile({ stateDir: options.stateDir, @@ -119,33 +109,33 @@ function awsDeviceFarmProfileFields(options: { flags: CliFlags; env?: EnvMap; }): RemoteConfigProfile { + const { env, flags } = options; const platform = requireCloudWebDriverPlatform( - options.flags.platform, + flags.platform, 'connect aws-device-farm requires --platform ios|android.', ); return { platform, - device: options.flags.device, - awsProjectArn: requireFlag( - options.flags.awsProjectArn ?? - options.env?.AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN ?? - options.env?.AWS_DEVICE_FARM_PROJECT_ARN, + device: flags.device, + awsProjectArn: requireAwsProfileValue( + flags.awsProjectArn, + env, + ['AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN', 'AWS_DEVICE_FARM_PROJECT_ARN'], 'connect aws-device-farm requires --aws-project-arn or AWS_DEVICE_FARM_PROJECT_ARN.', ), - awsDeviceArn: requireFlag( - options.flags.awsDeviceArn ?? - options.env?.AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN ?? - options.env?.AWS_DEVICE_FARM_DEVICE_ARN, + awsDeviceArn: requireAwsProfileValue( + flags.awsDeviceArn, + env, + ['AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN', 'AWS_DEVICE_FARM_DEVICE_ARN'], 'connect aws-device-farm requires --aws-device-arn or AWS_DEVICE_FARM_DEVICE_ARN.', ), - awsAppArn: - options.flags.awsAppArn ?? - options.env?.AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN ?? - options.env?.AWS_DEVICE_FARM_APP_ARN, - awsRegion: - options.flags.awsRegion ?? options.env?.AWS_REGION ?? options.env?.AWS_DEFAULT_REGION, - awsInteractionMode: options.flags.awsInteractionMode, - providerSessionName: options.flags.providerSessionName, + awsAppArn: readAwsProfileValue(flags.awsAppArn, env, [ + 'AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN', + 'AWS_DEVICE_FARM_APP_ARN', + ]), + awsRegion: readAwsProfileValue(flags.awsRegion, env, ['AWS_REGION', 'AWS_DEFAULT_REGION']), + awsInteractionMode: flags.awsInteractionMode, + providerSessionName: flags.providerSessionName, }; } @@ -168,6 +158,24 @@ function requireEnv(env: EnvMap | undefined, name: string, command: string): str throw new AppError('INVALID_ARGS', `${command} requires ${name} in the environment.`); } +function requireAwsProfileValue( + flagValue: string | undefined, + env: EnvMap | undefined, + envNames: readonly string[], + message: string, +): string { + return requireFlag(readAwsProfileValue(flagValue, env, envNames), message); +} + +function readAwsProfileValue( + flagValue: string | undefined, + env: EnvMap | undefined, + envNames: readonly string[], +): string | undefined { + if (flagValue) return flagValue; + return envNames.map((name) => env?.[name]).find((value): value is string => Boolean(value)); +} + function buildCloudWebDriverClientId( provider: CloudWebDriverKnownProviderName, stateDir: string, diff --git a/src/cli/proxy-connection-profile.ts b/src/cli/proxy-connection-profile.ts index 95b64872c..681601e71 100644 --- a/src/cli/proxy-connection-profile.ts +++ b/src/cli/proxy-connection-profile.ts @@ -3,6 +3,7 @@ import type { RemoteConfigProfile } from '../remote/remote-config-schema.ts'; import { AppError } from '../kernel/errors.ts'; import type { CliFlags } from './parser/cli-flags.ts'; import type { EnvMap } from '../utils/env-map.ts'; +import { readMetroProfileFields } from './connection-profile-fields.ts'; import { persistAndResolveGeneratedProfile } from './generated-remote-config.ts'; import { resolveRequestedLeaseBackend } from './commands/connection-runtime.ts'; @@ -38,22 +39,11 @@ export function resolveProxyConnectProfile(options: { iosSimulatorDeviceSet: options.flags.iosSimulatorDeviceSet, androidDeviceAllowlist: options.flags.androidDeviceAllowlist, session: options.flags.session, - metroProjectRoot: options.flags.metroProjectRoot, - metroKind: options.flags.metroKind, - metroPublicBaseUrl: options.flags.metroPublicBaseUrl, - metroProxyBaseUrl: options.flags.metroProxyBaseUrl, // Secrets must never be persisted in the generated (non-secret) profile. // Mirror the cloud path, which keeps daemonAuthToken in-memory only: the // bearer token survives this connect via the returned flags below, and // later commands re-supply it through AGENT_DEVICE_METRO_BEARER_TOKEN. - metroPreparePort: options.flags.metroPreparePort, - metroListenHost: options.flags.metroListenHost, - metroStatusHost: options.flags.metroStatusHost, - metroStartupTimeoutMs: options.flags.metroStartupTimeoutMs, - metroProbeTimeoutMs: options.flags.metroProbeTimeoutMs, - metroRuntimeFile: options.flags.metroRuntimeFile, - metroNoReuseExisting: options.flags.metroNoReuseExisting, - metroNoInstallDeps: options.flags.metroNoInstallDeps, + ...readMetroProfileFields(options.flags), }; return persistAndResolveGeneratedProfile({ stateDir: options.stateDir, diff --git a/src/cloud-webdriver.ts b/src/cloud-webdriver.ts deleted file mode 100644 index fda1992ac..000000000 --- a/src/cloud-webdriver.ts +++ /dev/null @@ -1,54 +0,0 @@ -export { - CLOUD_WEBDRIVER_PROVIDERS, - type CloudWebDriverKnownProviderName, -} from './cloud-webdriver/providers.ts'; -export { - createCloudWebDriverRuntime, - type CloudWebDriverPlatform, - type CloudWebDriverBaseSession, - type CloudWebDriverPreparedSession, - type CloudWebDriverListArtifacts, - type CloudWebDriverPrepareSession, - type CloudWebDriverRuntimeOptions, - type CloudWebDriverUploadApp, - type CloudWebDriverUploadResult, -} from './cloud-webdriver/runtime.ts'; -export { - createBrowserStackWebDriverRuntime, - getBrowserStackWebDriverCapabilities, - listBrowserStackCloudArtifacts, - uploadBrowserStackApp, - type BrowserStackSessionDetailsOptions, - type BrowserStackUploadOptions, - type BrowserStackWebDriverRuntimeOptions, -} from './cloud-webdriver/browserstack.ts'; -export { - createAwsCliDeviceFarmClient, - createAwsDeviceFarmWebDriverRuntime, - getAwsDeviceFarmWebDriverCapabilities, - listAwsDeviceFarmCloudArtifacts, - selectAwsDeviceFarmWebDriverEndpoint, - type AwsDeviceFarmArtifact, - type AwsDeviceFarmArtifactGroup, - type AwsCliDeviceFarmClientOptions, - type AwsCreateRemoteAccessSessionInput, - type AwsDeviceFarmClient, - type AwsDeviceFarmRemoteAccessSession, - type AwsDeviceFarmWebDriverRuntimeOptions, -} from './cloud-webdriver/aws-device-farm.ts'; -export type { - CloudWebDriverCapabilityMap, - CloudWebDriverCapabilityOverrides, - CloudWebDriverOperation, - CloudWebDriverOperationCapability, - CloudWebDriverProviderCapabilities, - CloudWebDriverSupportLevel, -} from './cloud-webdriver/capabilities.ts'; -export type { WebDriverAuth, WebDriverRequestPolicy } from './cloud-webdriver/webdriver-client.ts'; -export type { - CloudArtifact, - CloudArtifactAvailability, - CloudArtifactKind, - CloudArtifactsResult, - CloudArtifactsStatus, -} from './cloud-artifacts.ts'; diff --git a/src/cloud-webdriver/aws-device-farm.ts b/src/cloud-webdriver/aws-device-farm.ts index 7dbd2c037..10b75cc95 100644 --- a/src/cloud-webdriver/aws-device-farm.ts +++ b/src/cloud-webdriver/aws-device-farm.ts @@ -144,14 +144,10 @@ export type AwsCliDeviceFarmClientOptions = { export function createAwsCliDeviceFarmClient( options: AwsCliDeviceFarmClientOptions = {}, ): AwsDeviceFarmClient { - const regionArgs = options.region ? ['--region', options.region] : []; - const awsCommand = options.awsCommand ?? 'aws'; + const runDeviceFarmJson = createAwsDeviceFarmCommandRunner(options); return { createRemoteAccessSession: async (input) => { - const args = [ - 'devicefarm', - 'create-remote-access-session', - ...regionArgs, + const json = await runDeviceFarmJson('create-remote-access-session', [ '--project-arn', input.projectArn, '--device-arn', @@ -161,48 +157,19 @@ export function createAwsCliDeviceFarmClient( ...(input.appArn ? ['--app-arn', input.appArn] : []), ...(input.interactionMode ? ['--interaction-mode', input.interactionMode] : []), ...(input.configuration ? ['--configuration', JSON.stringify(input.configuration)] : []), - '--output', - 'json', - ]; - const json = await runAwsJson(awsCommand, args); + ]); return readRemoteAccessSession(json); }, getRemoteAccessSession: async (arn) => { - const json = await runAwsJson(awsCommand, [ - 'devicefarm', - 'get-remote-access-session', - ...regionArgs, - '--arn', - arn, - '--output', - 'json', - ]); + const json = await runDeviceFarmJson('get-remote-access-session', ['--arn', arn]); return readRemoteAccessSession(json); }, stopRemoteAccessSession: async (arn) => { - const json = await runAwsJson(awsCommand, [ - 'devicefarm', - 'stop-remote-access-session', - ...regionArgs, - '--arn', - arn, - '--output', - 'json', - ]); + const json = await runDeviceFarmJson('stop-remote-access-session', ['--arn', arn]); return readRemoteAccessSession(json); }, listArtifacts: async (arn, type) => { - const json = await runAwsJson(awsCommand, [ - 'devicefarm', - 'list-artifacts', - ...regionArgs, - '--arn', - arn, - '--type', - type, - '--output', - 'json', - ]); + const json = await runDeviceFarmJson('list-artifacts', ['--arn', arn, '--type', type]); return readAwsArtifacts(json); }, }; @@ -312,6 +279,22 @@ async function runAwsJson(command: string, args: string[]): Promise { return JSON.parse(result.stdout) as unknown; } +function createAwsDeviceFarmCommandRunner( + options: AwsCliDeviceFarmClientOptions, +): (subcommand: string, args: string[]) => Promise { + const regionArgs = options.region ? ['--region', options.region] : []; + const awsCommand = options.awsCommand ?? 'aws'; + return async (subcommand, args) => + await runAwsJson(awsCommand, [ + 'devicefarm', + subcommand, + ...regionArgs, + ...args, + '--output', + 'json', + ]); +} + function readRemoteAccessSession(value: unknown): AwsDeviceFarmRemoteAccessSession { if (!value || typeof value !== 'object') { throw new AppError('COMMAND_FAILED', 'AWS Device Farm response was not an object.', { diff --git a/src/cloud-webdriver/browserstack.ts b/src/cloud-webdriver/browserstack.ts index 4b14b16e5..57f6b4384 100644 --- a/src/cloud-webdriver/browserstack.ts +++ b/src/cloud-webdriver/browserstack.ts @@ -66,6 +66,16 @@ export type BrowserStackWebDriverRuntimeOptions = { prepareSession?: CloudWebDriverRuntimeOptions['prepareSession']; }; +export type BrowserStackCapabilitiesOptions = { + deviceName: string; + osVersion: string; + app?: string; + projectName?: string; + buildName: string; + sessionName: string; + configured?: Record; +}; + export function getBrowserStackWebDriverCapabilities( platform: CloudWebDriverPlatform, ): CloudWebDriverProviderCapabilities { @@ -94,7 +104,16 @@ export function createBrowserStackWebDriverRuntime( username: options.username, accessKey: options.accessKey, }, - webdriverCapabilities: (lease) => browserStackCapabilities(options, lease), + webdriverCapabilities: (lease) => + buildBrowserStackCapabilities({ + deviceName: options.deviceName, + osVersion: options.osVersion, + app: options.app, + projectName: options.projectName, + buildName: resolveLeaseValue(options.buildName, lease) ?? lease.runId, + sessionName: resolveLeaseValue(options.sessionName, lease) ?? lease.leaseId, + configured: resolveConfiguredBrowserStackCapabilities(options, lease), + }), uploadApp: createBrowserStackUploadApp({ username: options.username, accessKey: options.accessKey, @@ -178,27 +197,31 @@ export function createBrowserStackUploadApp( }; } -function browserStackCapabilities( - options: BrowserStackWebDriverRuntimeOptions, - lease: DeviceLease, +export function buildBrowserStackCapabilities( + options: BrowserStackCapabilitiesOptions, ): Record { - const configured = - typeof options.webdriverCapabilities === 'function' - ? options.webdriverCapabilities(lease) - : (options.webdriverCapabilities ?? {}); return { device: options.deviceName, os_version: options.osVersion, ...(options.app ? { app: options.app } : {}), 'bstack:options': { ...(options.projectName ? { projectName: options.projectName } : {}), - buildName: resolveLeaseValue(options.buildName, lease) ?? lease.runId, - sessionName: resolveLeaseValue(options.sessionName, lease) ?? lease.leaseId, + buildName: options.buildName, + sessionName: options.sessionName, }, - ...configured, + ...(options.configured ?? {}), }; } +function resolveConfiguredBrowserStackCapabilities( + options: BrowserStackWebDriverRuntimeOptions, + lease: DeviceLease, +): Record { + return typeof options.webdriverCapabilities === 'function' + ? options.webdriverCapabilities(lease) + : (options.webdriverCapabilities ?? {}); +} + async function fetchBrowserStackSessionDetails( sessionId: string, options: BrowserStackSessionDetailsOptions, diff --git a/src/cloud-webdriver/provider-definitions.ts b/src/cloud-webdriver/provider-definitions.ts index 696d76be2..4884f0160 100644 --- a/src/cloud-webdriver/provider-definitions.ts +++ b/src/cloud-webdriver/provider-definitions.ts @@ -14,12 +14,17 @@ import { BROWSERSTACK_APP_AUTOMATE_ENDPOINT, BROWSERSTACK_APP_UPLOAD_ENDPOINT, BROWSERSTACK_CAPABILITY_OVERRIDES, + buildBrowserStackCapabilities, createBrowserStackUploadApp, listBrowserStackCloudArtifacts, uploadBrowserStackApp, } from './browserstack.ts'; import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts'; -import { createCloudWebDriverRuntime, type CloudWebDriverPlatform } from './runtime.ts'; +import { + buildCloudWebDriverBaseCapabilities, + createCloudWebDriverRuntime, + type CloudWebDriverPlatform, +} from './runtime.ts'; export type DefaultCloudWebDriverArtifactEnv = { BROWSERSTACK_USERNAME?: string; @@ -108,14 +113,14 @@ export const CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS: readonly CloudWebDriverProvid accessKey, endpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT, }), - webdriverCapabilities: browserStackCapabilities({ - platform, + webdriverCapabilities: buildBrowserStackCapabilities({ deviceName, osVersion, app, projectName: readFlag(request, 'providerProject'), buildName: readFlag(request, 'providerBuild') ?? lease.runId, sessionName: readFlag(request, 'providerSessionName') ?? lease.leaseId, + configured: buildCloudWebDriverBaseCapabilities(platform, deviceName), }), }; }, @@ -242,29 +247,6 @@ function requireRequest(req: DaemonRequest | undefined, providerLabel: string): ); } -function browserStackCapabilities(options: { - platform: CloudWebDriverPlatform; - deviceName: string; - osVersion: string; - app: string; - projectName?: string; - buildName: string; - sessionName: string; -}): Record { - return { - platformName: options.platform === 'ios' ? 'iOS' : 'Android', - 'appium:deviceName': options.deviceName, - device: options.deviceName, - os_version: options.osVersion, - app: options.app, - 'bstack:options': { - ...(options.projectName ? { projectName: options.projectName } : {}), - buildName: options.buildName, - sessionName: options.sessionName, - }, - }; -} - function requireRequestPlatform(req: DaemonRequest, providerLabel: string): CloudWebDriverPlatform { const platform = req.flags?.platform; if (platform === 'android' || platform === 'ios') return platform; diff --git a/src/cloud-webdriver/runtime.ts b/src/cloud-webdriver/runtime.ts index d13c0c5a8..8ee28f8ec 100644 --- a/src/cloud-webdriver/runtime.ts +++ b/src/cloud-webdriver/runtime.ts @@ -113,6 +113,18 @@ export function createCloudWebDriverRuntime( return new CloudWebDriverRuntime(options); } +export function buildCloudWebDriverBaseCapabilities( + platform: CloudWebDriverPlatform, + deviceName: string, + configured: Record = {}, +): Record { + return { + platformName: platform === 'ios' ? 'iOS' : 'Android', + 'appium:deviceName': deviceName, + ...configured, + }; +} + class CloudWebDriverRuntime implements ProviderDeviceRuntime { readonly provider: string; readonly leaseLifecycle: LeaseLifecycleProvider; @@ -303,11 +315,11 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { deviceName: this.options.deviceName, auth: this.options.auth, headers: this.options.headers, - webdriverCapabilities: { - platformName: this.options.platform === 'ios' ? 'iOS' : 'Android', - 'appium:deviceName': this.options.deviceName, - ...configured, - }, + webdriverCapabilities: buildCloudWebDriverBaseCapabilities( + this.options.platform, + this.options.deviceName, + configured, + ), }; } diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts index 793389b1e..acd9908df 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -10,15 +10,17 @@ import { test } from 'vitest'; import { createAwsCliDeviceFarmClient, createAwsDeviceFarmWebDriverRuntime, - createBrowserStackWebDriverRuntime, getAwsDeviceFarmWebDriverCapabilities, - getBrowserStackWebDriverCapabilities, listAwsDeviceFarmCloudArtifacts, - listBrowserStackCloudArtifacts, selectAwsDeviceFarmWebDriverEndpoint, - uploadBrowserStackApp, type AwsDeviceFarmClient, -} from '../../../src/cloud-webdriver.ts'; +} from '../../../src/cloud-webdriver/aws-device-farm.ts'; +import { + createBrowserStackWebDriverRuntime, + getBrowserStackWebDriverCapabilities, + listBrowserStackCloudArtifacts, + uploadBrowserStackApp, +} from '../../../src/cloud-webdriver/browserstack.ts'; import type { DeviceLease } from '../../../src/daemon/lease-registry.ts'; import { withCommandExecutorOverride } from '../../../src/utils/exec.ts'; import { withProviderScenarioResource, withProviderScenarioTempDir } from './harness.ts'; diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 61d6020ae..4864b2e73 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -7,7 +7,7 @@ import http, { } from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; -import { createCloudWebDriverRuntime } from '../../../src/cloud-webdriver.ts'; +import { createCloudWebDriverRuntime } from '../../../src/cloud-webdriver/runtime.ts'; import { createDefaultCloudWebDriverProviderRuntimes } from '../../../src/cloud-webdriver/provider-runtimes.ts'; import { parseWebDriverSource } from '../../../src/cloud-webdriver/webdriver-source.ts'; import { CLOUD_WEBDRIVER_PROVIDERS } from '../../../src/cloud-webdriver/providers.ts'; From 4e13cb5311d46b0087464d7fd78faa149549ebe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 14:10:49 +0200 Subject: [PATCH 10/17] docs: clarify hosted provider interfaces --- src/cli/parser/cli-help.ts | 5 + website/docs/docs/hosted-device-providers.md | 163 +++++++++++++++++-- 2 files changed, 151 insertions(+), 17 deletions(-) diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 9cac7420c..9b19350f2 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -659,6 +659,11 @@ Providers: BrowserStack: agent-device connect browserstack stores a local provider profile and creates the App Automate session on first open. AWS Device Farm: agent-device connect aws-device-farm stores a local provider profile and creates the remote access session on first open. +Hosted provider interfaces: + CLI is the canonical bootstrap path: connect browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. + JavaScript can skip persisted connect state by passing leaseProvider plus provider fields to createAgentDeviceClient or per-command options. + MCP exposes operational tools such as open, snapshot, click, close, and artifacts. It does not expose connect/disconnect; run CLI connect first in the same state dir before relying on MCP tools. + Direct proxy flow for a remote Mac/simulator: On the Mac with simulator/device access: agent-device proxy --port 4310 diff --git a/website/docs/docs/hosted-device-providers.md b/website/docs/docs/hosted-device-providers.md index c17d5cb63..93f07f19c 100644 --- a/website/docs/docs/hosted-device-providers.md +++ b/website/docs/docs/hosted-device-providers.md @@ -14,6 +14,18 @@ agent-device connect aws-device-farm ... These providers are not remote `agent-device` daemons. `connect browserstack` and `connect aws-device-farm` write a local generated profile, then the first lease-allocating command such as `open` creates the hosted WebDriver session. +## Interface Summary + +Hosted providers have one setup model and three ways to drive the resulting session: + +| Interface | What it does well | How provider setup works | +| ----------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CLI | Best default for agents and CI. It creates the local provider profile once, then normal commands inherit it. | Run `agent-device connect browserstack` or `agent-device connect aws-device-farm`, then `open`, `snapshot`, `click`, `close`, `artifacts`, and `disconnect`. | +| JavaScript client | Best for Node integrations that already own process configuration. | Pass provider fields in `createAgentDeviceClient(...)` config or per-command options, then call normal client methods such as `client.apps.open(...)` and `client.capture.snapshot(...)`. | +| MCP | Best for tool-only agents after bootstrap. MCP exposes operational tools backed by the same command contracts. | Run CLI `connect ...` before starting or using the MCP server in the same effective state directory. MCP intentionally does not expose `connect` or `disconnect` as provider setup tools. | + +The CLI is the canonical bootstrap path because it persists a non-secret generated profile and keeps BrowserStack/AWS credentials in the environment. After bootstrap, CLI, JavaScript, and MCP all use the same daemon/session behavior. + ## Autonomous Agent Requirements Agents can connect autonomously when all required credentials and selectors are present before the command starts. @@ -23,7 +35,18 @@ Agents can connect autonomously when all required credentials and selectors are - Keep generated remote profiles non-secret. They may contain provider app ids, Device Farm ARNs, device names, OS versions, and labels; they must not contain BrowserStack access keys or AWS secret keys. - Run `agent-device artifacts --json` after `close` when the provider has video/log URLs to fetch. -## BrowserStack +## CLI Experience + +The CLI experience is: + +1. Export provider credentials. +2. Run `agent-device connect ` with provider selectors. +3. Run normal `agent-device` commands. +4. Run `agent-device close` to stop the hosted session. +5. Run `agent-device artifacts --json` to retrieve provider-hosted video/log/dashboard URLs. +6. Run `agent-device disconnect` to clear local connection state. + +### BrowserStack Required environment: @@ -52,7 +75,29 @@ Optional labels: --provider-session-name "$GITHUB_JOB" ``` -## AWS Device Farm +Full flow: + +```bash +export BROWSERSTACK_USERNAME=... +export BROWSERSTACK_ACCESS_KEY=... + +agent-device connect browserstack \ + --platform android \ + --device "Google Pixel 8" \ + --provider-os-version 14.0 \ + --provider-app bs://app-id \ + --provider-project agent-device \ + --provider-build "$GITHUB_RUN_ID" + +agent-device open com.example.app +agent-device snapshot -i +agent-device click 'label="Continue"' +agent-device close +agent-device artifacts --json +agent-device disconnect +``` + +### AWS Device Farm AWS Device Farm uses the AWS CLI credential provider chain. `agent-device` does not require `aws login`; it shells out to `aws devicefarm ...`, so any non-interactive AWS CLI credential source that works in CI works here. The AWS CLI documents environment variables such as `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`, `AWS_PROFILE`, `AWS_ROLE_ARN`, and `AWS_WEB_IDENTITY_TOKEN_FILE` in the [AWS CLI environment variable reference](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html). @@ -95,13 +140,21 @@ export AWS_DEVICE_FARM_APP_ARN=... `AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN`, `AGENT_DEVICE_AWS_DEVICE_FARM_DEVICE_ARN`, and `AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN` are accepted as agent-device-specific aliases. -## Minimal CI Shape +Full flow: ```bash -# BrowserStack -BROWSERSTACK_USERNAME=... -BROWSERSTACK_ACCESS_KEY=... -agent-device connect browserstack --platform android --device "Google Pixel 8" --provider-os-version 14.0 --provider-app bs://app-id +export AWS_REGION=us-west-2 +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... +export AWS_SESSION_TOKEN=... + +agent-device connect aws-device-farm \ + --platform android \ + --aws-project-arn "$AWS_DEVICE_FARM_PROJECT_ARN" \ + --aws-device-arn "$AWS_DEVICE_FARM_DEVICE_ARN" \ + --aws-app-arn "$AWS_DEVICE_FARM_APP_ARN" \ + --provider-session-name "$GITHUB_JOB" + agent-device open com.example.app agent-device snapshot -i agent-device close @@ -109,20 +162,96 @@ agent-device artifacts --json agent-device disconnect ``` +## JavaScript Client Experience + +Use the JavaScript client when a Node process owns the provider configuration and does not need a persisted CLI connection profile. Provider selectors can live in the client config and normal methods drive the session. + +```bash +export BROWSERSTACK_USERNAME=... +export BROWSERSTACK_ACCESS_KEY=... +``` + +```ts +import { createAgentDeviceClient } from 'agent-device'; + +const client = createAgentDeviceClient({ + leaseProvider: 'browserstack', + platform: 'android', + device: 'Google Pixel 8', + providerOsVersion: '14.0', + providerApp: 'bs://app-id', + providerProject: 'agent-device', + providerBuild: process.env.GITHUB_RUN_ID, +}); + +await client.apps.open({ app: 'com.example.app' }); +const snapshot = await client.capture.snapshot({ interactiveOnly: true }); +console.log(snapshot.nodes.slice(0, 5)); +await client.interactions.click({ selector: 'label="Continue"' }); +const closed = await client.sessions.close(); +const providerSessionId = closed.provider?.providerSessionId; + +if (providerSessionId) { + const artifacts = await client.sessions.artifacts({ + provider: 'browserstack', + providerSessionId, + }); + console.log(artifacts.cloudArtifacts); +} +``` + +AWS Device Farm uses the same shape with AWS fields: + +```ts +const client = createAgentDeviceClient({ + leaseProvider: 'aws-device-farm', + platform: 'android', + awsProjectArn: process.env.AWS_DEVICE_FARM_PROJECT_ARN, + awsDeviceArn: process.env.AWS_DEVICE_FARM_DEVICE_ARN, + awsAppArn: process.env.AWS_DEVICE_FARM_APP_ARN, + awsRegion: process.env.AWS_REGION, +}); +``` + +The JavaScript client does not publish a hosted WebDriver SDK subpath. Use the normal typed client methods; provider implementation details stay internal. + +## MCP Experience + +The MCP server exposes operational command tools such as `open`, `snapshot`, `click`, `close`, and `artifacts`. It does not expose `connect browserstack` or `connect aws-device-farm`. + +For MCP-only operation, bootstrap with the CLI first in the same effective state directory: + +```bash +export BROWSERSTACK_USERNAME=... +export BROWSERSTACK_ACCESS_KEY=... +agent-device connect browserstack --platform android --device "Google Pixel 8" --provider-os-version 14.0 --provider-app bs://app-id +agent-device mcp +``` + +Then an MCP client can call the normal tools: + +```text +open { "app": "com.example.app" } +snapshot { "interactiveOnly": true } +click { "target": { "kind": "selector", "selector": "label=\"Continue\"" } } +close {} +artifacts {} +``` + +Use the same pattern for AWS Device Farm: provide AWS credentials in the MCP server environment, run CLI `connect aws-device-farm ...` once, then let MCP tools operate on the active connection. If an integration cannot run CLI bootstrap, use the JavaScript client path instead of MCP for provider setup. + +## Artifact Lookup + +`close` stops the active hosted session and may return a provider session id. `artifacts` fetches provider-hosted output: + ```bash -# AWS Device Farm -AWS_REGION=us-west-2 -AWS_ACCESS_KEY_ID=... -AWS_SECRET_ACCESS_KEY=... -AWS_SESSION_TOKEN=... -agent-device connect aws-device-farm --platform android --aws-project-arn "$AWS_DEVICE_FARM_PROJECT_ARN" --aws-device-arn "$AWS_DEVICE_FARM_DEVICE_ARN" --aws-app-arn "$AWS_DEVICE_FARM_APP_ARN" -agent-device open com.example.app -agent-device snapshot -i -agent-device close agent-device artifacts --json -agent-device disconnect +agent-device artifacts --provider browserstack --json +agent-device artifacts --provider aws-device-farm --json ``` +BrowserStack can return session video, Appium logs, device logs, dashboard URL, and public URL. AWS Device Farm can return remote-access video and log artifacts after the provider finalizes them. + ## Troubleshooting - If BrowserStack connect fails before opening a session, check `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `--provider-app`, `--provider-os-version`, and `--device`. From 9b87e60a75b181caa27b2c41deb6ef946d2c0ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 14:14:25 +0200 Subject: [PATCH 11/17] fix: avoid regex slash trimming in webdriver urls --- .../__tests__/webdriver-utils.test.ts | 17 +++++++++++++++++ src/cloud-webdriver/webdriver-utils.ts | 12 ++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 src/cloud-webdriver/__tests__/webdriver-utils.test.ts diff --git a/src/cloud-webdriver/__tests__/webdriver-utils.test.ts b/src/cloud-webdriver/__tests__/webdriver-utils.test.ts new file mode 100644 index 000000000..5b619e0a2 --- /dev/null +++ b/src/cloud-webdriver/__tests__/webdriver-utils.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { trimLeadingSlash, trimTrailingSlash } from '../webdriver-utils.ts'; + +test('slash trimming utilities handle slash-heavy strings without regular expressions', () => { + const slashRun = '/'.repeat(10_000); + + assert.equal(trimLeadingSlash(`${slashRun}wd/hub`), 'wd/hub'); + assert.equal( + trimTrailingSlash(`https://example.test/wd/hub${slashRun}`), + 'https://example.test/wd/hub', + ); + assert.equal(trimLeadingSlash('wd/hub'), 'wd/hub'); + assert.equal(trimTrailingSlash('https://example.test/wd/hub'), 'https://example.test/wd/hub'); + assert.equal(trimLeadingSlash(slashRun), ''); + assert.equal(trimTrailingSlash(slashRun), ''); +}); diff --git a/src/cloud-webdriver/webdriver-utils.ts b/src/cloud-webdriver/webdriver-utils.ts index 36c1e8476..53f526ca0 100644 --- a/src/cloud-webdriver/webdriver-utils.ts +++ b/src/cloud-webdriver/webdriver-utils.ts @@ -14,11 +14,19 @@ export function basicAuthHeader(credentials: { username: string; accessKey: stri } export function trimLeadingSlash(value: string): string { - return value.replace(/^\/+/, ''); + let firstNonSlash = 0; + while (firstNonSlash < value.length && value.charCodeAt(firstNonSlash) === 47) { + firstNonSlash += 1; + } + return firstNonSlash === 0 ? value : value.slice(firstNonSlash); } export function trimTrailingSlash(value: string): string { - return value.replace(/\/+$/, ''); + let lastNonSlash = value.length - 1; + while (lastNonSlash >= 0 && value.charCodeAt(lastNonSlash) === 47) { + lastNonSlash -= 1; + } + return lastNonSlash === value.length - 1 ? value : value.slice(0, lastNonSlash + 1); } export function withTrailingSlash(url: URL): URL { From d239b6b1ad4f9573a66ab3255214a09f9e13f3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 14:20:01 +0200 Subject: [PATCH 12/17] docs: rename hosted providers to device clouds --- src/cli/parser/cli-help.ts | 6 +++--- website/docs/docs/_meta.json | 4 ++-- website/docs/docs/commands.md | 4 ++-- .../{hosted-device-providers.md => device-clouds.md} | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) rename website/docs/docs/{hosted-device-providers.md => device-clouds.md} (96%) diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 9b19350f2..3f588a373 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -659,7 +659,7 @@ Providers: BrowserStack: agent-device connect browserstack stores a local provider profile and creates the App Automate session on first open. AWS Device Farm: agent-device connect aws-device-farm stores a local provider profile and creates the remote access session on first open. -Hosted provider interfaces: +Device cloud interfaces: CLI is the canonical bootstrap path: connect browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. JavaScript can skip persisted connect state by passing leaseProvider plus provider fields to createAgentDeviceClient or per-command options. MCP exposes operational tools such as open, snapshot, click, close, and artifacts. It does not expose connect/disconnect; run CLI connect first in the same state dir before relying on MCP tools. @@ -717,9 +717,9 @@ Rules: Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. Use BrowserStack and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. - Hosted provider credentials must be available before the command starts. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. + Device cloud credentials must be available before the command starts. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not BrowserStack access keys or AWS credentials. - After closing a hosted provider session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. + After closing a device cloud session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. connect proxy stores the connection profile and client identity. Device leases are acquired on open and expire after five minutes without commands. Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. disconnect releases local connection state; close releases the active session and device lease. diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index 42b55206d..ab65a70b6 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -45,9 +45,9 @@ "label": "Remote Proxy" }, { - "name": "hosted-device-providers", + "name": "device-clouds", "type": "file", - "label": "Hosted Device Providers" + "label": "Device Clouds" }, { "name": "security-trust", diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d7b76fac6..60cb58fa6 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -87,7 +87,7 @@ agent-device app-switcher - Remote daemon clients can pass `--daemon-base-url http(s)://host:port[/base-path]` to skip local daemon discovery/startup and call a remote HTTP daemon directly. - Use `--daemon-auth-token ` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) for explicit service/API-token automation against non-loopback remote daemon URLs; the client sends it in both the JSON-RPC request token and HTTP auth headers. - Use [Remote Proxy](/docs/remote-proxy) when you need to run `agent-device proxy` on a Mac with simulator/device access and drive it from another machine through cloudflared, ngrok, or another HTTP tunnel. -- Use [Hosted Device Providers](/docs/hosted-device-providers) when agents need BrowserStack or AWS Device Farm sessions from CI without interactive login. +- Use [Device Clouds & Farms](/docs/device-clouds) when agents need BrowserStack or AWS Device Farm sessions from CI without interactive login. - For human cloud access, `connect` can discover a cloud connection profile, while `connect --remote-config ...` uses a local profile. Both refresh a stored CLI session into a short-lived `adc_agent_...` token when needed. If no CLI session exists, interactive shells start login automatically; CI and non-interactive shells fail with API-token setup instructions. Use `--no-login` to disable implicit login. `AGENT_DEVICE_CLOUD_BASE_URL` is the bridge/control-plane API origin; its `/api-keys` route may redirect to the dashboard for token creation. - For remote `connect` and `connect --remote-config` flows, see [Remote Metro workflow](#remote-metro-workflow). - Android React Native relaunch flows require an installed package name for `open --relaunch`; install/reinstall the APK first, then relaunch by package. `open --relaunch` is rejected because runtime hints are written through the installed app sandbox. @@ -966,7 +966,7 @@ agent-device artifacts --provider aws-device-farm --provider-session ` plus `--provider `. BrowserStack expects `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY`; AWS Device Farm uses the AWS CLI credential chain and infers the region from the session ARN when possible. See [Hosted Device Providers](/docs/hosted-device-providers) for autonomous CI credential setup. +- Historical lookup requires `--provider-session ` plus `--provider `. BrowserStack expects `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY`; AWS Device Farm uses the AWS CLI credential chain and infers the region from the session ARN when possible. See [Device Clouds & Farms](/docs/device-clouds) for autonomous CI credential setup. - When a cloud runtime is registered in-process by an embedding host, `artifacts` can infer the active provider session from the current lease before disconnect. - `disconnect --json` and `close --json` include provider release data when the runtime returns final cloud artifacts after session teardown. Some providers only finalize video/log URLs after the remote session is stopped, so retry `agent-device artifacts --provider --json` if the first response is `pending`. diff --git a/website/docs/docs/hosted-device-providers.md b/website/docs/docs/device-clouds.md similarity index 96% rename from website/docs/docs/hosted-device-providers.md rename to website/docs/docs/device-clouds.md index 93f07f19c..1f631d712 100644 --- a/website/docs/docs/hosted-device-providers.md +++ b/website/docs/docs/device-clouds.md @@ -1,11 +1,11 @@ --- -title: Hosted Device Providers -description: Configure BrowserStack and AWS Device Farm so agents can connect without interactive login. +title: Device Clouds & Farms +description: Connect agents to BrowserStack device clouds and AWS Device Farm without interactive login. --- -# Hosted Device Providers +# Device Clouds & Farms -Use hosted provider connections when the agent should drive BrowserStack App Automate or AWS Device Farm remote access through the local `agent-device` daemon: +Use device cloud and device farm connections when the agent should drive BrowserStack App Automate or AWS Device Farm remote access through the local `agent-device` daemon: ```bash agent-device connect browserstack ... @@ -16,7 +16,7 @@ These providers are not remote `agent-device` daemons. `connect browserstack` an ## Interface Summary -Hosted providers have one setup model and three ways to drive the resulting session: +Device cloud providers have one setup model and three ways to drive the resulting session: | Interface | What it does well | How provider setup works | | ----------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From ad0f22df912a57808d266d87953be50665559e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 14:32:06 +0200 Subject: [PATCH 13/17] fix: align provider profile imports with remote modules --- src/cli/connection-profile-fields.ts | 4 ++-- src/cli/provider-connection-profile.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cli/connection-profile-fields.ts b/src/cli/connection-profile-fields.ts index e1d974d3a..f9cd0a42f 100644 --- a/src/cli/connection-profile-fields.ts +++ b/src/cli/connection-profile-fields.ts @@ -1,5 +1,5 @@ -import type { RemoteConfigMetroOptions } from '../remote-config-schema.ts'; -import type { CliFlags } from '../utils/cli-flags.ts'; +import type { RemoteConfigMetroOptions } from '../remote/remote-config-schema.ts'; +import type { CliFlags } from './parser/cli-flags.ts'; export function readMetroProfileFields(flags: CliFlags): RemoteConfigMetroOptions { return { diff --git a/src/cli/provider-connection-profile.ts b/src/cli/provider-connection-profile.ts index 63177aa99..07b76e09f 100644 --- a/src/cli/provider-connection-profile.ts +++ b/src/cli/provider-connection-profile.ts @@ -1,10 +1,10 @@ import crypto from 'node:crypto'; import { CLOUD_WEBDRIVER_PROVIDERS } from '../cloud-webdriver/providers.ts'; import type { CloudWebDriverKnownProviderName } from '../cloud-webdriver/providers.ts'; -import type { RemoteConfigProfile } from '../remote-config-schema.ts'; +import type { RemoteConfigProfile } from '../remote/remote-config-schema.ts'; import { AppError } from '../kernel/errors.ts'; import type { PlatformSelector } from '../kernel/device.ts'; -import type { CliFlags } from '../utils/cli-flags.ts'; +import type { CliFlags } from './parser/cli-flags.ts'; import type { EnvMap } from '../utils/env-map.ts'; import { readMetroProfileFields } from './connection-profile-fields.ts'; import { persistAndResolveGeneratedProfile } from './generated-remote-config.ts'; From 60a64d295e311b5c9dffc7b9fcedc3d56af21b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 15:07:09 +0200 Subject: [PATCH 14/17] fix: skip local android recovery for provider devices --- .../request-router-android-modal.test.ts | 47 +++++++++++++++++++ src/daemon/request-generic-dispatch.ts | 5 ++ 2 files changed, 52 insertions(+) diff --git a/src/daemon/__tests__/request-router-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index 3e6632ccd..ca541c70f 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -21,6 +21,10 @@ import { createRequestHandler } from '../request-router.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { + createProviderDeviceRuntimeRequestProviders, + type ProviderDeviceRuntime, +} from '../../provider-device-runtime.ts'; vi.mock('../../platforms/android/snapshot.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -174,3 +178,46 @@ test('generic Android gesture commands continue when recording dialog inspection expect(openAndroidApp).not.toHaveBeenCalled(); expect(snapshotCalls).toBe(1); }); + +test('generic Android gesture commands skip local dialog recovery for provider devices', async () => { + snapshotCalls = 0; + snapshotMode = 'blocking-dialog'; + execCalls.length = 0; + dispatchCalls.length = 0; + + const sessionStore = makeSessionStore('agent-device-router-android-modal-provider-'); + const session = makeAndroidSession('default'); + sessionStore.set('default', session); + + const runtime: ProviderDeviceRuntime = { + provider: 'webdriver-fake', + leaseLifecycle: {}, + deviceInventoryProvider: async () => [session.device], + ownsDevice: (device) => device.id === session.device.id, + getInteractor: () => undefined, + shutdown: async () => {}, + }; + const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + providerDeviceRuntimeScope: providers.providerDeviceRuntimeScope, + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'scroll', + positionals: ['down', '0.55'], + meta: { requestId: 'req-android-modal-provider' }, + }); + + expect(response.ok).toBe(true); + expect(dispatchCalls).toEqual([['scroll', 'down', '0.55']]); + expect(execCalls).toEqual([]); + expect(snapshotCalls).toBe(0); +}); diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index f3e6c16e9..dbd7852a4 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -26,6 +26,7 @@ import { import { markPostGestureStabilization } from './post-gesture-stabilization.ts'; import { normalizeError } from '../kernel/errors.ts'; import { shouldGuardAndroidBlockingDialog } from './daemon-command-registry.ts'; +import { isActiveProviderDevice } from '../provider-device-runtime.ts'; const GESTURE_PLATFORM_COMMANDS: Readonly> = { pan: 'pan', @@ -136,6 +137,9 @@ async function ensureNoAndroidBlockingDialogReady( if (session.device.platform !== 'android' || !shouldGuardAndroidBlockingDialog(platformCommand)) { return { status: 'clear' }; } + if (isActiveProviderDevice(session.device)) { + return { status: 'clear' }; + } try { return await ensureAndroidBlockingSystemDialogReady({ session, @@ -155,6 +159,7 @@ async function ensureGenericCommandReady( if (unsupported) return unsupported; if ( session.device.platform !== 'android' || + isActiveProviderDevice(session.device) || !session.recording || platformCommand === 'record' || (await recoverAndroidBlockingSystemDialog({ session })).status !== 'failed' From 9dc9424d1e308ae4d5b689a07580cd3c1ce32e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 15:14:15 +0200 Subject: [PATCH 15/17] test: classify cloud provider integration flags --- scripts/integration-progress-model.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 7b37ce3f8..ac7f03e62 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -232,8 +232,22 @@ function summarizeProviderScenarioFlagExclusions() { }, { name: 'cloud artifact provider lookup', - owner: 'cloud artifact provider, CLI output, and cloud WebDriver provider scenario tests', - keys: ['provider', 'providerSessionId'], + owner: + 'cloud provider profile, artifact provider, CLI output, and cloud WebDriver provider scenario tests', + keys: [ + 'provider', + 'providerSessionId', + 'providerApp', + 'providerOsVersion', + 'providerProject', + 'providerBuild', + 'providerSessionName', + 'awsProjectArn', + 'awsDeviceArn', + 'awsAppArn', + 'awsRegion', + 'awsInteractionMode', + ], }, { name: 'Metro and React Native runtime preparation', From 71c7b6a6ec4a96bd19235dffdfae07c120508173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 18:49:03 +0200 Subject: [PATCH 16/17] fix: close active cloud connection session --- package.json | 4 ---- src/__tests__/package-exports.test.ts | 1 - src/__tests__/remote-connection.test.ts | 12 +++++++++++- src/cli/commands/connection.ts | 4 +++- src/index.ts | 1 - website/docs/docs/client-api.md | 8 -------- 6 files changed, 14 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 8dadac1c3..508968b43 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,6 @@ "import": "./dist/src/android-adb.js", "types": "./dist/src/android-adb.d.ts" }, - "./android-snapshot-helper": { - "import": "./dist/src/android-snapshot-helper.js", - "types": "./dist/src/android-snapshot-helper.d.ts" - }, "./contracts": { "import": "./dist/src/contracts.js", "types": "./dist/src/contracts.d.ts" diff --git a/src/__tests__/package-exports.test.ts b/src/__tests__/package-exports.test.ts index 3d70dd957..bd2790603 100644 --- a/src/__tests__/package-exports.test.ts +++ b/src/__tests__/package-exports.test.ts @@ -27,7 +27,6 @@ const supportedSubpaths = [ './remote-config', './install-source', './android-adb', - './android-snapshot-helper', './contracts', './selectors', './finders', diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index c32382116..4055065e6 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -1878,6 +1878,7 @@ test('disconnect without a session uses active connection state', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-disconnect-active-')); const stateDir = path.join(tempRoot, '.state'); const remoteConfigPath = path.join(tempRoot, 'remote.json'); + const closedSessions: Array<{ session: string | undefined; shutdown: boolean | undefined }> = []; fs.writeFileSync(remoteConfigPath, '{}'); writeRemoteConnectionState({ stateDir, @@ -1905,10 +1906,19 @@ test('disconnect without a session uses active connection state', async () => { stateDir, shutdown: true, }, - client: createTestClient(), + client: createTestClient({ + closeSession: async (options) => { + closedSessions.push({ session: options?.session, shutdown: options?.shutdown }); + return { + session: options?.session ?? 'default', + identifiers: { session: options?.session ?? 'default' }, + }; + }, + }), }); }); + assert.deepEqual(closedSessions, [{ session: 'adc-android', shutdown: true }]); assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-android' }), null); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 7fa97f3ce..f2794badb 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -315,7 +315,9 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) let providerData: Record | undefined; try { - providerData = (await client.sessions.close({ shutdown: flags.shutdown })).provider; + providerData = ( + await client.sessions.close({ session: connectedSession, shutdown: flags.shutdown }) + ).provider; } catch { // Disconnect is idempotent; the session may already be closed. } diff --git a/src/index.ts b/src/index.ts index 37f70e470..4c4a463e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,7 +93,6 @@ export type { FindOptions, FocusOptions, GetOptions, - HomeCommandOptions, HomeCommandResult, InteractionTarget, IsOptions, diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 0a7ed2fa2..cbd20e704 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -51,14 +51,6 @@ Public subpath API exposed for Node consumers: - types: `MaterializeInstallSource` - `agent-device/artifacts` - `resolveAndroidArchivePackageName(archivePath)` -- `agent-device/android-snapshot-helper` - - `ensureAndroidSnapshotHelper(options)` - - `captureAndroidSnapshotWithHelper(options)` - - `parseAndroidSnapshotHelperOutput(output)` - - `parseAndroidSnapshotHelperXml(xml, metadata?, options?, maxNodes?)` - - `prepareAndroidSnapshotHelperArtifactFromManifestUrl(options)` - - `verifyAndroidSnapshotHelperArtifact(artifact)` - - types: `AndroidAdbExecutor`, `AndroidSnapshotHelperArtifact`, `AndroidSnapshotHelperManifest`, `AndroidSnapshotHelperOutput`, `AndroidSnapshotHelperParsedSnapshot` - `agent-device/android-adb` - `createAndroidPortReverseManager(provider)` - `captureAndroidLogcatWithAdb(executor, options?)` From 656308947376706ecd6b708f3df2ac2bf85433c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 19:23:59 +0200 Subject: [PATCH 17/17] test: cover provider disconnect cli flow --- .../smoke-open-remote-config.test.ts | 25 +- .../smoke-provider-cli-disconnect.test.ts | 249 ++++++++++++++++++ 2 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 test/integration/smoke-provider-cli-disconnect.test.ts diff --git a/test/integration/smoke-open-remote-config.test.ts b/test/integration/smoke-open-remote-config.test.ts index 04a20437f..534b4c55d 100644 --- a/test/integration/smoke-open-remote-config.test.ts +++ b/test/integration/smoke-open-remote-config.test.ts @@ -71,12 +71,7 @@ async function runCliJson(args: string[], env?: NodeJS.ProcessEnv): Promise { const chunks: Buffer[] = []; for await (const chunk of req) { @@ -303,14 +312,14 @@ test('connect prepares Metro and open reuses bridged runtime for remote daemon', ); assert.equal(connectResult.code, null, `${connectResult.stderr}\n${connectResult.stdout}`); - assert.equal(connectResult.json?.success, true, JSON.stringify(connectResult.json)); + assert.equal(connectResult.json?.success, true, JSON.stringify(connectResult.json) ?? ''); const result = await runCliJson(['open', 'Demo', '--state-dir', stateDir, '--json'], { AGENT_DEVICE_DAEMON_AUTH_TOKEN: sharedToken, }); assert.equal(result.code, null, `${result.stderr}\n${result.stdout}`); - assert.equal(result.json?.success, true, JSON.stringify(result.json)); + assert.equal(result.json?.success, true, JSON.stringify(result.json) ?? result.stdout); assert.equal(capturedBridgeRequest?.authorization, `Bearer ${sharedToken}`); assert.equal(capturedOpenRpcRequest?.authorization, `Bearer ${sharedToken}`); diff --git a/test/integration/smoke-provider-cli-disconnect.test.ts b/test/integration/smoke-provider-cli-disconnect.test.ts new file mode 100644 index 000000000..21887a261 --- /dev/null +++ b/test/integration/smoke-provider-cli-disconnect.test.ts @@ -0,0 +1,249 @@ +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../src/__tests__/test-utils/loopback.ts'; +import { formatResultDebug, runBuiltCliJson } from './cli-json.ts'; + +async function readJsonBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const body = Buffer.concat(chunks).toString('utf8'); + return body ? JSON.parse(body) : {}; +} + +test('built CLI provider flow closes active generated session before disconnect cleanup', async (t) => { + if (await skipWhenLoopbackUnavailable(t, 'provider disconnect smoke coverage')) { + return; + } + + const fixture = await createProviderDaemonFixture(t); + const env = createProviderEnv(); + const activeSession = await connectBrowserStackProvider(fixture, env); + await openProviderApp(fixture, env); + await disconnectProviderSession(fixture, env, activeSession); + assertProviderDisconnectRpc(fixture.rpcRequests, activeSession); + await assertNoActiveConnection(fixture, env); +}); + +type ProviderDaemonFixture = { + root: string; + stateDir: string; + daemonBaseUrl: string; + rpcRequests: any[]; +}; + +async function createProviderDaemonFixture(t: TestContext): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-provider-disconnect-smoke-')); + const stateDir = path.join(root, 'state'); + const rpcRequests: any[] = []; + let hostPort = 0; + const hostServer = http.createServer(async (req, res) => { + if (req.method === 'GET' && req.url === '/agent-device/health') { + res.writeHead(200, { + 'content-type': 'application/json', + connection: 'close', + }); + res.end( + JSON.stringify({ + ok: true, + service: 'agent-device-daemon', + version: '0.0.0-test', + rpcProtocolVersion: 1, + }), + ); + return; + } + + if (req.method === 'POST' && req.url === '/agent-device/rpc') { + const body = await readJsonBody(req); + rpcRequests.push(body); + const data = responseDataForRpc(body); + res.writeHead(200, { + 'content-type': 'application/json', + connection: 'close', + }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body?.id ?? 'provider-disconnect-smoke', + result: { + ok: true, + data, + }, + }), + ); + return; + } + + res.writeHead(404); + res.end(); + }); + hostPort = await listenOnLoopback(hostServer); + t.after(async () => { + await closeLoopbackServer(hostServer); + fs.rmSync(root, { recursive: true, force: true }); + }); + return { + root, + stateDir, + daemonBaseUrl: `http://127.0.0.1:${hostPort}/agent-device`, + rpcRequests, + }; +} + +function createProviderEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + BROWSERSTACK_USERNAME: 'browser-user', + BROWSERSTACK_ACCESS_KEY: 'browser-key', + }; +} + +async function connectBrowserStackProvider( + fixture: ProviderDaemonFixture, + env: NodeJS.ProcessEnv, +): Promise { + const connectArgs = [ + 'connect', + 'browserstack', + '--platform', + 'android', + '--device', + 'Google Pixel 8', + '--provider-os-version', + '14.0', + '--provider-app', + 'bs://app-id', + '--daemon-base-url', + fixture.daemonBaseUrl, + '--state-dir', + fixture.stateDir, + '--json', + ]; + const connectResult = await runBuiltCliJson(connectArgs, env); + + assert.equal(connectResult.status, 0, formatResultDebug('connect', connectArgs, connectResult)); + assert.equal( + connectResult.json?.success, + true, + formatResultDebug('connect', connectArgs, connectResult), + ); + const activeSession = connectResult.json?.data?.session; + assert.match(activeSession, /^adc-/); + return activeSession; +} + +async function openProviderApp( + fixture: ProviderDaemonFixture, + env: NodeJS.ProcessEnv, +): Promise { + const openArgs = ['open', 'Demo', '--state-dir', fixture.stateDir, '--json']; + const openResult = await runBuiltCliJson(openArgs, env); + assert.equal(openResult.status, 0, formatResultDebug('open', openArgs, openResult)); + assert.equal(openResult.json?.success, true, formatResultDebug('open', openArgs, openResult)); +} + +async function disconnectProviderSession( + fixture: ProviderDaemonFixture, + env: NodeJS.ProcessEnv, + activeSession: string, +): Promise { + const disconnectArgs = ['disconnect', '--state-dir', fixture.stateDir, '--json']; + const disconnectResult = await runBuiltCliJson(disconnectArgs, env); + assert.equal( + disconnectResult.status, + 0, + formatResultDebug('disconnect', disconnectArgs, disconnectResult), + ); + assert.equal( + disconnectResult.json?.success, + true, + formatResultDebug('disconnect', disconnectArgs, disconnectResult), + ); + assert.equal(disconnectResult.json?.data?.session, activeSession); + assert.equal(disconnectResult.json?.data?.released, true); +} + +function assertProviderDisconnectRpc(rpcRequests: any[], activeSession: string): void { + const closeRpc = rpcRequests.find( + (request) => request.method === 'agent_device.command' && request.params?.command === 'close', + ); + assert.equal(closeRpc?.params?.session, activeSession); + assert.notEqual(closeRpc?.params?.session, 'default'); + + const releaseRpc = rpcRequests.find((request) => request.method === 'agent_device.lease.release'); + assert.equal(releaseRpc?.params?.session, activeSession); + assert.equal(releaseRpc?.params?.leaseId, 'lease-bs-1'); + assert.equal(releaseRpc?.params?.leaseProvider, 'browserstack'); +} + +async function assertNoActiveConnection( + fixture: ProviderDaemonFixture, + env: NodeJS.ProcessEnv, +): Promise { + const statusArgs = ['connection', 'status', '--state-dir', fixture.stateDir, '--json']; + const statusResult = await runBuiltCliJson(statusArgs, env); + assert.equal(statusResult.status, 0, formatResultDebug('status', statusArgs, statusResult)); + assert.equal( + statusResult.json?.success, + true, + formatResultDebug('status', statusArgs, statusResult), + ); + assert.equal(statusResult.json?.data?.connected, false); +} + +function responseDataForRpc(body: any): Record { + const params = body?.params ?? {}; + if (body?.method === 'agent_device.lease.allocate') { + return { + lease: { + leaseId: 'lease-bs-1', + tenantId: params.tenantId, + runId: params.runId, + backend: params.backend, + leaseProvider: params.leaseProvider, + clientId: params.clientId, + deviceKey: params.deviceKey, + }, + }; + } + if (body?.method === 'agent_device.lease.release') { + return { + released: true, + provider: { + provider: 'browserstack', + providerSessionId: 'bs-session-1', + }, + }; + } + if (params.command === 'open') { + return { + session: params.session, + appName: 'Demo', + appBundleId: 'com.example.demo', + platform: 'android', + target: 'mobile', + device: 'Pixel', + id: 'browserstack-pixel', + serial: 'browserstack-pixel', + }; + } + if (params.command === 'close') { + return { + provider: { + provider: 'browserstack', + providerSessionId: 'bs-session-1', + }, + }; + } + return {}; +}