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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/__tests__/cloud-connect-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): ReturnType<typeof vi.fn> {
mockedResolveCloudAccessForConnect.mockResolvedValue({
accessToken: 'adc_agent_cloud',
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/remote-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
Expand Down
6 changes: 5 additions & 1 deletion src/cli/commands/connection-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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.`,
);
}

Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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;
}

Expand Down
33 changes: 33 additions & 0 deletions src/cli/connection/cloud-webdriver-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 <url> or ROKU_WEBDRIVER_URL.',
);
const rokuDeviceIp = requireFlag(
readRokuProfileValue(options.flags.rokuDeviceIp, options.env, 'ROKU_DEVICE_IP'),
'connect roku requires --roku-device-ip <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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/cli/connection/provider-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function connectProviderNamesForError(): string {
'proxy',
CLOUD_WEBDRIVER_PROVIDERS.browserStack,
CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm,
CLOUD_WEBDRIVER_PROVIDERS.roku,
].join(', ');
}

Expand Down
21 changes: 21 additions & 0 deletions src/cli/parser/__tests__/args-parse-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
17 changes: 14 additions & 3 deletions src/cli/parser/__tests__/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ test('usage includes only global flags in the top-level global flags section', a
assert.doesNotMatch(flagsSection, /--lease-id <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 <name:value>/);
Expand Down Expand Up @@ -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/);
Expand All @@ -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/);
Expand Down
20 changes: 18 additions & 2 deletions src/cli/parser/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
{
Expand Down Expand Up @@ -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 <url>',
usageDescription: 'Roku WebDriver server URL for Roku provider sessions',
},
{
key: 'rokuDeviceIp',
names: ['--roku-device-ip'],
type: 'string',
usageLabel: '--roku-device-ip <ip>',
usageDescription: 'LAN IP address of the Roku device controlled by Roku WebDriver',
},
{
key: 'force',
names: ['--force'],
Expand Down Expand Up @@ -1305,6 +1319,8 @@ export const COMMON_COMMAND_SUPPORTED_FLAG_KEYS = flagKeys(
'awsAppArn',
'awsRegion',
'awsInteractionMode',
'rokuWebDriverUrl',
'rokuDeviceIp',
'udid',
'serial',
'iosSimulatorDeviceSet',
Expand Down
21 changes: 16 additions & 5 deletions src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,9 +801,10 @@ Providers:
Direct proxy: agent-device connect proxy --daemon-base-url <proxy-agent-device-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.

Expand Down Expand Up @@ -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 <channel-id>
agent-device open <channel-id>
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
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/client/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export type AgentDeviceRequestOverrides = Pick<
| 'awsAppArn'
| 'awsRegion'
| 'awsInteractionMode'
| 'rokuWebDriverUrl'
| 'rokuDeviceIp'
| 'leaseTtlMs'
| 'cwd'
| 'debug'
Expand Down
12 changes: 12 additions & 0 deletions src/cloud-webdriver/provider-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/cloud-webdriver/providers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const CLOUD_WEBDRIVER_PROVIDERS = {
browserStack: 'browserstack',
awsDeviceFarm: 'aws-device-farm',
roku: 'roku',
} as const;

export type CloudWebDriverKnownProviderName =
Expand Down
Loading
Loading