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/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 28db37220..ac7f03e62 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -230,6 +230,25 @@ function summarizeProviderScenarioFlagExclusions() { owner: 'connection/runtime/request policy tests', keys: ['force', 'noLogin', 'sessionLock', 'sessionLocked', 'sessionLockConflicts'], }, + { + name: 'cloud artifact provider lookup', + 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', 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__/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__/default-cloud-artifact-provider.test.ts b/src/__tests__/default-cloud-artifact-provider.test.ts new file mode 100644 index 000000000..b95ef3236 --- /dev/null +++ b/src/__tests__/default-cloud-artifact-provider.test.ts @@ -0,0 +1,126 @@ +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 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({}); + + 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__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 1db15781e..4055065e6 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 }); }); @@ -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.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 3c68f56a5..ee99421ed 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -20,8 +20,10 @@ 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'; const leaseDeferredCommands = new Set([ 'connect', @@ -457,8 +459,8 @@ export async function stopReactDevtoolsCleanup(options: { export async function releaseRemoteConnectionLease( client: AgentDeviceClient, state: RemoteConnectionState, -): Promise { - if (!state.leaseId) return false; +): Promise<{ released: boolean; provider?: CloudProviderSessionResult }> { + if (!state.leaseId) return { released: false }; const result = await client.leases.release({ tenant: state.tenant, runId: state.runId, @@ -472,7 +474,7 @@ export async function releaseRemoteConnectionLease( clientId: state.clientId, deviceKey: state.deviceKey, }); - return result.released; + return result; } export async function releasePreviousLease( @@ -604,7 +606,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 d40774519..f2794badb 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) { @@ -289,8 +313,11 @@ 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({ session: connectedSession, shutdown: flags.shutdown }) + ).provider; } catch { // Disconnect is idempotent; the session may already be closed. } @@ -299,7 +326,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 +336,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; @@ -349,16 +383,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}.`, ); } @@ -502,6 +538,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.' @@ -564,6 +615,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/connection-profile-fields.ts b/src/cli/connection-profile-fields.ts new file mode 100644 index 000000000..f9cd0a42f --- /dev/null +++ b/src/cli/connection-profile-fields.ts @@ -0,0 +1,19 @@ +import type { RemoteConfigMetroOptions } from '../remote/remote-config-schema.ts'; +import type { CliFlags } from './parser/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/parser/cli-flags.ts b/src/cli/parser/cli-flags.ts index ecbaac844..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; @@ -47,6 +51,8 @@ export type CliFlags = RemoteConfigMetroOptions & runId?: string; leaseId?: string; leaseBackend?: LeaseBackend; + provider?: string; + providerSessionId?: string; force?: boolean; noLogin?: boolean; kind?: string; @@ -311,6 +317,92 @@ 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: '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'], @@ -1154,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..3f588a373 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -653,9 +653,16 @@ 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. + +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. Direct proxy flow for a remote Mac/simulator: On the Mac with simulator/device access: @@ -675,6 +682,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_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 + 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 +716,10 @@ 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. + 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 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/src/cli/provider-connection-profile.ts b/src/cli/provider-connection-profile.ts new file mode 100644 index 000000000..07b76e09f --- /dev/null +++ b/src/cli/provider-connection-profile.ts @@ -0,0 +1,190 @@ +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/remote-config-schema.ts'; +import { AppError } from '../kernel/errors.ts'; +import type { PlatformSelector } from '../kernel/device.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'; + +export function resolveCloudWebDriverConnectProfile(options: { + provider: CloudWebDriverKnownProviderName; + flags: CliFlags; + stateDir: string; + cwd: string; + env?: EnvMap; +}): { flags: CliFlags; remoteConfigPath: string } { + const providerConfig = requireConnectProfileBuilder(options.provider)(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, + ...readMetroProfileFields(options.flags), + }; + return persistAndResolveGeneratedProfile({ + stateDir: options.stateDir, + provider: options.provider, + profile, + cwd: options.cwd, + env: options.env, + flags: options.flags, + }); +} + +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; +}): 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 { env, flags } = options; + const platform = requireCloudWebDriverPlatform( + flags.platform, + 'connect aws-device-farm requires --platform ios|android.', + ); + return { + platform, + 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: 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: 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, + }; +} + +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 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, + 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/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/client/client-normalizers.ts b/src/client/client-normalizers.ts index a502402d1..2b82a1360 100644 --- a/src/client/client-normalizers.ts +++ b/src/client/client-normalizers.ts @@ -272,6 +272,18 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { daemonTransport: options.daemonTransport, daemonServerMode: options.daemonServerMode, ...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-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..c40f1c6e1 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -44,8 +44,12 @@ 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, CloudProviderSessionResult } from '../cloud-artifacts.ts'; export type { FindLocator } from '../utils/finders.ts'; export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; @@ -68,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, @@ -104,6 +109,16 @@ export type AgentDeviceRequestOverrides = Pick< | 'leaseProvider' | 'deviceKey' | 'clientId' + | 'providerApp' + | 'providerOsVersion' + | 'providerProject' + | 'providerBuild' + | 'providerSessionName' + | 'awsProjectArn' + | 'awsDeviceArn' + | 'awsAppArn' + | 'awsRegion' + | 'awsInteractionMode' | 'leaseTtlMs' | 'cwd' | 'debug' @@ -186,9 +201,15 @@ export type StartupPerfSample = { export type SessionCloseResult = { session: string; shutdown?: TargetShutdownResult; + provider?: CloudProviderSessionResult; identifiers: AgentDeviceIdentifiers; }; +export type CloudArtifactsOptions = AgentDeviceRequestOverrides & { + provider?: string; + providerSessionId?: string; +}; + export type AppInstallOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { app?: string; @@ -894,6 +915,8 @@ export type InternalRequestOptions = AgentDeviceClientConfig & materializedPathRetentionMs?: number; materializationId?: string; leaseTtlMs?: number; + provider?: string; + providerSessionId?: string; }; export type CommandRequestResult = DaemonResponseData; @@ -915,6 +938,7 @@ export type AgentDeviceClient = { close: ( options?: AgentDeviceRequestOverrides & { shutdown?: boolean }, ) => Promise; + artifacts: (options?: CloudArtifactsOptions) => Promise; }; apps: { install: (options: AppInstallOptions) => Promise; @@ -934,7 +958,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?: CloudProviderSessionResult }>; }; 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..565b3c9c5 --- /dev/null +++ b/src/cloud-artifacts.ts @@ -0,0 +1,56 @@ +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; +}; + +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 + * 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/__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/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..10b75cc95 --- /dev/null +++ b/src/cloud-webdriver/aws-device-farm.ts @@ -0,0 +1,311 @@ +import { + createCloudWebDriverCapabilities, + type CloudWebDriverCapabilityOverrides, + 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 { 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; +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.', + }, + 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, + 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?: LeaseValue; + webdriverCapabilities?: + | Record + | ((lease: DeviceLease) => Record); + client?: AwsDeviceFarmClient; + pollIntervalMs?: number; + startupTimeoutMs?: number; + interactionMode?: AwsCreateRemoteAccessSessionInput['interactionMode']; + configuration?: AwsCreateRemoteAccessSessionInput['configuration']; + deviceId?: CloudWebDriverRuntimeOptions['deviceId']; + requestPolicy?: CloudWebDriverRuntimeOptions['requestPolicy']; + prepareSession?: CloudWebDriverRuntimeOptions['prepareSession']; +}; + +export function getAwsDeviceFarmWebDriverCapabilities( + platform: CloudWebDriverPlatform, +): CloudWebDriverProviderCapabilities { + return createCloudWebDriverCapabilities({ + provider: AWS_DEVICE_FARM_PROVIDER, + platform, + overrides: AWS_DEVICE_FARM_CAPABILITY_OVERRIDES, + }); +} + +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: + options.prepareSession ?? + createAwsDeviceFarmPrepareSession({ + ...options, + platform, + deviceName, + client, + }), + deviceId: options.deviceId, + requestPolicy: options.requestPolicy, + capabilityOverrides: AWS_DEVICE_FARM_CAPABILITY_OVERRIDES, + }); +} + +export type AwsCliDeviceFarmClientOptions = { + region?: string; + awsCommand?: string; +}; + +export function createAwsCliDeviceFarmClient( + options: AwsCliDeviceFarmClientOptions = {}, +): AwsDeviceFarmClient { + const runDeviceFarmJson = createAwsDeviceFarmCommandRunner(options); + return { + createRemoteAccessSession: async (input) => { + const json = await runDeviceFarmJson('create-remote-access-session', [ + '--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)] : []), + ]); + return readRemoteAccessSession(json); + }, + getRemoteAccessSession: async (arn) => { + const json = await runDeviceFarmJson('get-remote-access-session', ['--arn', arn]); + return readRemoteAccessSession(json); + }, + stopRemoteAccessSession: async (arn) => { + const json = await runDeviceFarmJson('stop-remote-access-session', ['--arn', arn]); + return readRemoteAccessSession(json); + }, + listArtifacts: async (arn, type) => { + const json = await runDeviceFarmJson('list-artifacts', ['--arn', arn, '--type', type]); + return readAwsArtifacts(json); + }, + }; +} + +export 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: resolveLeaseValue(options.sessionName, lease) ?? `agent-device-${lease.leaseId}`, + 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 sleep(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, + }); +} + +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 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.', { + 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; +} diff --git a/src/cloud-webdriver/browserstack.ts b/src/cloud-webdriver/browserstack.ts new file mode 100644 index 000000000..57f6b4384 --- /dev/null +++ b/src/cloud-webdriver/browserstack.ts @@ -0,0 +1,315 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CloudArtifact, CloudArtifactsResult } from '../cloud-artifacts.ts'; +import { + createCloudWebDriverCapabilities, + type CloudWebDriverCapabilityOverrides, + 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'; +import { agentDeviceRequestHeaders } from './request-headers.ts'; +import { + basicAuthHeader, + resolveLeaseValue, + trimTrailingSlash, + type LeaseValue, +} from './webdriver-utils.ts'; + +const BROWSERSTACK_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.browserStack; +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'; +export 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; + accessKey: string; + platform: CloudWebDriverPlatform; + deviceName: string; + osVersion: string; + app?: string; + projectName?: string; + buildName?: LeaseValue; + sessionName?: LeaseValue; + webdriverCapabilities?: + | Record + | ((lease: DeviceLease) => Record); + endpoint?: string | URL; + uploadEndpoint?: string | URL; + sessionDetailsEndpoint?: string | URL; + deviceId?: CloudWebDriverRuntimeOptions['deviceId']; + requestPolicy?: CloudWebDriverRuntimeOptions['requestPolicy']; + 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 { + return createCloudWebDriverCapabilities({ + provider: BROWSERSTACK_PROVIDER, + platform, + overrides: BROWSERSTACK_CAPABILITY_OVERRIDES, + }); +} + +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) => + 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, + endpoint: uploadEndpoint, + }), + listArtifacts: async ({ provider, providerSessionId }) => + await listBrowserStackCloudArtifacts(provider, providerSessionId, artifactOptions), + deviceId: options.deviceId, + prepareSession: options.prepareSession, + requestPolicy: options.requestPolicy, + capabilityOverrides: BROWSERSTACK_CAPABILITY_OVERRIDES, + }); +} + +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: { + ...agentDeviceRequestHeaders(), + Authorization: basicAuthHeader(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; +} + +export 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, + }; + }; +} + +export function buildBrowserStackCapabilities( + options: BrowserStackCapabilitiesOptions, +): Record { + return { + device: options.deviceName, + os_version: options.osVersion, + ...(options.app ? { app: options.app } : {}), + 'bstack:options': { + ...(options.projectName ? { projectName: options.projectName } : {}), + buildName: options.buildName, + sessionName: options.sessionName, + }, + ...(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, +): Promise> { + const endpoint = new URL( + `${trimTrailingSlash(String(options.endpoint ?? BROWSERSTACK_SESSION_DETAILS_ENDPOINT))}/${sessionId}.json`, + ); + const response = await fetch(endpoint, { + headers: { + ...agentDeviceRequestHeaders(), + Authorization: basicAuthHeader(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 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/provider-definitions.ts b/src/cloud-webdriver/provider-definitions.ts new file mode 100644 index 000000000..4884f0160 --- /dev/null +++ b/src/cloud-webdriver/provider-definitions.ts @@ -0,0 +1,313 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { CloudArtifactsResult } from '../cloud-artifacts.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, + createAwsDeviceFarmPrepareSession, + listAwsDeviceFarmCloudArtifacts, +} from './aws-device-farm.ts'; +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 { + buildCloudWebDriverBaseCapabilities, + createCloudWebDriverRuntime, + 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: (env: DefaultCloudWebDriverProviderRuntimeEnv) => ProviderDeviceRuntime; + listArtifactsFromEnv: ( + providerSessionId: string, + env: DefaultCloudWebDriverArtifactEnv, + ) => Promise; +}; + +export const CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS: readonly CloudWebDriverProviderDefinition[] = [ + { + provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + 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: buildBrowserStackCapabilities({ + deviceName, + osVersion, + app, + projectName: readFlag(request, 'providerProject'), + buildName: readFlag(request, 'providerBuild') ?? lease.runId, + sessionName: readFlag(request, 'providerSessionName') ?? lease.leaseId, + configured: buildCloudWebDriverBaseCapabilities(platform, deviceName), + }), + }; + }, + }), + 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: (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: + 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 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 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 new file mode 100644 index 000000000..d0f6d839b --- /dev/null +++ b/src/cloud-webdriver/provider-registry.ts @@ -0,0 +1,18 @@ +import type { CloudArtifactsQuery, CloudArtifactsResult } from '../cloud-artifacts.ts'; +import { + findCloudWebDriverProviderDefinition, + type DefaultCloudWebDriverArtifactEnv, +} from './provider-definitions.ts'; + +export type { DefaultCloudWebDriverArtifactEnv } from './provider-definitions.ts'; + +export async function listCloudWebDriverArtifactsFromEnv( + query: CloudArtifactsQuery, + env: DefaultCloudWebDriverArtifactEnv, +): Promise { + if (!query.providerSessionId) return undefined; + return await findCloudWebDriverProviderDefinition(query.provider)?.listArtifactsFromEnv( + query.providerSessionId, + env, + ); +} diff --git a/src/cloud-webdriver/provider-runtimes.ts b/src/cloud-webdriver/provider-runtimes.ts new file mode 100644 index 000000000..7b79f085e --- /dev/null +++ b/src/cloud-webdriver/provider-runtimes.ts @@ -0,0 +1,13 @@ +import type { ProviderDeviceRuntime } from '../provider-device-runtime.ts'; +import { + CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS, + type DefaultCloudWebDriverProviderRuntimeEnv, +} from './provider-definitions.ts'; + +export type { DefaultCloudWebDriverProviderRuntimeEnv } from './provider-definitions.ts'; + +export function createDefaultCloudWebDriverProviderRuntimes( + env: DefaultCloudWebDriverProviderRuntimeEnv = process.env, +): ProviderDeviceRuntime[] { + return CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS.map((definition) => definition.createRuntime(env)); +} diff --git a/src/cloud-webdriver/providers.ts b/src/cloud-webdriver/providers.ts new file mode 100644 index 000000000..814a0c19a --- /dev/null +++ b/src/cloud-webdriver/providers.ts @@ -0,0 +1,15 @@ +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]; + +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/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/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..8ee28f8ec --- /dev/null +++ b/src/cloud-webdriver/runtime.ts @@ -0,0 +1,405 @@ +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 { DaemonRequest } from '../daemon/types.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; + lease?: DeviceLease; + device?: DeviceInfo; + webDriverSessionId?: string; + providerSessionId?: string; +}) => Promise; + +export type CloudWebDriverPrepareSession = (params: { + lease: DeviceLease; + req?: DaemonRequest; + 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; + capabilities: CloudWebDriverProviderCapabilities; + webDriverSessionId: string; + providerSessionId: string; +}; + +export function createCloudWebDriverRuntime( + options: CloudWebDriverRuntimeOptions, +): ProviderDeviceRuntime { + 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; + 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, context) => await this.allocate(lease, context?.req), + 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, + req?: DaemonRequest, + ): Promise | undefined> { + if (lease.leaseProvider !== this.provider) return undefined; + 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, + 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; + 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, + }), + }); + return { + provider: this.provider, + deviceId: device.id, + sessionId: session.sessionId, + providerSessionId, + 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); + 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); + if (!query.providerSessionId || !this.options.listArtifacts) return undefined; + return await this.options.listArtifacts({ + provider: this.provider, + providerSessionId: query.providerSessionId, + }); + } + + private async prepareSession( + lease: DeviceLease, + req: DaemonRequest | undefined, + ): Promise { + const base = this.baseSessionForLease(lease); + 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 { + 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: buildCloudWebDriverBaseCapabilities( + this.options.platform, + 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(session.capabilities, 'install')) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + unsupportedCapabilityMessage(session.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, + ): Promise { + const listArtifacts = session.prepared.listArtifacts ?? this.options.listArtifacts; + if (!listArtifacts) return undefined; + try { + return await listArtifacts({ + provider: this.provider, + 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..ba28fdb41 --- /dev/null +++ b/src/cloud-webdriver/webdriver-client.ts @@ -0,0 +1,300 @@ +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; + 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 WebDriverWindowRect = { + x: number; + y: number; + width: number; + height: number; +}; + +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 = { + ...agentDeviceRequestHeaders(), + ...(options.auth ? { Authorization: basicAuthHeader(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 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 }); + } + + 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 sleep(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 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 }; +} + +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'); +} diff --git a/src/cloud-webdriver/webdriver-gestures.ts b/src/cloud-webdriver/webdriver-gestures.ts new file mode 100644 index 000000000..9489c093b --- /dev/null +++ b/src/cloud-webdriver/webdriver-gestures.ts @@ -0,0 +1,48 @@ +import { centerOfRect, type Rect } 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, + rect: Rect, +): { x: number; y: number } { + 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, + rect: Rect, +): { x: number; y: number } { + const start = scrollStart(direction, distance, rect); + 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..45e3bb603 --- /dev/null +++ b/src/cloud-webdriver/webdriver-interactor.ts @@ -0,0 +1,279 @@ +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 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 }; + } + + 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..1f86b7f47 --- /dev/null +++ b/src/cloud-webdriver/webdriver-source.ts @@ -0,0 +1,110 @@ +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[] = []; + 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, + 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 rectFromAttributes(attrs: Record): RawSnapshotNode['rect'] | undefined { + const bounds = parseBounds(attrs.bounds ?? null); + 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 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()); +} diff --git a/src/cloud-webdriver/webdriver-utils.ts b/src/cloud-webdriver/webdriver-utils.ts new file mode 100644 index 000000000..53f526ca0 --- /dev/null +++ b/src/cloud-webdriver/webdriver-utils.ts @@ -0,0 +1,37 @@ +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 { + 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 { + 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 { + if (url.pathname.endsWith('/')) return url; + const copy = new URL(url); + copy.pathname = `${copy.pathname}/`; + return copy; +} 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..4a9f2a088 100644 --- a/src/daemon-runtime.ts +++ b/src/daemon-runtime.ts @@ -5,6 +5,12 @@ 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 { createDefaultCloudWebDriverProviderRuntimes } from './cloud-webdriver/provider-runtimes.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'; @@ -82,6 +88,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, @@ -89,6 +102,10 @@ export async function startDaemonRuntime( token, sessionStore, leaseRegistry, + leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, + cloudArtifactProvider, + deviceInventoryProvider: providerRuntimeProviders.deviceInventoryProvider, + providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, trackDownloadableArtifact, }); @@ -223,6 +240,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. 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/__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/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/lease.ts b/src/daemon/handlers/lease.ts index 301625286..83927577e 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -1,33 +1,67 @@ +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>; - 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 = { 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; try { - providerData = await leaseLifecycleProvider?.allocate?.(lease); + providerData = await leaseLifecycleProvider?.allocate?.(lease, { req }); } catch (error) { leaseRegistry.releaseLease( leaseScopeToReleaseRequest({ @@ -49,7 +83,7 @@ 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..26763bbd5 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); @@ -108,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. - releaseSessionLease({ session, leaseRegistry }); - 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, @@ -122,12 +127,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-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' 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..a324b7446 --- /dev/null +++ b/src/default-cloud-artifact-provider.ts @@ -0,0 +1,15 @@ +import type { CloudArtifactProvider } from './cloud-artifacts.ts'; +import { + listCloudWebDriverArtifactsFromEnv, + type DefaultCloudWebDriverArtifactEnv, +} from './cloud-webdriver/provider-registry.ts'; + +export type DefaultCloudArtifactProviderEnv = DefaultCloudWebDriverArtifactEnv; + +export function createDefaultCloudArtifactProvider( + env: DefaultCloudArtifactProviderEnv = process.env, +): CloudArtifactProvider { + return { + listCloudArtifacts: async (query) => await listCloudWebDriverArtifactsFromEnv(query, env), + }; +} diff --git a/src/index.ts b/src/index.ts index 6e366e0a8..4c4a463e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,20 +4,132 @@ 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, 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/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/provider-device-runtime.ts b/src/provider-device-runtime.ts index 32d5a0fd1..466f0fa1c 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -1,7 +1,12 @@ 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'; +import type { LeaseLifecycleContext, LeaseLifecycleProvider } from './daemon/handlers/lease.ts'; import type { DeviceLease } from './daemon/lease-registry.ts'; import type { DeviceInfo } from './kernel/device.ts'; import { AppError } from './kernel/errors.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,20 +159,51 @@ export function createProviderDeviceRuntimeRequestProviders( ): ProviderDeviceRuntimeRequestProviders { return { leaseLifecycleProvider: composeLeaseProvider(runtimes), + cloudArtifactProvider: composeCloudArtifactProvider(runtimes), deviceInventoryProvider: composeDeviceInventoryProvider(runtimes), providerDeviceRuntimeScope: async (task) => await withProviderDeviceRuntimeScope(runtimes, task), }; } +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 { if (runtimes.length === 0) return undefined; return { - allocate: async (lease) => 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), + }; +} + +function composeCloudArtifactProvider( + runtimes: ProviderDeviceRuntime[], +): CloudArtifactProvider | undefined { + if (runtimes.length === 0) return undefined; + return { + listCloudArtifacts: async (query) => await firstCloudArtifactsResult(runtimes, query), }; } @@ -183,15 +221,28 @@ 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, 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..ea76bebf0 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,19 @@ 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, /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/); assert.match(help, /agent-device proxy --port 4310/); @@ -1640,6 +1715,9 @@ 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, /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/); 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/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-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts new file mode 100644 index 000000000..acd9908df --- /dev/null +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -0,0 +1,554 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http, { + type IncomingHttpHeaders, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import path from 'node:path'; +import { test } from 'vitest'; +import { + createAwsCliDeviceFarmClient, + createAwsDeviceFarmWebDriverRuntime, + getAwsDeviceFarmWebDriverCapabilities, + listAwsDeviceFarmCloudArtifacts, + selectAwsDeviceFarmWebDriverEndpoint, + type AwsDeviceFarmClient, +} 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'; + +type HttpCall = { + method: string; + path: string; + headers: IncomingHttpHeaders; + 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'); + assertAgentDeviceHeaders(server.calls[0]?.headers); + 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'); + assertAgentDeviceHeaders(server.calls[0]?.headers); + }); + }); +}, 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 { + assertCallPathAndHeaders(calls, 0, '/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}`, + }, + }, + }, + }); + 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' }); + 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 { + 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 ?? '/', + headers: req.headers, + ...(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..4864b2e73 --- /dev/null +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -0,0 +1,512 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +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/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'; +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; + headers: IncomingHttpHeaders; + 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); + +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); + +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; + 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: 'scroll', + command: 'scroll', + positionals: ['down'], + flags: { pixels: 200 }, + expectData: { direction: 'down', distance: 200 }, + }, + { + 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', + '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', + ], + ); + 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') }); + 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'); + assert.notEqual(call.headers['x-agent-device-version'], ''); + } +} + +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 ?? '/', + headers: req.headers, + }; + 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 === '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, + { + 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/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 {}; +} diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index ef721c318..ab65a70b6 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -44,6 +44,11 @@ "type": "file", "label": "Remote Proxy" }, + { + "name": "device-clouds", + "type": "file", + "label": "Device Clouds" + }, { "name": "security-trust", "type": "file", diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index cedd383d2..cbd20e704 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -62,7 +62,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 +106,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 +179,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 +339,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..60cb58fa6 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 [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. @@ -955,6 +956,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. 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`. + ## iOS physical-device prerequisites For CLI-discoverable setup guidance, run `agent-device help physical-device`. diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md new file mode 100644 index 000000000..1f631d712 --- /dev/null +++ b/website/docs/docs/device-clouds.md @@ -0,0 +1,259 @@ +--- +title: Device Clouds & Farms +description: Connect agents to BrowserStack device clouds and AWS Device Farm without interactive login. +--- + +# Device Clouds & Farms + +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 ... +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 + +Device cloud 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. + +- 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. + +## 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: + +```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" +``` + +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). + +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. + +Full flow: + +```bash +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 +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 +agent-device artifacts --json +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`. +- 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. 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