diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index ac2e02724..be3306f83 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -204,6 +204,38 @@ test('connect aws-device-farm generates local provider profile from flags', asyn } }); +test('connect roku generates local provider profile from flags', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-roku-')); + const stateDir = path.join(tempRoot, '.state'); + + try { + await connectWithGeneratedProviderProfile({ + stateDir, + positionals: ['roku'], + flags: { + rokuWebDriverUrl: 'http://127.0.0.1:9000', + rokuDeviceIp: '192.168.1.50', + providerApp: 'dev-channel', + }, + }); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.tenant, 'roku'); + assert.equal(state.leaseProvider, 'roku'); + assert.equal(state.daemon?.baseUrl, undefined); + assert.match(state.remoteConfigPath, /generated\/roku-[a-f0-9]{16}\.json$/); + const generated = readGeneratedConfig(state.remoteConfigPath); + assert.equal(generated.platform, 'web'); + assert.equal(generated.target, 'tv'); + assert.equal(generated.leaseBackend, 'web-instance'); + assert.equal(generated.rokuWebDriverUrl, 'http://127.0.0.1:9000'); + assert.equal(generated.rokuDeviceIp, '192.168.1.50'); + assert.equal(generated.providerApp, 'dev-channel'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + function mockCloudConnectionProfile(connection: Record): ReturnType { mockedResolveCloudAccessForConnect.mockResolvedValue({ accessToken: 'adc_agent_cloud', @@ -294,11 +326,16 @@ async function connectWithGeneratedProviderProfile(options: { function readGeneratedConfig(configPath: string): { tenant?: string; leaseProvider?: string; + leaseBackend?: string; clientId?: string; + platform?: string; + target?: string; providerApp?: string; providerOsVersion?: string; providerProject?: string; providerBuild?: string; + rokuWebDriverUrl?: string; + rokuDeviceIp?: string; awsProjectArn?: string; awsDeviceArn?: string; awsAppArn?: string; @@ -308,11 +345,16 @@ function readGeneratedConfig(configPath: string): { return JSON.parse(fs.readFileSync(configPath, 'utf8')) as { tenant?: string; leaseProvider?: string; + leaseBackend?: string; clientId?: string; + platform?: string; + target?: string; providerApp?: string; providerOsVersion?: string; providerProject?: string; providerBuild?: string; + rokuWebDriverUrl?: string; + rokuDeviceIp?: string; awsProjectArn?: string; awsDeviceArn?: string; awsAppArn?: string; diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index e587ea2ea..725e6f75c 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -399,7 +399,7 @@ test('connect proxy rejects remote-config and unknown provider combinations', as }, client: createTestClient(), }), - /Supported providers: cloud, proxy, browserstack, aws-device-farm/, + /Supported providers: cloud, proxy, browserstack, aws-device-farm, roku/, ); fs.rmSync(tempRoot, { recursive: true, force: true }); }); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 64cace126..467947138 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -537,6 +537,7 @@ export function resolveRequestedLeaseBackend(flags: CliFlags): LeaseBackend | un if (flags.leaseBackend) return flags.leaseBackend; if (flags.platform === 'android') return 'android-instance'; if (flags.platform === 'ios') return 'ios-instance'; + if (flags.platform === 'web') return 'web-instance'; return undefined; } @@ -545,7 +546,7 @@ function requireRequestedLeaseBackend(flags: CliFlags, command: string): LeaseBa if (leaseBackend) return leaseBackend; throw new AppError( 'INVALID_ARGS', - `${command} requires --platform ios|android or --lease-backend when the remote connection has not resolved a lease yet.`, + `${command} requires --platform ios|android|web or --lease-backend when the remote connection has not resolved a lease yet.`, ); } @@ -692,6 +693,8 @@ async function allocateOrReuseLease( awsAppArn: flags.awsAppArn, awsRegion: flags.awsRegion, awsInteractionMode: flags.awsInteractionMode, + rokuWebDriverUrl: flags.rokuWebDriverUrl, + rokuDeviceIp: flags.rokuDeviceIp, }); return { lease, acquired: true }; } @@ -777,6 +780,7 @@ function buildProxyDeviceKey(device: DeviceInfo): string { function leaseBackendForDevice(device: DeviceInfo): LeaseBackend | undefined { if (isIosFamily(device)) return 'ios-instance'; if (device.platform === 'android') return 'android-instance'; + if (device.platform === 'web') return 'web-instance'; return undefined; } diff --git a/src/cli/connection/cloud-webdriver-profile.ts b/src/cli/connection/cloud-webdriver-profile.ts index 64a551997..d45980609 100644 --- a/src/cli/connection/cloud-webdriver-profile.ts +++ b/src/cli/connection/cloud-webdriver-profile.ts @@ -60,6 +60,10 @@ const CLOUD_WEBDRIVER_CONNECT_PROFILE_BUILDERS: readonly { provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, buildProfileFields: awsDeviceFarmProfileFields, }, + { + provider: CLOUD_WEBDRIVER_PROVIDERS.roku, + buildProfileFields: rokuProfileFields, + }, ]; function requireConnectProfileBuilder( @@ -139,6 +143,27 @@ function awsDeviceFarmProfileFields(options: { }; } +function rokuProfileFields(options: { flags: CliFlags; env?: EnvMap }): RemoteConfigProfile { + const rokuWebDriverUrl = requireFlag( + readRokuProfileValue(options.flags.rokuWebDriverUrl, options.env, 'ROKU_WEBDRIVER_URL'), + 'connect roku requires --roku-webdriver-url or ROKU_WEBDRIVER_URL.', + ); + const rokuDeviceIp = requireFlag( + readRokuProfileValue(options.flags.rokuDeviceIp, options.env, 'ROKU_DEVICE_IP'), + 'connect roku requires --roku-device-ip or ROKU_DEVICE_IP.', + ); + return { + platform: 'web', + target: 'tv', + leaseBackend: options.flags.leaseBackend ?? 'web-instance', + device: options.flags.device, + providerApp: options.flags.providerApp, + providerSessionName: options.flags.providerSessionName, + rokuWebDriverUrl, + rokuDeviceIp, + }; +} + function requireCloudWebDriverPlatform( platform: PlatformSelector | undefined, message: string, @@ -176,6 +201,14 @@ function readAwsProfileValue( return envNames.map((name) => env?.[name]).find((value): value is string => Boolean(value)); } +function readRokuProfileValue( + flagValue: string | undefined, + env: EnvMap | undefined, + envName: string, +): string | undefined { + return flagValue ?? env?.[envName]; +} + function buildCloudWebDriverClientId( provider: CloudWebDriverKnownProviderName, stateDir: string, diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 6941f5e6e..c5e864259 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -22,6 +22,7 @@ export function connectProviderNamesForError(): string { 'proxy', CLOUD_WEBDRIVER_PROVIDERS.browserStack, CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + CLOUD_WEBDRIVER_PROVIDERS.roku, ].join(', '); } diff --git a/src/cli/parser/__tests__/args-parse-session.test.ts b/src/cli/parser/__tests__/args-parse-session.test.ts index 50fda9ea1..300d463e9 100644 --- a/src/cli/parser/__tests__/args-parse-session.test.ts +++ b/src/cli/parser/__tests__/args-parse-session.test.ts @@ -739,6 +739,27 @@ test('parseArgs recognizes connect aws-device-farm provider flags', () => { assert.equal(parsed.flags.awsInteractionMode, 'INTERACTIVE'); }); +test('parseArgs recognizes connect roku provider flags', () => { + const parsed = parseArgs( + [ + 'connect', + 'roku', + '--roku-webdriver-url', + 'http://127.0.0.1:9000', + '--roku-device-ip', + '192.168.1.50', + '--provider-app', + 'dev-channel', + ], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'connect'); + assert.deepEqual(parsed.positionals, ['roku']); + assert.equal(parsed.flags.rokuWebDriverUrl, 'http://127.0.0.1:9000'); + assert.equal(parsed.flags.rokuDeviceIp, '192.168.1.50'); + assert.equal(parsed.flags.providerApp, 'dev-channel'); +}); + test('parseArgs accepts auth management subcommands', () => { const status = parseArgs(['auth', 'status'], { strictFlags: true }); assert.equal(status.command, 'auth'); diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index 51d29fd27..38fc3691d 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -60,7 +60,7 @@ test('usage includes only global flags in the top-level global flags section', a assert.doesNotMatch(flagsSection, /--lease-id /); assert.doesNotMatch( flagsSection, - /--lease-backend ios-simulator\|ios-instance\|android-instance/, + /--lease-backend ios-simulator\|ios-instance\|android-instance\|web-instance/, ); assert.doesNotMatch(flagsSection, /--relaunch/); assert.doesNotMatch(flagsSection, /--header /); @@ -406,10 +406,18 @@ test('usageForCommand resolves remote help topic', async () => { 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, /Roku: agent-device connect roku/); 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, /connect browserstack\/aws-device-farm\/roku/); + assert.match( + help, + /ROKU_WEBDRIVER_URL=http:\/\/127\.0\.0\.1:9000 ROKU_DEVICE_IP=192\.168\.1\.50/, + ); + assert.match(help, /agent-device tv-remote press down/); + assert.match(help, /Roku does not expose provider artifacts/); 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/); @@ -429,9 +437,12 @@ test('usageForCommand resolves remote help topic', async () => { 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, AWS Device Farm, and Roku 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, + /Generated connection profiles store app\/device selectors, ARNs, and Roku connection hints/, + ); 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/cli/parser/cli-flags.ts b/src/cli/parser/cli-flags.ts index 53076cc0e..828d62d19 100644 --- a/src/cli/parser/cli-flags.ts +++ b/src/cli/parser/cli-flags.ts @@ -323,8 +323,8 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ key: 'leaseBackend', names: ['--lease-backend'], type: 'enum', - enumValues: ['ios-simulator', 'ios-instance', 'android-instance'], - usageLabel: '--lease-backend ios-simulator|ios-instance|android-instance', + enumValues: ['ios-simulator', 'ios-instance', 'android-instance', 'web-instance'], + usageLabel: '--lease-backend ios-simulator|ios-instance|android-instance|web-instance', usageDescription: 'Lease backend for remote tenant connection admission', }, { @@ -413,6 +413,20 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--aws-interaction-mode INTERACTIVE|NO_VIDEO|VIDEO_ONLY', usageDescription: 'AWS Device Farm remote access interaction mode', }, + { + key: 'rokuWebDriverUrl', + names: ['--roku-webdriver-url'], + type: 'string', + usageLabel: '--roku-webdriver-url ', + usageDescription: 'Roku WebDriver server URL for Roku provider sessions', + }, + { + key: 'rokuDeviceIp', + names: ['--roku-device-ip'], + type: 'string', + usageLabel: '--roku-device-ip ', + usageDescription: 'LAN IP address of the Roku device controlled by Roku WebDriver', + }, { key: 'force', names: ['--force'], @@ -1305,6 +1319,8 @@ export const COMMON_COMMAND_SUPPORTED_FLAG_KEYS = flagKeys( 'awsAppArn', 'awsRegion', 'awsInteractionMode', + 'rokuWebDriverUrl', + 'rokuDeviceIp', 'udid', 'serial', 'iosSimulatorDeviceSet', diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 8562621a2..a0c198588 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -801,9 +801,10 @@ Providers: 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. + Roku: agent-device connect roku stores a local provider profile for a LAN Roku device controlled through Roku WebDriver/ECP. Device cloud interfaces: - CLI is the canonical bootstrap path: connect browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. + CLI is the canonical bootstrap path: connect browserstack/aws-device-farm/roku, then use normal open/snapshot/close/disconnect commands plus provider-supported interactions. 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. @@ -844,6 +845,16 @@ AWS Device Farm hosted-device flow: agent-device artifacts --json agent-device disconnect +Roku TV flow: + ROKU_WEBDRIVER_URL=http://127.0.0.1:9000 ROKU_DEVICE_IP=192.168.1.50 + agent-device connect roku --provider-app + agent-device open + agent-device snapshot -i + agent-device tv-remote press down + agent-device tv-remote press select + agent-device close + agent-device disconnect + Local profile flow: agent-device connect --remote-config ./remote-config.json agent-device open com.example.app @@ -860,10 +871,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. + Use BrowserStack, AWS Device Farm, and Roku 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. Roku uses ROKU_WEBDRIVER_URL and ROKU_DEVICE_IP or the matching connect flags. + Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors, ARNs, and Roku connection hints, not BrowserStack access keys or AWS credentials. + After closing a BrowserStack or AWS Device Farm session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. Roku does not expose provider artifacts through agent-device. 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/client/client-normalizers.ts b/src/client/client-normalizers.ts index f9000dbcb..2d4394c96 100644 --- a/src/client/client-normalizers.ts +++ b/src/client/client-normalizers.ts @@ -296,6 +296,8 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { awsAppArn: options.awsAppArn, awsRegion: options.awsRegion, awsInteractionMode: options.awsInteractionMode, + rokuWebDriverUrl: options.rokuWebDriverUrl, + rokuDeviceIp: options.rokuDeviceIp, sessionIsolation: options.sessionIsolation, platform: options.platform, target: options.target, diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 51955d2aa..a76c2ec39 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -128,6 +128,8 @@ export type AgentDeviceRequestOverrides = Pick< | 'awsAppArn' | 'awsRegion' | 'awsInteractionMode' + | 'rokuWebDriverUrl' + | 'rokuDeviceIp' | 'leaseTtlMs' | 'cwd' | 'debug' diff --git a/src/cloud-webdriver/provider-definitions.ts b/src/cloud-webdriver/provider-definitions.ts index 4884f0160..85a96a4ff 100644 --- a/src/cloud-webdriver/provider-definitions.ts +++ b/src/cloud-webdriver/provider-definitions.ts @@ -20,6 +20,7 @@ import { uploadBrowserStackApp, } from './browserstack.ts'; import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts'; +import { createRokuWebDriverRuntime } from './roku.ts'; import { buildCloudWebDriverBaseCapabilities, createCloudWebDriverRuntime, @@ -43,6 +44,8 @@ export type DefaultCloudWebDriverProviderRuntimeEnv = DefaultCloudWebDriverArtif AWS_DEVICE_FARM_DEVICE_ARN?: string; AGENT_DEVICE_AWS_DEVICE_FARM_APP_ARN?: string; AWS_DEVICE_FARM_APP_ARN?: string; + ROKU_WEBDRIVER_URL?: string; + ROKU_DEVICE_IP?: string; }; export type CloudWebDriverProviderDefinition = { @@ -204,6 +207,15 @@ export const CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS: readonly CloudWebDriverProvid ); }, }, + { + provider: CLOUD_WEBDRIVER_PROVIDERS.roku, + createRuntime: (env) => + createRokuWebDriverRuntime({ + endpoint: env.ROKU_WEBDRIVER_URL, + deviceIp: env.ROKU_DEVICE_IP, + }), + listArtifactsFromEnv: async () => undefined, + }, ]; export function findCloudWebDriverProviderDefinition( diff --git a/src/cloud-webdriver/providers.ts b/src/cloud-webdriver/providers.ts index 814a0c19a..c84b910c1 100644 --- a/src/cloud-webdriver/providers.ts +++ b/src/cloud-webdriver/providers.ts @@ -1,6 +1,7 @@ export const CLOUD_WEBDRIVER_PROVIDERS = { browserStack: 'browserstack', awsDeviceFarm: 'aws-device-farm', + roku: 'roku', } as const; export type CloudWebDriverKnownProviderName = diff --git a/src/cloud-webdriver/roku.ts b/src/cloud-webdriver/roku.ts new file mode 100644 index 000000000..4a9c850d7 --- /dev/null +++ b/src/cloud-webdriver/roku.ts @@ -0,0 +1,609 @@ +import type { CloudArtifactProvider } from '../cloud-artifacts.ts'; +import type { DeviceInventoryProvider, DeviceInventoryRequest } from '../core/dispatch-resolve.ts'; +import type { Interactor, ScreenshotOptions, SnapshotOptions } from '../core/interactor-types.ts'; +import type { BackMode } from '../core/back-mode.ts'; +import type { DeviceRotation } from '../core/device-rotation.ts'; +import type { TransformGestureParams } from '../core/scroll-gesture.ts'; +import type { TvRemoteButton } from '../core/tv-remote.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 { ProviderDeviceRuntime } from '../provider-device-runtime.ts'; +import type { DeviceInfo } from '../kernel/device.ts'; +import { AppError } from '../kernel/errors.ts'; +import type { SettingOptions } from '../platforms/permission-utils.ts'; +import { sleep } from '../utils/timeouts.ts'; +import { unavailableCloudArtifactsResult } from './artifact-results.ts'; +import { agentDeviceRequestHeaders } from './request-headers.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; +import { parseWebDriverSource } from './webdriver-source.ts'; +import { trimLeadingSlash, withTrailingSlash } from './webdriver-utils.ts'; + +export const ROKU_CAPABILITIES = { + provider: CLOUD_WEBDRIVER_PROVIDERS.roku, + platform: 'web', + target: 'tv', + snapshotSource: 'roku-webdriver-source', + operations: { + lease: { support: 'supported' }, + inventory: { + support: 'partial', + note: 'Inventory exposes only the leased Roku device.', + }, + open: { support: 'supported' }, + close: { + support: 'partial', + note: 'Uses Roku ECP exit-app when the device permits it.', + }, + snapshot: { + support: 'partial', + note: 'Decodes Roku WebDriver base64 page source XML.', + }, + tvRemote: { + support: 'supported', + note: 'Uses Roku ECP keypress/keydown/keyup remote input.', + }, + screenshot: { + support: 'unsupported', + note: 'Roku WebDriver does not document a screenshot endpoint.', + }, + install: { + support: 'unsupported', + note: 'Roku store install and sideload flows need dedicated package handling.', + }, + }, +} as const; + +export type RokuWebDriverRuntimeOptions = { + endpoint?: string | URL; + ecpEndpoint?: string | URL; + deviceIp?: string; + deviceName?: string; + channelId?: string; + requestPolicy?: RokuWebDriverRequestPolicy; +}; + +type RokuWebDriverRequestPolicy = { + timeoutMs?: number; +}; + +type RokuSession = { + sessionId: string; + endpoint: string | URL; + deviceIp: string; + deviceName: string; + channelId?: string; + client: RokuWebDriverClient; + interactor: Interactor; + device: DeviceInfo; +}; + +type RokuCreateSessionResult = { + sessionId: string; + deviceName?: string; + raw: Record; +}; + +export function createRokuWebDriverRuntime( + options: RokuWebDriverRuntimeOptions = {}, +): ProviderDeviceRuntime { + return new RokuWebDriverRuntime(options); +} + +class RokuWebDriverRuntime implements ProviderDeviceRuntime { + readonly provider = CLOUD_WEBDRIVER_PROVIDERS.roku; + readonly leaseLifecycle: LeaseLifecycleProvider; + readonly cloudArtifacts: CloudArtifactProvider; + readonly deviceInventoryProvider: DeviceInventoryProvider; + + private readonly options: RokuWebDriverRuntimeOptions; + private readonly sessionsByLeaseId = new Map(); + + constructor(options: RokuWebDriverRuntimeOptions) { + this.options = options; + 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) => { + if (query.provider !== this.provider) return undefined; + return unavailableCloudArtifactsResult({ + provider: this.provider, + providerSessionId: query.providerSessionId ?? '', + error: new AppError( + 'UNSUPPORTED_OPERATION', + 'Roku provider does not expose cloud artifacts.', + ), + }); + }, + }; + this.deviceInventoryProvider = async (request) => { + if (request.leaseProvider !== this.provider) return null; + if (!request.leaseId) { + const settings = resolveRokuSettings(this.options, request); + return [createRokuDevice(settings.deviceIp, settings.deviceName)]; + } + 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 shutdown(): Promise { + await Promise.allSettled( + [...this.sessionsByLeaseId.values()].map((session) => session.client.deleteSession()), + ); + 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 settings = resolveRokuSettings(this.options, req); + const client = new RokuWebDriverClient({ + endpoint: settings.endpoint, + ecpEndpoint: settings.ecpEndpoint, + deviceIp: settings.deviceIp, + requestPolicy: this.options.requestPolicy, + }); + const created = await client.createSession(); + const deviceName = settings.deviceName ?? created.deviceName; + const device = createRokuDevice(settings.deviceIp, deviceName); + const session: RokuSession = { + sessionId: created.sessionId, + endpoint: settings.endpoint, + deviceIp: settings.deviceIp, + deviceName: device.name, + channelId: settings.channelId, + client, + device, + interactor: new RokuInteractor(client, settings.channelId), + }; + this.sessionsByLeaseId.set(lease.leaseId, session); + return { + provider: this.provider, + sessionId: created.sessionId, + providerSessionId: created.sessionId, + deviceId: device.id, + capabilities: ROKU_CAPABILITIES, + roku: { + endpoint: String(settings.endpoint), + deviceIp: settings.deviceIp, + channelId: settings.channelId, + session: created.raw, + }, + }; + } + + 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); + await session.client.deleteSession(); + return { + provider: this.provider, + providerSessionId: session.sessionId, + }; + } +} + +class RokuInteractor implements Interactor { + private activeChannelId: string | undefined; + private readonly client: RokuWebDriverClient; + private readonly defaultChannelId: string | undefined; + + constructor(client: RokuWebDriverClient, defaultChannelId: string | undefined) { + this.client = client; + this.defaultChannelId = defaultChannelId; + } + + async open(app: string, options?: { url?: string }): Promise { + if (options?.url) { + throw unsupportedRokuOperation('open URL'); + } + const channelId = this.resolveChannelId(app); + await this.client.launch(channelId); + this.activeChannelId = channelId; + } + + async openDevice(): Promise { + const channelId = this.resolveChannelId(undefined); + await this.client.launch(channelId); + this.activeChannelId = channelId; + } + + async close(app: string): Promise { + const channelId = app || this.activeChannelId || this.defaultChannelId; + if (!channelId) return; + await this.client.exitApp(channelId); + } + + async tap(): Promise | void> { + throw unsupportedRokuOperation('tap'); + } + + async doubleTap(): Promise | void> { + throw unsupportedRokuOperation('doubleTap'); + } + + async swipe(): Promise | void> { + throw unsupportedRokuOperation('swipe'); + } + + async pan(): Promise | void> { + throw unsupportedRokuOperation('pan'); + } + + async fling(): Promise | void> { + throw unsupportedRokuOperation('fling'); + } + + async longPress(): Promise | void> { + throw unsupportedRokuOperation('longPress'); + } + + async focus(): Promise | void> { + throw unsupportedRokuOperation('focus'); + } + + async type(): Promise { + throw unsupportedRokuOperation('type'); + } + + async fill(): Promise | void> { + throw unsupportedRokuOperation('fill'); + } + + async scroll(): Promise | void> { + throw unsupportedRokuOperation('scroll'); + } + + async pinch(): Promise | void> { + throw unsupportedRokuOperation('pinch'); + } + + async screenshot(_outPath: string, _options?: ScreenshotOptions): Promise { + throw unsupportedRokuOperation('screenshot'); + } + + async snapshot(_options?: SnapshotOptions) { + return { + backend: 'web' as const, + nodes: parseWebDriverSource(await this.client.source()), + }; + } + + async back(_mode?: BackMode): Promise { + await this.tvRemote('back'); + } + + async home(): Promise { + await this.tvRemote('home'); + } + + async rotate(_orientation: DeviceRotation): Promise { + throw unsupportedRokuOperation('rotate'); + } + + async rotateGesture(): Promise | void> { + throw unsupportedRokuOperation('rotateGesture'); + } + + async transformGesture( + _options: TransformGestureParams, + ): Promise | void> { + throw unsupportedRokuOperation('transformGesture'); + } + + async appSwitcher(): Promise { + throw unsupportedRokuOperation('appSwitcher'); + } + + async tvRemote(button: TvRemoteButton, durationMs?: number): Promise { + await this.client.pressRemote(button, durationMs); + } + + async readClipboard(): Promise { + throw unsupportedRokuOperation('clipboard.read'); + } + + async writeClipboard(_text: string): Promise { + throw unsupportedRokuOperation('clipboard.write'); + } + + async setSetting( + _setting: string, + _state: string, + _appId?: string, + _options?: SettingOptions, + ): Promise | void> { + throw unsupportedRokuOperation('settings'); + } + + private resolveChannelId(app: string | undefined): string { + const channelId = app || this.defaultChannelId; + if (channelId) return channelId; + throw new AppError( + 'INVALID_ARGS', + 'Roku open requires a channel id. Pass open or configure --provider-app.', + ); + } +} + +class RokuWebDriverClient { + private readonly endpoint: URL; + private readonly options: { + endpoint: string | URL; + ecpEndpoint?: string | URL; + deviceIp: string; + requestPolicy?: RokuWebDriverRequestPolicy; + }; + private readonly timeoutMs: number; + private sessionId: string | undefined; + + constructor(options: { + endpoint: string | URL; + ecpEndpoint?: string | URL; + deviceIp: string; + requestPolicy?: RokuWebDriverRequestPolicy; + }) { + this.options = options; + this.endpoint = withTrailingSlash(new URL(options.endpoint)); + this.timeoutMs = options.requestPolicy?.timeoutMs ?? 30_000; + } + + async createSession(): Promise { + const value = await this.webDriverRequest('POST', '/v1/session', { + ip: this.options.deviceIp, + }); + const session = readRokuSession(value); + this.sessionId = session.sessionId; + return session; + } + + async deleteSession(): Promise { + const sessionId = this.requireSessionId(); + await this.webDriverRequest('DELETE', `/v1/session/${sessionId}`); + this.sessionId = undefined; + } + + async launch(channelId: string): Promise { + await this.sessionRequest('POST', '/launch', { channelId }); + } + + async source(): Promise { + const value = await this.sessionRequest('GET', '/source'); + const encoded = typeof value === 'string' ? value : undefined; + if (!encoded) { + throw new AppError('COMMAND_FAILED', 'Roku WebDriver source response missed base64 value.', { + valueType: typeof value, + }); + } + return Buffer.from(encoded, 'base64').toString('utf8'); + } + + async pressRemote(button: TvRemoteButton, durationMs: number | undefined): Promise { + const key = rokuKeyForButton(button); + if (durationMs && durationMs > 0) { + await this.ecpRequest('POST', `/keydown/${key}`); + await sleep(durationMs); + await this.ecpRequest('POST', `/keyup/${key}`); + return; + } + await this.ecpRequest('POST', `/keypress/${key}`); + } + + async exitApp(channelId: string): Promise { + await this.ecpRequest('POST', `/exit-app/${encodeURIComponent(channelId)}/true`); + } + + private async sessionRequest( + method: string, + pathSuffix: string, + body?: unknown, + ): Promise { + return await this.webDriverRequest( + method, + `/v1/session/${this.requireSessionId()}${pathSuffix}`, + body, + ); + } + + private async webDriverRequest(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' }), + ...agentDeviceRequestHeaders(), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(this.timeoutMs), + }); + const text = await response.text(); + const payload = text ? parseJsonResponse(text) : {}; + if (!response.ok) throw rokuRequestError('Roku WebDriver request failed', response, payload); + return readRokuValue(payload); + } + + private async ecpRequest(method: string, path: string): Promise { + const response = await fetch(new URL(trimLeadingSlash(path), this.ecpBaseUrl()), { + method, + headers: agentDeviceRequestHeaders(), + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (!response.ok) { + const text = await response.text(); + throw new AppError( + 'COMMAND_FAILED', + `Roku ECP request failed with HTTP ${response.status}.`, + { + status: response.status, + body: text, + }, + ); + } + } + + private ecpBaseUrl(): URL { + if (this.options.ecpEndpoint) return withTrailingSlash(new URL(this.options.ecpEndpoint)); + return new URL(`http://${this.options.deviceIp}:8060/`); + } + + private requireSessionId(): string { + if (this.sessionId) return this.sessionId; + throw new AppError('SESSION_NOT_FOUND', 'Roku WebDriver session has not been created yet.'); + } +} + +function resolveRokuSettings( + options: RokuWebDriverRuntimeOptions, + req: DaemonRequest | DeviceInventoryRequest | undefined, +): { + endpoint: string | URL; + ecpEndpoint?: string | URL; + deviceIp: string; + deviceName?: string; + channelId?: string; +} { + const endpoint = readFlag(req, 'rokuWebDriverUrl') ?? options.endpoint; + const deviceIp = readFlag(req, 'rokuDeviceIp') ?? options.deviceIp; + if (!endpoint) { + throw new AppError( + 'INVALID_ARGS', + 'Roku provider requires --roku-webdriver-url or ROKU_WEBDRIVER_URL.', + ); + } + if (!deviceIp) { + throw new AppError( + 'INVALID_ARGS', + 'Roku provider requires --roku-device-ip or ROKU_DEVICE_IP.', + ); + } + return { + endpoint, + ecpEndpoint: options.ecpEndpoint, + deviceIp, + deviceName: readFlag(req, 'device') ?? readFlag(req, 'deviceName') ?? options.deviceName, + channelId: readFlag(req, 'providerApp') ?? options.channelId, + }; +} + +function readFlag( + req: DaemonRequest | DeviceInventoryRequest | undefined, + key: string, +): string | undefined { + const source = req && 'flags' in req ? req.flags : req; + const record = source as Record | undefined; + const value = record?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function createRokuDevice(deviceIp: string, deviceName: string | undefined): DeviceInfo { + return { + platform: 'web', + target: 'tv', + kind: 'device', + id: `roku:${deviceIp}`, + name: deviceName ?? `Roku ${deviceIp}`, + booted: true, + }; +} + +function readRokuSession(value: unknown): RokuCreateSessionResult { + if (!value || typeof value !== 'object') { + throw new AppError('COMMAND_FAILED', 'Roku WebDriver session response was not an object.', { + response: value, + }); + } + const record = value as Record; + const sessionId = + readString(record.sessionId) ?? + readString(record.session_id) ?? + readString(record.adId) ?? + readString(record.deviceAdId); + if (!sessionId) { + throw new AppError('COMMAND_FAILED', 'Roku WebDriver session response missed sessionId.', { + response: value, + }); + } + const model = readString(record.model); + const vendor = readString(record.vendor); + const deviceName = [vendor, model].filter(Boolean).join(' ') || undefined; + return { sessionId, deviceName, raw: record }; +} + +function readRokuValue(payload: unknown): unknown { + if (!payload || typeof payload !== 'object') return payload; + const record = payload as Record; + return 'value' in record ? record.value : payload; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function parseJsonResponse(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch (error) { + throw new AppError('COMMAND_FAILED', 'Roku response was not valid JSON.', { text }, error); + } +} + +function rokuRequestError(message: string, response: Response, payload: unknown): AppError { + return new AppError('COMMAND_FAILED', `${message} with HTTP ${response.status}.`, { + status: response.status, + response: payload, + }); +} + +function unsupportedRokuOperation(operation: string): AppError { + return new AppError( + 'UNSUPPORTED_OPERATION', + `Roku WebDriver runtime does not support ${operation}.`, + { provider: CLOUD_WEBDRIVER_PROVIDERS.roku, operation }, + ); +} + +function rokuKeyForButton(button: TvRemoteButton): string { + switch (button) { + case 'up': + return 'Up'; + case 'down': + return 'Down'; + case 'left': + return 'Left'; + case 'right': + return 'Right'; + case 'select': + return 'Select'; + case 'menu': + return 'Info'; + case 'home': + return 'Home'; + case 'back': + return 'Back'; + } +} diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index fafa78ef1..38c67bc67 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -59,6 +59,15 @@ const linuxDevice: DeviceInfo = { const webDevice = WEB_DESKTOP_DEVICE; +const webTvDevice: DeviceInfo = { + platform: 'web', + id: 'roku:127.0.0.1', + name: 'Roku', + kind: 'device', + target: 'tv', + booted: true, +}; + const tvOsSimulator: DeviceInfo = { platform: 'apple', id: 'tv-sim-1', @@ -461,6 +470,33 @@ test('web supports only the initial browser interaction slice', () => { ); }); +test('web TV exposes only Roku-supported commands', () => { + assertCommandSupport( + ['close', 'find', 'get', 'is', 'open', 'snapshot', 'tv-remote', 'wait'], + [{ device: webTvDevice, expected: true, label: 'on Roku web TV' }], + ); + assertCommandSupport( + [ + 'audio', + 'click', + 'fill', + 'focus', + 'network', + 'press', + 'record', + 'screenshot', + 'scroll', + 'type', + 'viewport', + ], + [{ device: webTvDevice, expected: false, label: 'on Roku web TV' }], + ); + assert.match( + unsupportedHintForDevice('click', webTvDevice) ?? '', + /click is not supported on Roku WebDriver TV targets/, + ); +}); + test('audio probe support is limited to browser and host-rendered audio targets', () => { const hostAudioSupported = process.platform === 'darwin'; assertCommandSupport( diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index f8b3c7357..d140c93c4 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -174,6 +174,41 @@ const HINT_REF: Record string | undefined> = { 'rotate-gesture': synthesisGestureUnsupportedHint, 'transform-gesture': synthesisGestureUnsupportedHint, }; +const WEB_TV_SUPPORTED_COMMANDS_REF = new Set([ + 'open', + 'close', + 'find', + 'get', + 'is', + 'snapshot', + 'wait', + 'tv-remote', +]); +const WEB_TARGET_GATED_COMMANDS_REF = [ + 'audio', + 'click', + 'fill', + 'focus', + 'network', + 'press', + 'record', + 'screenshot', + 'scroll', + 'type', + 'viewport', + 'tv-remote', +]; +const supportsWebCommandByTargetRef = (command: string, device: DeviceInfo): boolean => + device.target === 'tv' ? WEB_TV_SUPPORTED_COMMANDS_REF.has(command) : command !== 'tv-remote'; +const webUnsupportedHintByTargetRef = (command: string, device: DeviceInfo): string | undefined => { + if (!WEB_TARGET_GATED_COMMANDS_REF.includes(command)) return undefined; + if (command === 'tv-remote') { + return device.target === 'tv' ? undefined : 'tv-remote is supported only on TV targets.'; + } + return device.target === 'tv' + ? `${command} is not supported on Roku WebDriver TV targets.` + : undefined; +}; // Independent reference for `isCommandSupportedOnDevice` over NON-WEB platforms, // reproducing the BEFORE pipeline exactly: descriptor-fold bucket selection (b.1 @@ -240,7 +275,11 @@ test('(b.2) unsupportedHint closures are verbatim across the full device matrix' for (const device of SAMPLE_DEVICES) { assert.equal( unsupportedHintForDevice(command, device), - reference ? reference(device) : undefined, + device.platform === 'web' + ? webUnsupportedHintByTargetRef(command, device) + : reference + ? reference(device) + : undefined, `${command} hint on ${device.id}`, ); } @@ -299,13 +338,41 @@ test('(b.2) non-Apple families only carry their own non-portable support gates', assert.deepEqual(Object.keys(getPlugin('android').capability.unsupportedHintByDefault ?? {}), [ 'tv-remote', ]); - for (const platform of ['linux', 'web'] as const) { - const capability = getPlugin(platform).capability; - assert.equal(capability.supportsByDefault, undefined, `${platform} has no supportsByDefault`); - assert.equal( - capability.unsupportedHintByDefault, - undefined, - `${platform} has no unsupportedHintByDefault`, - ); + const linuxCapability = getPlugin('linux').capability; + assert.equal(linuxCapability.supportsByDefault, undefined, 'linux has no supportsByDefault'); + assert.equal( + linuxCapability.unsupportedHintByDefault, + undefined, + 'linux has no unsupportedHintByDefault', + ); + + const webCapability = getPlugin('web').capability; + assert.deepEqual( + Object.keys(webCapability.supportsByDefault ?? {}).sort(), + [...WEB_TARGET_GATED_COMMANDS_REF].sort(), + 'web support gates are limited to target-sensitive web commands', + ); + assert.deepEqual( + Object.keys(webCapability.unsupportedHintByDefault ?? {}).sort(), + [...WEB_TARGET_GATED_COMMANDS_REF].sort(), + 'web hint gates are limited to target-sensitive web commands', + ); + for (const command of WEB_TARGET_GATED_COMMANDS_REF) { + const supports = webCapability.supportsByDefault?.[command]; + const hint = webCapability.unsupportedHintByDefault?.[command]; + assert.ok(supports, `${command} web supports closure present`); + assert.ok(hint, `${command} web hint closure present`); + for (const device of SAMPLE_DEVICES.filter((sample) => sample.platform === 'web')) { + assert.equal( + supports(device), + supportsWebCommandByTargetRef(command, device), + `${command} web supports on ${device.id}`, + ); + assert.equal( + hint(device), + webUnsupportedHintByTargetRef(command, device), + `${command} web hint on ${device.id}`, + ); + } } }); diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 9bbf85055..bdccfccde 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -41,12 +41,14 @@ const WEB_QUERY_COMMANDS = [ ] as const; const WEB_INTERACTION_COMMANDS = ['click', 'fill', 'focus', 'press', 'scroll', 'type'] as const; const WEB_SETTING_COMMANDS = ['viewport'] as const; +const WEB_TV_COMMANDS = ['tv-remote'] as const; const WEB_SUPPORTED_COMMANDS = new Set([ ...WEB_RUNTIME_COMMANDS, ...WEB_RECORDING_COMMANDS, ...WEB_QUERY_COMMANDS, ...WEB_INTERACTION_COMMANDS, ...WEB_SETTING_COMMANDS, + ...WEB_TV_COMMANDS, ]); // Built from the additive command-descriptor registry (ADR-0008, Phase 1 step 3). // The hand-authored literal was deleted after #906 proved deriveCapabilityMatrix is diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index f0cc62cf2..25c2d9db1 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -25,6 +25,8 @@ export type ResolveDeviceFlags = Pick< | 'leaseId' | 'iosSimulatorDeviceSet' | 'androidDeviceAllowlist' + | 'rokuWebDriverUrl' + | 'rokuDeviceIp' > & { leaseProvider?: string; deviceKey?: string; @@ -205,6 +207,8 @@ export function buildDeviceInventoryRequestFromFlags( leaseProvider: flags.leaseProvider, deviceKey: flags.deviceKey, clientId: flags.clientId, + rokuWebDriverUrl: flags.rokuWebDriverUrl, + rokuDeviceIp: flags.rokuDeviceIp, iosSimulatorSetPath, androidSerialAllowlist: androidSerialAllowlist ? Array.from(androidSerialAllowlist).sort() diff --git a/src/core/interactors/register-builtins.ts b/src/core/interactors/register-builtins.ts index dcd373f10..a1c8af156 100644 --- a/src/core/interactors/register-builtins.ts +++ b/src/core/interactors/register-builtins.ts @@ -78,10 +78,63 @@ const linuxPlugin = { }, } as const satisfies PlatformPlugin; +const WEB_TV_SUPPORTED_COMMANDS = new Set([ + PUBLIC_COMMANDS.open, + PUBLIC_COMMANDS.close, + PUBLIC_COMMANDS.find, + PUBLIC_COMMANDS.get, + PUBLIC_COMMANDS.is, + PUBLIC_COMMANDS.snapshot, + PUBLIC_COMMANDS.wait, + PUBLIC_COMMANDS.tvRemote, +]); + +const WEB_TARGET_GATED_COMMANDS = [ + PUBLIC_COMMANDS.audio, + PUBLIC_COMMANDS.click, + PUBLIC_COMMANDS.fill, + PUBLIC_COMMANDS.focus, + PUBLIC_COMMANDS.network, + PUBLIC_COMMANDS.press, + PUBLIC_COMMANDS.record, + PUBLIC_COMMANDS.screenshot, + PUBLIC_COMMANDS.scroll, + PUBLIC_COMMANDS.type, + PUBLIC_COMMANDS.viewport, + PUBLIC_COMMANDS.tvRemote, +] as const; +type WebTargetGatedCommand = (typeof WEB_TARGET_GATED_COMMANDS)[number]; + +const supportsWebCommandByTarget: Record boolean> = + {} as Record boolean>; +for (const command of WEB_TARGET_GATED_COMMANDS) { + supportsWebCommandByTarget[command] = (device) => + device.target === 'tv' ? WEB_TV_SUPPORTED_COMMANDS.has(command) : command !== 'tv-remote'; +} + +const webUnsupportedHintByTarget: Record< + WebTargetGatedCommand, + (device: DeviceInfo) => string | undefined +> = {} as Record string | undefined>; +for (const command of WEB_TARGET_GATED_COMMANDS) { + webUnsupportedHintByTarget[command] = (device) => { + if (command === PUBLIC_COMMANDS.tvRemote) { + return device.target === 'tv' ? undefined : 'tv-remote is supported only on TV targets.'; + } + return device.target === 'tv' + ? `${command} is not supported on Roku WebDriver TV targets.` + : undefined; + }; +} + const webPlugin = { id: 'web', platforms: ['web'], - capability: { bucket: 'web' }, + capability: { + bucket: 'web', + supportsByDefault: supportsWebCommandByTarget, + unsupportedHintByDefault: webUnsupportedHintByTarget, + }, // Wraps the web arm of `resolveRecordingBackendForDevice`: the web device resolves to // the web (agent-browser) recording backend. recording: { resolveBackendTag: () => 'web' }, diff --git a/src/core/platform-inventory.ts b/src/core/platform-inventory.ts index a4dca3745..961e834f4 100644 --- a/src/core/platform-inventory.ts +++ b/src/core/platform-inventory.ts @@ -18,6 +18,8 @@ export type DeviceInventoryRequest = { leaseProvider?: string; deviceKey?: string; clientId?: string; + rokuWebDriverUrl?: string; + rokuDeviceIp?: string; iosSimulatorSetPath?: string; androidSerialAllowlist?: string[]; }; diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index b41afd603..0b8238fd1 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -119,7 +119,9 @@ function normalizeLeaseId(raw: string | undefined): string | undefined { function normalizeLeaseBackend(raw: string | undefined): LeaseBackend { const value = (raw ?? '').trim().toLowerCase(); if (!value || value === 'ios-simulator') return 'ios-simulator'; - if (value === 'ios-instance' || value === 'android-instance') return value; + if (value === 'ios-instance' || value === 'android-instance' || value === 'web-instance') { + return value; + } throw new AppError('INVALID_ARGS', `Unsupported lease backend: ${raw ?? ''}`); } diff --git a/src/kernel/contracts.ts b/src/kernel/contracts.ts index fe90603dd..b496d13a2 100644 --- a/src/kernel/contracts.ts +++ b/src/kernel/contracts.ts @@ -47,7 +47,12 @@ export type DaemonInstallSource = const DAEMON_LOCK_POLICIES = ['reject', 'strip'] as const; export type DaemonLockPolicy = (typeof DAEMON_LOCK_POLICIES)[number]; -const LEASE_BACKENDS = ['ios-simulator', 'ios-instance', 'android-instance'] as const; +const LEASE_BACKENDS = [ + 'ios-simulator', + 'ios-instance', + 'android-instance', + 'web-instance', +] as const; export type LeaseBackend = (typeof LEASE_BACKENDS)[number]; const DAEMON_SERVER_MODES = ['socket', 'http', 'dual'] as const; export type DaemonServerMode = (typeof DAEMON_SERVER_MODES)[number]; diff --git a/src/remote/remote-config-schema.ts b/src/remote/remote-config-schema.ts index b7c1a7170..be7345cf7 100644 --- a/src/remote/remote-config-schema.ts +++ b/src/remote/remote-config-schema.ts @@ -51,6 +51,8 @@ export type CloudProviderProfileFields = { awsAppArn?: string; awsRegion?: string; awsInteractionMode?: 'INTERACTIVE' | 'NO_VIDEO' | 'VIDEO_ONLY'; + rokuWebDriverUrl?: string; + rokuDeviceIp?: string; }; export type RemoteConfigProfile = RemoteConfigMetroOptions & @@ -100,7 +102,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'leaseBackend', type: 'enum', - enumValues: ['ios-simulator', 'ios-instance', 'android-instance'], + enumValues: ['ios-simulator', 'ios-instance', 'android-instance', 'web-instance'], }, { key: 'platform', type: 'enum', enumValues: PLATFORM_SELECTORS }, { key: 'target', type: 'enum', enumValues: ['mobile', 'tv', 'desktop'] }, @@ -129,6 +131,8 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ type: 'enum', enumValues: ['INTERACTIVE', 'NO_VIDEO', 'VIDEO_ONLY'], }, + { key: 'rokuWebDriverUrl', type: 'string' }, + { key: 'rokuDeviceIp', type: 'string' }, { key: 'metroProjectRoot', type: 'string', path: true }, { key: 'metroKind', type: 'enum', enumValues: ['auto', 'react-native', 'expo', 'repack'] }, { key: 'metroPublicBaseUrl', type: 'string' }, diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 85341436c..59a88098a 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 [cloud|proxy|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', + 'connect [cloud|proxy|browserstack|aws-device-farm|roku] [--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', @@ -54,6 +54,8 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'awsAppArn', 'awsRegion', 'awsInteractionMode', + 'rokuWebDriverUrl', + 'rokuDeviceIp', 'force', 'noLogin', ], diff --git a/test/integration/provider-scenarios/roku-webdriver-runtime.test.ts b/test/integration/provider-scenarios/roku-webdriver-runtime.test.ts new file mode 100644 index 000000000..f195092bb --- /dev/null +++ b/test/integration/provider-scenarios/roku-webdriver-runtime.test.ts @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import type { ServerResponse } from 'node:http'; +import { test } from 'vitest'; +import { CLOUD_WEBDRIVER_PROVIDERS } from '../../../src/cloud-webdriver/providers.ts'; +import { createRokuWebDriverRuntime } from '../../../src/cloud-webdriver/roku.ts'; +import type { DeviceLease } from '../../../src/daemon/lease-registry.ts'; +import type { DaemonRequest } from '../../../src/daemon/types.ts'; +import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; +import { assertRpcOk } from './assertions.ts'; +import { + CloudWebDriverTestServer, + type CloudWebDriverHttpCall, + startCloudWebDriverTestServer, + type StartedCloudWebDriverTestServer, + writeCloudWebDriverTestJson, +} from './cloud-webdriver-test-server.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; +import { runProviderScenario } from './scenario.ts'; + +const ROKU_DEVICE_IP = '127.0.0.1'; +const ROKU_CHANNEL_ID = 'dev-channel'; +const ROKU_SESSION_ID = 'roku-session-1'; + +test('Roku WebDriver runtime drives TV commands through daemon routes', async () => { + await withProviderScenarioResource(createRokuWebDriverWorld, async ({ daemon, server }) => { + const lease = await allocateRokuLease(daemon, server.url); + + await runProviderScenario( + daemon, + [ + { + name: 'open', + command: 'open', + positionals: [ROKU_CHANNEL_ID], + }, + { + name: 'snapshot', + command: 'snapshot', + assert: (response) => { + const data = assertRpcOk<{ + nodes?: Array<{ label?: string; identifier?: string; hittable?: boolean }>; + }>(response); + assert.equal(data.nodes?.[1]?.label, 'Play'); + assert.equal(data.nodes?.[1]?.identifier, 'play'); + assert.equal(data.nodes?.[1]?.hittable, true); + }, + }, + { + name: 'remote down', + command: 'tv-remote', + positionals: ['down'], + expectData: { + action: 'tv-remote', + button: 'down', + message: 'Pressed TV remote down', + }, + }, + { + name: 'close', + command: 'close', + positionals: [ROKU_CHANNEL_ID], + }, + ], + { + flags: rokuCommandFlags(lease.leaseId), + meta: rokuCommandMeta(lease.leaseId), + }, + ); + + assert.deepEqual( + server.calls.map((call) => `${call.method} ${call.path}`), + [ + 'POST /v1/session', + `POST /v1/session/${ROKU_SESSION_ID}/launch`, + `GET /v1/session/${ROKU_SESSION_ID}/source`, + 'POST /keypress/Down', + `POST /exit-app/${ROKU_CHANNEL_ID}/true`, + `DELETE /v1/session/${ROKU_SESSION_ID}`, + ], + ); + assert.deepEqual(server.calls[0]?.body, { ip: ROKU_DEVICE_IP }); + assert.deepEqual(server.calls[1]?.body, { channelId: ROKU_CHANNEL_ID }); + for (const call of server.calls) { + assert.equal(call.headers['x-agent-device-client'], 'agent-device-cli'); + assert.equal(typeof call.headers['x-agent-device-version'], 'string'); + } + }); +}, 15_000); + +async function createRokuWebDriverWorld() { + const server = await FakeRokuWebDriverServer.start(); + const runtime = createRokuWebDriverRuntime({ + ecpEndpoint: server.url, + }); + const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + const daemon = await createProviderScenarioHarness({ + ...providers, + deviceInventoryProvider: providers.deviceInventoryProvider!, + }); + return { + daemon, + server, + close: async () => { + await runtime.shutdown(); + await daemon.close(); + await server.close(); + }, + }; +} + +async function allocateRokuLease( + daemon: Awaited>, + endpoint: string, +): Promise { + const allocate = await daemon.callCommand( + 'lease_allocate', + [], + { + ...rokuCommandFlags(), + rokuWebDriverUrl: endpoint, + rokuDeviceIp: ROKU_DEVICE_IP, + providerApp: ROKU_CHANNEL_ID, + }, + { meta: rokuCommandMeta() }, + ); + const data = assertRpcOk<{ + lease?: DeviceLease; + provider?: { + provider?: string; + providerSessionId?: string; + deviceId?: string; + capabilities?: { operations?: { tvRemote?: { support?: string } } }; + }; + }>(allocate); + assert.ok(data.lease); + assert.equal(data.provider?.provider, CLOUD_WEBDRIVER_PROVIDERS.roku); + assert.equal(data.provider?.providerSessionId, ROKU_SESSION_ID); + assert.equal(data.provider?.deviceId, `roku:${ROKU_DEVICE_IP}`); + assert.equal(data.provider?.capabilities?.operations?.tvRemote?.support, 'supported'); + return data.lease; +} + +function rokuCommandFlags(leaseId?: string): DaemonRequest['flags'] { + return { + platform: 'web', + target: 'tv', + tenant: 'team-roku', + runId: 'run-roku', + leaseId, + leaseProvider: CLOUD_WEBDRIVER_PROVIDERS.roku, + }; +} + +function rokuCommandMeta(leaseId?: string): DaemonRequest['meta'] { + return { + tenantId: 'team-roku', + runId: 'run-roku', + leaseId, + leaseBackend: 'web-instance', + leaseProvider: CLOUD_WEBDRIVER_PROVIDERS.roku, + deviceKey: `roku:${ROKU_DEVICE_IP}`, + clientId: 'client-roku', + }; +} + +class FakeRokuWebDriverServer extends CloudWebDriverTestServer { + static async start(): Promise> { + return await startCloudWebDriverTestServer(new FakeRokuWebDriverServer()); + } + + protected respond(call: CloudWebDriverHttpCall, res: ServerResponse): void { + switch (`${call.method} ${call.path}`) { + case 'POST /v1/session': + writeCloudWebDriverTestJson(res, { + value: { + sessionId: ROKU_SESSION_ID, + vendor: 'Roku', + model: 'Ultra', + }, + }); + return; + case `POST /v1/session/${ROKU_SESSION_ID}/launch`: + case `DELETE /v1/session/${ROKU_SESSION_ID}`: + writeCloudWebDriverTestJson(res, { value: null }); + return; + case `GET /v1/session/${ROKU_SESSION_ID}/source`: + writeCloudWebDriverTestJson(res, { + value: Buffer.from(fakeRokuSource(), 'utf8').toString('base64'), + }); + return; + case 'POST /keypress/Down': + case `POST /exit-app/${ROKU_CHANNEL_ID}/true`: + res.writeHead(200); + res.end(); + return; + default: + writeCloudWebDriverTestJson(res, { value: { message: 'unexpected route' } }, 404); + } + } +} + +function fakeRokuSource(): string { + return ( + '' + + '' + + '' + ); +}