diff --git a/CONTEXT.md b/CONTEXT.md index 8aef407282..ff8fbbdcc4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -270,11 +270,12 @@ The perfect-shape refactor is complete and merged. Its end-state: it ranks an explicit target spine — as rank groups, lowest (kernel sink) to highest, where `A ◄ B` means B may not be outranked by A (the back-edge order the gate rejects), NOT that every displayed import exists: - `{ contracts, request, selectors, platforms, utils, replay, recording, snapshot, screenshot-diff, cloud-webdriver } ◄ { core, providers } ◄ { commands, cli-schema, mcp } ◄ { client, daemon-server, compat, remote, metro, sdk } ◄ daemon-client ◄ cli` (the former rank-0 kernel zone lives in `packages/kernel` since #1490 W0; R11 package-boundaries owns that seam, and R1 kernel-sink is retired as physically subsumed) — - and rejects every back-edge within it. Only `(root)` is unranked (`UNRANKED_ZONES` in - `scripts/layering/model.ts`): it holds the entrypoints and the composition roots that wire the - command surface into the daemon, and R2 forbids `daemon/` from importing `commands/`, so those - files sit outside the spine by construction. The satellite zones used to be unranked too, on the + `{ contracts, request, selectors, platforms, utils, replay, recording, snapshot, screenshot-diff } ◄ { core, providers } ◄ { commands, cli-schema, mcp } ◄ { client, daemon-server, compat, remote, metro, sdk } ◄ daemon-client ◄ cli` (the former rank-0 kernel zone lives in `packages/kernel` since #1490 W0, the former `cloud-webdriver` leaf lives behind the single `@agent-device/provider-webdriver` facade since W1b, and the dependency-free XML codec lives behind the single `@agent-device/xml` facade; R11 package-boundaries owns these physical seams) — + and rejects every back-edge within it. Only `(root)` is unranked among `src/` zones + (`UNRANKED_ZONES` in `scripts/layering/model.ts`): it holds the entrypoints and the composition + roots that wire the command surface into the daemon, and R2 forbids `daemon/` from importing + `commands/`, so those files sit outside the spine by construction. Extracted workspace packages + are classified separately and enforced by R11. The satellite zones used to be unranked too, on the grounds that ranking them would invent an order the architecture had not committed to; once `utils` joined the spine and `(root)` was emptied of shared contracts, every one of them turned out to have a consistent rank already. `model.test.ts` guards that no new zone escapes this diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index ac677ef8ec..b87e45597c 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -623,7 +623,7 @@ "src/selectors/build.ts:high impact", "src/kernel/redaction.ts:high impact", "src/utils/rect-visibility.ts:high impact", - "src/cloud-webdriver/webdriver-utils.ts:high impact", + "packages/provider-webdriver/src/webdriver-utils.ts:high impact", "src/utils/keyed-lock.ts:high impact", "src/daemon/request-progress-protocol.ts:high impact", "src/replay/test/reporters/format.ts:high impact", diff --git a/package.json b/package.json index 7d98f33473..db1a233028 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm package:npm", - "typecheck": "tsc -b packages/kernel packages/contracts packages/maestro && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/maestro packages/provider-webdriver && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", "test-app:install": "pnpm install --dir examples/test-app", "test-app:start": "pnpm --dir examples/test-app start", "test-app:ios": "pnpm --dir examples/test-app ios", @@ -248,6 +248,8 @@ "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*", "@agent-device/maestro": "workspace:*", + "@agent-device/provider-webdriver": "workspace:*", + "@agent-device/xml": "workspace:*", "@chenglou/freerange": "^0.0.1", "@stryker-mutator/core": "9.6.1", "@stryker-mutator/vitest-runner": "9.6.1", diff --git a/packages/contracts/src/device-provider.ts b/packages/contracts/src/device-provider.ts index 8d9f4bd6cb..4570031880 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -1,6 +1,6 @@ // The device-provider port. // -// A provider adapter (`providers/`, `cloud-webdriver/`) implements these; the daemon calls +// A provider adapter (`providers/`, `@agent-device/provider-webdriver`) implements these; the daemon calls // them. Both sides therefore name the same shapes, and since the adapters sit below the // daemon in the spine, the shapes have to be declared below both — here — rather than // inside the daemon module that happens to consume them first. diff --git a/packages/provider-webdriver/package.json b/packages/provider-webdriver/package.json new file mode 100644 index 0000000000..35c6b546d4 --- /dev/null +++ b/packages/provider-webdriver/package.json @@ -0,0 +1,18 @@ +{ + "name": "@agent-device/provider-webdriver", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "BrowserStack and AWS Device Farm WebDriver providers for agent-device. Internal workspace package bundled into the published artifact.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*", + "@agent-device/xml": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/src/cloud-webdriver/artifact-results.ts b/packages/provider-webdriver/src/artifact-results.ts similarity index 100% rename from src/cloud-webdriver/artifact-results.ts rename to packages/provider-webdriver/src/artifact-results.ts diff --git a/src/cloud-webdriver/aws-device-farm-artifacts.ts b/packages/provider-webdriver/src/aws-device-farm-artifacts.ts similarity index 100% rename from src/cloud-webdriver/aws-device-farm-artifacts.ts rename to packages/provider-webdriver/src/aws-device-farm-artifacts.ts diff --git a/src/cloud-webdriver/aws-device-farm.ts b/packages/provider-webdriver/src/aws-device-farm.ts similarity index 93% rename from src/cloud-webdriver/aws-device-farm.ts rename to packages/provider-webdriver/src/aws-device-farm.ts index cce2e99333..83bc3138ee 100644 --- a/src/cloud-webdriver/aws-device-farm.ts +++ b/packages/provider-webdriver/src/aws-device-farm.ts @@ -16,9 +16,9 @@ import type { CloudWebDriverPrepareSession, } from './runtime.ts'; import type { DeviceLease, ProviderDeviceRuntime } from '@agent-device/contracts/device'; -import { runCmd } from '../utils/exec.ts'; -import { sleep } from '../utils/timeouts.ts'; +import { setTimeout as sleep } from 'node:timers/promises'; import { AppError } from '@agent-device/kernel/errors'; +import type { RunHostCommand } from './dependencies.ts'; import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; import { resolveLeaseValue, type LeaseValue } from './webdriver-utils.ts'; @@ -79,6 +79,7 @@ export type AwsCreateRemoteAccessSessionInput = { }; export type AwsDeviceFarmWebDriverRuntimeOptions = { + clientVersion: string; projectArn: string; deviceArn: string; region?: string; @@ -118,10 +119,17 @@ export function getAwsDeviceFarmWebDriverCapabilities( export function createAwsDeviceFarmWebDriverRuntime( options: AwsDeviceFarmWebDriverRuntimeOptions, ): ProviderDeviceRuntime { - const client = options.client ?? createAwsCliDeviceFarmClient({ region: options.region }); + if (!options.client) { + throw new AppError( + 'INVALID_ARGS', + 'AWS Device Farm runtime construction requires a client from the package facade.', + ); + } + const client = options.client; const platform = options.platform ?? 'android'; const deviceName = options.deviceName ?? 'AWS Device Farm device'; return createCloudWebDriverRuntime({ + clientVersion: options.clientVersion, provider: AWS_DEVICE_FARM_PROVIDER, endpoint: 'http://127.0.0.1/', platform, @@ -144,12 +152,13 @@ export function createAwsDeviceFarmWebDriverRuntime( } export type AwsCliDeviceFarmClientOptions = { + runHostCommand: RunHostCommand; region?: string; awsCommand?: string; }; export function createAwsCliDeviceFarmClient( - options: AwsCliDeviceFarmClientOptions = {}, + options: AwsCliDeviceFarmClientOptions, ): AwsDeviceFarmClient { const runDeviceFarmJson = createAwsDeviceFarmCommandRunner(options); return { @@ -189,7 +198,10 @@ export function createAwsDeviceFarmPrepareSession( 'client' | 'platform' | 'deviceName' | 'projectArn' | 'deviceArn' > > & - Omit, + Omit< + AwsDeviceFarmWebDriverRuntimeOptions, + 'client' | 'platform' | 'deviceName' | 'clientVersion' + >, ): CloudWebDriverPrepareSession { return async ({ lease, base }) => { const remoteAccess = await options.client.createRemoteAccessSession({ @@ -289,8 +301,12 @@ async function waitForRunningRemoteAccessSession( }); } -async function runAwsJson(command: string, args: string[]): Promise { - const result = await runCmd(command, args, { maxBuffer: 10 * 1024 * 1024 }); +async function runAwsJson( + runHostCommand: RunHostCommand, + command: string, + args: string[], +): Promise { + const result = await runHostCommand(command, args); return JSON.parse(result.stdout) as unknown; } @@ -300,7 +316,7 @@ function createAwsDeviceFarmCommandRunner( const regionArgs = options.region ? ['--region', options.region] : []; const awsCommand = options.awsCommand ?? 'aws'; return async (subcommand, args) => - await runAwsJson(awsCommand, [ + await runAwsJson(options.runHostCommand, awsCommand, [ 'devicefarm', subcommand, ...regionArgs, diff --git a/src/cloud-webdriver/browserstack.ts b/packages/provider-webdriver/src/browserstack.ts similarity index 96% rename from src/cloud-webdriver/browserstack.ts rename to packages/provider-webdriver/src/browserstack.ts index c4490e0e47..c41564ffab 100644 --- a/src/cloud-webdriver/browserstack.ts +++ b/packages/provider-webdriver/src/browserstack.ts @@ -46,6 +46,7 @@ export const BROWSERSTACK_CAPABILITY_OVERRIDES = { } as const satisfies CloudWebDriverCapabilityOverrides; export type BrowserStackWebDriverRuntimeOptions = { + clientVersion: string; username: string; accessKey: string; platform: CloudWebDriverPlatform; @@ -97,11 +98,13 @@ export function createBrowserStackWebDriverRuntime( ): ProviderDeviceRuntime { const uploadEndpoint = options.uploadEndpoint ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT; const artifactOptions = { + clientVersion: options.clientVersion, username: options.username, accessKey: options.accessKey, endpoint: options.sessionDetailsEndpoint ?? BROWSERSTACK_SESSION_DETAILS_ENDPOINT, }; return createCloudWebDriverRuntime({ + clientVersion: options.clientVersion, provider: BROWSERSTACK_PROVIDER, endpoint: options.endpoint ?? BROWSERSTACK_APP_AUTOMATE_ENDPOINT, platform: options.platform, @@ -121,6 +124,7 @@ export function createBrowserStackWebDriverRuntime( configured: resolveConfiguredBrowserStackCapabilities(options, lease), }), uploadApp: createBrowserStackUploadApp({ + clientVersion: options.clientVersion, username: options.username, accessKey: options.accessKey, endpoint: uploadEndpoint, @@ -135,6 +139,7 @@ export function createBrowserStackWebDriverRuntime( } export type BrowserStackSessionDetailsOptions = { + clientVersion: string; username: string; accessKey: string; endpoint?: string | URL; @@ -157,6 +162,7 @@ export async function listBrowserStackCloudArtifacts( } export type BrowserStackUploadOptions = { + clientVersion: string; username: string; accessKey: string; endpoint?: string | URL; @@ -172,7 +178,7 @@ export async function uploadBrowserStackApp( const response = await fetch(options.endpoint ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT, { method: 'POST', headers: { - ...agentDeviceRequestHeaders(), + ...agentDeviceRequestHeaders(options.clientVersion), Authorization: basicAuthHeader(options), }, body: form, @@ -236,7 +242,7 @@ async function fetchBrowserStackSessionDetails( ); const response = await fetch(endpoint, { headers: { - ...agentDeviceRequestHeaders(), + ...agentDeviceRequestHeaders(options.clientVersion), Authorization: basicAuthHeader(options), }, }); diff --git a/src/cloud-webdriver/capabilities.ts b/packages/provider-webdriver/src/capabilities.ts similarity index 100% rename from src/cloud-webdriver/capabilities.ts rename to packages/provider-webdriver/src/capabilities.ts diff --git a/packages/provider-webdriver/src/dependencies.ts b/packages/provider-webdriver/src/dependencies.ts new file mode 100644 index 0000000000..95507f93e4 --- /dev/null +++ b/packages/provider-webdriver/src/dependencies.ts @@ -0,0 +1,13 @@ +export type HostCommandResult = { + stdout: string; +}; + +export type RunHostCommand = ( + command: string, + args: readonly string[], +) => Promise; + +export type ProviderWebDriverDependencies = { + clientVersion: string; + runHostCommand: RunHostCommand; +}; diff --git a/packages/provider-webdriver/src/index.ts b/packages/provider-webdriver/src/index.ts new file mode 100644 index 0000000000..70bc8a1e4c --- /dev/null +++ b/packages/provider-webdriver/src/index.ts @@ -0,0 +1,46 @@ +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import type { + CloudArtifactsQuery, + CloudArtifactsResult, +} from '@agent-device/contracts/observability'; +import type { ProviderWebDriverDependencies } from './dependencies.ts'; +import { + createCloudWebDriverProviderDefinitions, + type DefaultCloudWebDriverArtifactEnv, + type DefaultCloudWebDriverProviderRuntimeEnv, +} from './provider-definitions.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS, isCloudWebDriverProviderName } from './providers.ts'; + +export { CLOUD_WEBDRIVER_PROVIDERS, isCloudWebDriverProviderName }; +export type { CloudWebDriverKnownProviderName } from './providers.ts'; +export type { ProviderWebDriverDependencies, RunHostCommand } from './dependencies.ts'; +export type { + DefaultCloudWebDriverArtifactEnv, + DefaultCloudWebDriverProviderRuntimeEnv, +} from './provider-definitions.ts'; + +export type ProviderWebDriver = { + readonly providerIds: readonly string[]; + createDefaultRuntimes(env?: DefaultCloudWebDriverProviderRuntimeEnv): ProviderDeviceRuntime[]; + listArtifactsFromEnv( + query: CloudArtifactsQuery, + env: DefaultCloudWebDriverArtifactEnv, + ): Promise; +}; + +export function createProviderWebDriver( + dependencies: ProviderWebDriverDependencies, +): ProviderWebDriver { + const definitions = createCloudWebDriverProviderDefinitions(dependencies); + return { + providerIds: definitions.map((definition) => definition.provider), + createDefaultRuntimes: (env = process.env) => + definitions.map((definition) => definition.createRuntime(env)), + listArtifactsFromEnv: async (query, env) => { + if (!query.providerSessionId) return undefined; + return await definitions + .find((definition) => definition.provider === query.provider) + ?.listArtifactsFromEnv(query.providerSessionId, env); + }, + }; +} diff --git a/packages/provider-webdriver/src/provider-definitions.ts b/packages/provider-webdriver/src/provider-definitions.ts new file mode 100644 index 0000000000..401c62cef4 --- /dev/null +++ b/packages/provider-webdriver/src/provider-definitions.ts @@ -0,0 +1,329 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { CloudArtifactsResult } from '@agent-device/contracts/observability'; +import type { LeaseLifecycleContext, ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { ProviderWebDriverDependencies } from './dependencies.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 function createCloudWebDriverProviderDefinitions( + dependencies: ProviderWebDriverDependencies, +): readonly CloudWebDriverProviderDefinition[] { + return [ + { + provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + createRuntime: (env) => + createCloudWebDriverRuntime({ + clientVersion: dependencies.clientVersion, + 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, { + clientVersion: dependencies.clientVersion, + 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({ + clientVersion: dependencies.clientVersion, + app: requireFlag( + request, + 'providerApp', + 'BrowserStack requires --provider-app .', + ), + cwd: request.cwd, + username, + accessKey, + uploadEndpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, + }); + return { + ...base, + platform, + deviceName, + auth: { username, accessKey }, + uploadApp: createBrowserStackUploadApp({ + clientVersion: dependencies.clientVersion, + 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, + { + clientVersion: dependencies.clientVersion, + username, + accessKey, + endpoint: env.BROWSERSTACK_SESSION_DETAILS_ENDPOINT, + }, + ); + }, + }, + { + provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + createRuntime: (env) => + createCloudWebDriverRuntime({ + clientVersion: dependencies.clientVersion, + 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({ + runHostCommand: dependencies.runHostCommand, + 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({ + runHostCommand: dependencies.runHostCommand, + 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({ + runHostCommand: dependencies.runHostCommand, + region: + env.AWS_REGION ?? + env.AWS_DEFAULT_REGION ?? + readAwsRegionFromDeviceFarmArn(providerSessionId), + }); + return await listAwsDeviceFarmCloudArtifacts( + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + providerSessionId, + client, + ); + }, + }, + ]; +} + +async function resolveBrowserStackAppReference(options: { + clientVersion: string; + 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, { + clientVersion: options.clientVersion, + username: options.username, + accessKey: options.accessKey, + endpoint: options.uploadEndpoint, + }); +} + +function isProviderAppReference(value: string): boolean { + return value.startsWith('bs://') || /^https?:\/\//.test(value); +} + +function requireRequest( + req: LeaseLifecycleContext | undefined, + providerLabel: string, +): LeaseLifecycleContext { + if (req) return req; + throw new AppError( + 'INVALID_ARGS', + `${providerLabel} lease allocation requires provider profile flags on the request.`, + ); +} + +function requireRequestPlatform( + req: LeaseLifecycleContext, + 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: LeaseLifecycleContext, key: string, message: string): string { + const value = readFlag(req, key); + if (value) return value; + throw new AppError('INVALID_ARGS', message); +} + +function readFlag(req: LeaseLifecycleContext, key: string): 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: LeaseLifecycleContext, + env: DefaultCloudWebDriverProviderRuntimeEnv, + flagKey: string, + 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: LeaseLifecycleContext, +): '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/providers.ts b/packages/provider-webdriver/src/providers.ts similarity index 100% rename from src/cloud-webdriver/providers.ts rename to packages/provider-webdriver/src/providers.ts diff --git a/packages/provider-webdriver/src/request-headers.ts b/packages/provider-webdriver/src/request-headers.ts new file mode 100644 index 0000000000..7990f26de9 --- /dev/null +++ b/packages/provider-webdriver/src/request-headers.ts @@ -0,0 +1,8 @@ +const AGENT_DEVICE_CLIENT_HEADER = 'agent-device-cli'; + +export function agentDeviceRequestHeaders(clientVersion: string): Record { + return { + 'x-agent-device-client': AGENT_DEVICE_CLIENT_HEADER, + 'x-agent-device-version': clientVersion, + }; +} diff --git a/src/cloud-webdriver/runtime-helpers.ts b/packages/provider-webdriver/src/runtime-helpers.ts similarity index 100% rename from src/cloud-webdriver/runtime-helpers.ts rename to packages/provider-webdriver/src/runtime-helpers.ts diff --git a/packages/provider-webdriver/src/runtime.test.ts b/packages/provider-webdriver/src/runtime.test.ts new file mode 100644 index 0000000000..88e3a4422c --- /dev/null +++ b/packages/provider-webdriver/src/runtime.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { DeviceLease } from '@agent-device/contracts/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { createCloudWebDriverRuntime } from './runtime.ts'; + +test('session allocation preserves its primary failure when provider cleanup also fails', async () => { + const previousFetch = globalThis.fetch; + let cleanupCalled = false; + globalThis.fetch = async () => + new Response(JSON.stringify({ value: { message: 'create session failed' } }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + const runtime = createCloudWebDriverRuntime({ + clientVersion: 'test', + provider: 'webdriver-test', + endpoint: 'https://webdriver.test/wd/hub/', + platform: 'android', + deviceName: 'Test device', + requestPolicy: { retryAttempts: 0 }, + prepareSession: async ({ base }) => ({ + ...base, + cleanup: async () => { + cleanupCalled = true; + throw new Error('provider cleanup failed'); + }, + }), + }); + + try { + const allocate = runtime.leaseLifecycle.allocate; + assert.ok(allocate); + await assert.rejects( + () => allocate(makeLease()), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.match(error.message, /create session failed/); + assert.equal(error.details?.cleanupError, 'provider cleanup failed'); + return true; + }, + ); + assert.equal(cleanupCalled, true); + } finally { + await runtime.shutdown(); + globalThis.fetch = previousFetch; + } +}); + +function makeLease(): DeviceLease { + return { + leaseId: 'lease-1', + tenantId: 'team-a', + runId: 'run-a', + clientId: 'client-a', + leaseProvider: 'webdriver-test', + backend: 'android-instance', + deviceKey: 'webdriver-test:device-a', + createdAt: 1, + expiresAt: 2, + heartbeatAt: 1, + }; +} diff --git a/src/cloud-webdriver/runtime.ts b/packages/provider-webdriver/src/runtime.ts similarity index 99% rename from src/cloud-webdriver/runtime.ts rename to packages/provider-webdriver/src/runtime.ts index 2821d424c9..73d7c7b396 100644 --- a/src/cloud-webdriver/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -84,6 +84,7 @@ export type CloudWebDriverPrepareSession = (params: { }) => Promise; export type CloudWebDriverRuntimeOptions = { + clientVersion: string; provider: string; endpoint: string | URL; platform: CloudWebDriverPlatform; @@ -220,6 +221,7 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { if (this.sessionsByLeaseId.has(lease.leaseId)) return this.heartbeat(lease); const prepared = await this.prepareSession(lease, req); const client = new WebDriverClient({ + clientVersion: this.options.clientVersion, endpoint: prepared.endpoint, auth: prepared.auth, headers: prepared.headers, diff --git a/src/cloud-webdriver/webdriver-client.ts b/packages/provider-webdriver/src/webdriver-client.ts similarity index 98% rename from src/cloud-webdriver/webdriver-client.ts rename to packages/provider-webdriver/src/webdriver-client.ts index 6401505e1f..de2134ef42 100644 --- a/src/cloud-webdriver/webdriver-client.ts +++ b/packages/provider-webdriver/src/webdriver-client.ts @@ -1,6 +1,6 @@ import fs from 'node:fs/promises'; +import { setTimeout as sleep } from 'node:timers/promises'; import { AppError } from '@agent-device/kernel/errors'; -import { sleep } from '../utils/timeouts.ts'; import { agentDeviceRequestHeaders } from './request-headers.ts'; import { basicAuthHeader, trimLeadingSlash, withTrailingSlash } from './webdriver-utils.ts'; @@ -10,6 +10,7 @@ export type WebDriverAuth = { }; export type WebDriverClientOptions = { + clientVersion: string; endpoint: string | URL; auth?: WebDriverAuth; headers?: Record; @@ -75,7 +76,7 @@ export class WebDriverClient { constructor(options: WebDriverClientOptions) { this.endpoint = withTrailingSlash(new URL(options.endpoint)); this.headers = { - ...agentDeviceRequestHeaders(), + ...agentDeviceRequestHeaders(options.clientVersion), ...(options.auth ? { Authorization: basicAuthHeader(options.auth) } : {}), ...options.headers, }; diff --git a/src/cloud-webdriver/webdriver-gestures.ts b/packages/provider-webdriver/src/webdriver-gestures.ts similarity index 100% rename from src/cloud-webdriver/webdriver-gestures.ts rename to packages/provider-webdriver/src/webdriver-gestures.ts diff --git a/src/cloud-webdriver/webdriver-interactor.ts b/packages/provider-webdriver/src/webdriver-interactor.ts similarity index 100% rename from src/cloud-webdriver/webdriver-interactor.ts rename to packages/provider-webdriver/src/webdriver-interactor.ts diff --git a/src/cloud-webdriver/webdriver-scroll-frame.ts b/packages/provider-webdriver/src/webdriver-scroll-frame.ts similarity index 100% rename from src/cloud-webdriver/webdriver-scroll-frame.ts rename to packages/provider-webdriver/src/webdriver-scroll-frame.ts diff --git a/packages/provider-webdriver/src/webdriver-source.test.ts b/packages/provider-webdriver/src/webdriver-source.test.ts new file mode 100644 index 0000000000..cc624625ff --- /dev/null +++ b/packages/provider-webdriver/src/webdriver-source.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { scrollFrameFromWebDriverSource } from './webdriver-scroll-frame.ts'; +import { parseWebDriverSource } from './webdriver-source.ts'; + +test('WebDriver source parsing preserves hardened attributes and geometry', () => { + 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__"/, + ); +}); + +test('WebDriver scroll frame prefers visible scrollable containers', () => { + assert.deepEqual( + scrollFrameFromWebDriverSource( + '' + + '' + + '' + + '' + + '', + ), + { x: 0, y: 393, width: 1080, height: 1103 }, + ); +}); diff --git a/src/cloud-webdriver/webdriver-source.ts b/packages/provider-webdriver/src/webdriver-source.ts similarity index 98% rename from src/cloud-webdriver/webdriver-source.ts rename to packages/provider-webdriver/src/webdriver-source.ts index a5b8065a3d..fa833796b5 100644 --- a/src/cloud-webdriver/webdriver-source.ts +++ b/packages/provider-webdriver/src/webdriver-source.ts @@ -1,7 +1,7 @@ import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; import { AppError } from '@agent-device/kernel/errors'; import { parseBounds } from '@agent-device/kernel/bounds'; -import { parseXmlDocumentSync, type XmlNode } from '../utils/xml.ts'; +import { parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; export function parseWebDriverSource(source: string): RawSnapshotNode[] { let roots: XmlNode[]; diff --git a/src/cloud-webdriver/__tests__/webdriver-utils.test.ts b/packages/provider-webdriver/src/webdriver-utils.test.ts similarity index 89% rename from src/cloud-webdriver/__tests__/webdriver-utils.test.ts rename to packages/provider-webdriver/src/webdriver-utils.test.ts index 5b619e0a20..58d326c241 100644 --- a/src/cloud-webdriver/__tests__/webdriver-utils.test.ts +++ b/packages/provider-webdriver/src/webdriver-utils.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { trimLeadingSlash, trimTrailingSlash } from '../webdriver-utils.ts'; +import { trimLeadingSlash, trimTrailingSlash } from './webdriver-utils.ts'; test('slash trimming utilities handle slash-heavy strings without regular expressions', () => { const slashRun = '/'.repeat(10_000); diff --git a/src/cloud-webdriver/webdriver-utils.ts b/packages/provider-webdriver/src/webdriver-utils.ts similarity index 100% rename from src/cloud-webdriver/webdriver-utils.ts rename to packages/provider-webdriver/src/webdriver-utils.ts diff --git a/packages/provider-webdriver/tsconfig.json b/packages/provider-webdriver/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/provider-webdriver/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/xml/package.json b/packages/xml/package.json new file mode 100644 index 0000000000..20dd93a79a --- /dev/null +++ b/packages/xml/package.json @@ -0,0 +1,13 @@ +{ + "name": "@agent-device/xml", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Dependency-free XML parsing and character-reference decoding for agent-device. Internal workspace package bundled into the published artifact.", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/src/platforms/apple/core/__tests__/xml.test.ts b/packages/xml/src/index.test.ts similarity index 78% rename from src/platforms/apple/core/__tests__/xml.test.ts rename to packages/xml/src/index.test.ts index c498ea5dd8..8104878fc9 100644 --- a/src/platforms/apple/core/__tests__/xml.test.ts +++ b/packages/xml/src/index.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { parseXmlDocumentSync } from '../xml.ts'; +import { + decodeXmlCharacterReferences, + escapeXmlTextAndAttribute, + parseXmlDocumentSync, +} from '@agent-device/xml'; test('parseXmlDocumentSync preserves ordered nodes with attributes and decoded text', () => { const nodes = parseXmlDocumentSync( @@ -102,3 +106,19 @@ test('parseXmlDocumentSync rejects documents above the configured size limit', ( /XML document exceeds maximum supported size of 10 characters/, ); }); + +test('decodeXmlCharacterReferences decodes predefined and numeric references only', () => { + assert.equal( + decodeXmlCharacterReferences('&<>"'A🚀🚀&custom;'), + '&<>"\'A🚀🚀&custom;', + ); + assert.equal(decodeXmlCharacterReferences('unterminated &'), 'unterminated &'); + assert.equal(decodeXmlCharacterReferences('invalid �'), 'invalid �'); +}); + +test('escapeXmlTextAndAttribute escapes text and quoted attribute delimiters', () => { + assert.equal( + escapeXmlTextAndAttribute(`Fish & Chips it's ready`), + 'Fish & Chips <tag value="one">it's ready</tag>', + ); +}); diff --git a/packages/xml/src/index.ts b/packages/xml/src/index.ts new file mode 100644 index 0000000000..5d96cfe36e --- /dev/null +++ b/packages/xml/src/index.ts @@ -0,0 +1,3 @@ +export { decodeXmlCharacterReferences, escapeXmlTextAndAttribute } from './internal/entities.ts'; +export { parseXmlDocumentSync } from './internal/parser.ts'; +export type { XmlNode, XmlParseOptions } from './internal/types.ts'; diff --git a/packages/xml/src/internal/entities.ts b/packages/xml/src/internal/entities.ts new file mode 100644 index 0000000000..fa879fe2e5 --- /dev/null +++ b/packages/xml/src/internal/entities.ts @@ -0,0 +1,45 @@ +export function decodeXmlCharacterReferences(value: string): string { + return value.replace( + /&(#(?:x|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 decodeNumericXmlCharacterReference(entity, body); + } + }, + ); +} + +export function escapeXmlTextAndAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function decodeNumericXmlCharacterReference(entity: string, body: string): string { + const codePoint = + body.slice(0, 2).toLowerCase() === '#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/src/utils/xml.ts b/packages/xml/src/internal/parser.ts similarity index 83% rename from src/utils/xml.ts rename to packages/xml/src/internal/parser.ts index 84f80e1088..91c230e8bb 100644 --- a/src/utils/xml.ts +++ b/packages/xml/src/internal/parser.ts @@ -1,9 +1,5 @@ -export type XmlNode = { - name: string; - attributes: Record; - text: string | null; - children: XmlNode[]; -}; +import { decodeXmlCharacterReferences } from './entities.ts'; +import type { XmlNode, XmlParseOptions } from './types.ts'; const MAX_XML_NESTING_DEPTH = 256; const MAX_XML_DOCUMENT_CHARS = 128 * 1024 * 1024; @@ -19,10 +15,7 @@ const UNSAFE_XML_ATTRIBUTE_NAMES = new Set([ 'prototype', ]); -export function parseXmlDocumentSync( - xml: string, - options: { maxDocumentChars?: number } = {}, -): XmlNode[] { +export function parseXmlDocumentSync(xml: string, options: XmlParseOptions = {}): XmlNode[] { const maxDocumentChars = options.maxDocumentChars ?? MAX_XML_DOCUMENT_CHARS; if (xml.length > maxDocumentChars) { throw new Error( @@ -32,24 +25,6 @@ export function parseXmlDocumentSync( 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[] = []; @@ -198,7 +173,9 @@ class LimitedXmlParser { 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}`; + node.text = `${node.text ?? ''}${ + decodeEntities ? decodeXmlCharacterReferences(trimmed) : trimmed + }`; } private addNode(node: XmlNode): void { @@ -236,7 +213,7 @@ class LimitedXmlParser { throw new Error(`XML attribute "${attributeName}" is not closed.`); } this.index = endIndex + 1; - return decodeXmlEntities(this.xml.slice(startIndex, endIndex).trim()); + return decodeXmlCharacterReferences(this.xml.slice(startIndex, endIndex).trim()); } private skipDeclaration(): void { @@ -320,39 +297,3 @@ function assertSafeXmlAttributeName(name: string): void { 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/packages/xml/src/internal/types.ts b/packages/xml/src/internal/types.ts new file mode 100644 index 0000000000..0bedf71aad --- /dev/null +++ b/packages/xml/src/internal/types.ts @@ -0,0 +1,10 @@ +export type XmlNode = { + name: string; + attributes: Record; + text: string | null; + children: XmlNode[]; +}; + +export type XmlParseOptions = { + maxDocumentChars?: number; +}; diff --git a/packages/xml/tsconfig.json b/packages/xml/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/xml/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e43bad29df..acfdbc18d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,12 @@ importers: '@agent-device/maestro': specifier: workspace:* version: link:packages/maestro + '@agent-device/provider-webdriver': + specifier: workspace:* + version: link:packages/provider-webdriver + '@agent-device/xml': + specifier: workspace:* + version: link:packages/xml '@chenglou/freerange': specifier: ^0.0.1 version: 0.0.1 @@ -99,6 +105,20 @@ importers: specifier: ^2.9.0 version: 2.9.0 + packages/provider-webdriver: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + '@agent-device/xml': + specifier: workspace:* + version: link:../xml + + packages/xml: {} + website: devDependencies: '@callstack/rspress-preset': diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 814ca2f630..a40aa81454 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -28,10 +28,9 @@ // engine files, and planned logical modules start with zero forbidden/internal imports (R10). // - Over the WORKSPACE PACKAGES: no root back-imports, no relative tunnelling past // an exports map, and every workspace specifier declared + exports-named (R11). -// Only `(root)` is unranked (see `UNRANKED_ZONES` in model.ts): it holds the -// entrypoints and the composition roots that wire the command surface into the -// daemon, which R2 forbids the daemon from importing, so they sit outside the -// spine by construction. Every other zone is ranked. +// Only `(root)` is unranked among src/ zones (see `UNRANKED_ZONES` in model.ts): +// it holds entrypoints and composition roots. Extracted workspace package zones +// are classified separately and held behind R11 instead of the src folder spine. import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; @@ -438,7 +437,7 @@ function report( process.stdout.write( `Layering guard: OK — ${files.length} source files satisfy R2-R3 and contain no ` + `value-import cycles (both checked globally); the ranked target spine contains no ` + - `back-edges (only the composition root is unranked), and its type-only ` + + `back-edges (only the composition root is unranked among src zones), and its type-only ` + `inversions match the R6 ratchet (${Object.values(TYPE_INVERSION_BASELINE).reduce((sum, count) => sum + count, 0)} remaining); ` + `all ${sessionStateFieldCount()} SessionState fields are classified and every write is ` + `inside its declared owner (R7); every zero-dep CI job resolves without ` + diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 57e79f66c6..ca8e041aa8 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -31,7 +31,6 @@ export type BackEdgeMap = Record; // ranked here or listed as unranked — `unclassifiedZones` and `model.test.ts` guard // that no zone is silently unclassified. const TARGET_DAG_RANK = new Map([ - ['cloud-webdriver', 1], ['contracts', 1], ['maestro', 1], ['platforms', 1], @@ -68,21 +67,23 @@ export function zoneRank(zone: string): number | null { return TARGET_DAG_RANK.get(zone) ?? null; } -// The one zone deliberately left OUT of the ranked spine. It is NOT unenforced: every file -// in it is still subject to the global production value-import cycle rejection (R4) and the -// R1-R3 move rules. It opts out of spine back-edge ranking because `(root)` holds the -// entrypoints and the composition roots that wire the command surface into the daemon — -// and R2 forbids `daemon/` from importing `commands/`, so those files must sit outside the -// spine by construction, composing it from above. +// Zones deliberately left OUT of the src folder spine. They are NOT unenforced: +// every file remains under the global value-cycle rule (R4). `(root)` composes +// the spine from above; extracted package zones are held by R11 package exports +// and the no-root-back-import rule instead of their former src folder rank. // // The satellite zones used to be listed here too, on the grounds that ranking them would // invent an order the architecture had not committed to. Once `utils` joined the spine and // `(root)` was emptied of shared contracts, every one of them turned out to have a // consistent rank already — so the order was there, just unasserted. -// 'kernel' is unranked because it is no longer a src/ zone at all: it lives in -// packages/kernel (#1490 W0), R11 package-boundaries owns that seam, and its -// zone name only appears in graphs that follow workspace specifiers. -export const UNRANKED_ZONES: ReadonlySet = new Set(['(root)', 'kernel']); +// `kernel`, `provider-webdriver`, and `xml` are not src/ zones: R11 owns their +// physical seams, and their zone names only appear in workspace-aware graphs. +export const UNRANKED_ZONES: ReadonlySet = new Set([ + '(root)', + 'kernel', + 'provider-webdriver', + 'xml', +]); export type ZoneClassification = 'ranked' | 'unranked' | 'unclassified'; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index e99e2943b1..064d703bf2 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -112,6 +112,20 @@ test('intra-package relative imports hold', () => { assert.deepEqual(checkPackageInternalSites(kernel, sites, ALL), []); }); +test('an exported package self-reference holds while a deep self-import fails', () => { + const exported = specifierSites( + 'packages/kernel/src/errors.test.ts', + "import { AppError } from '@agent-device/kernel/errors';", + ); + assert.deepEqual(checkPackageInternalSites(kernel, exported, ALL), []); + + const deep = specifierSites( + 'packages/kernel/src/errors.test.ts', + "import { AppError } from '@agent-device/kernel/src/errors.ts';", + ); + assert.equal(checkPackageInternalSites(kernel, deep, ALL).length, 1); +}); + test('a cross-package import needs a workspace:* declaration and an exported subpath', () => { const declared = specifierSites( 'packages/contracts/src/gesture.ts', @@ -200,6 +214,23 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/contracts', '@agent-device/kernel', ]); + const providerWebDriverPackage = packages.find( + (pkg) => pkg.name === '@agent-device/provider-webdriver', + ); + assert.ok(providerWebDriverPackage, 'provider-webdriver package must exist'); + assert.deepEqual( + [...providerWebDriverPackage.exportTargets.keys()], + ['@agent-device/provider-webdriver'], + ); + assert.deepEqual([...providerWebDriverPackage.workspaceDependencies].sort(), [ + '@agent-device/contracts', + '@agent-device/kernel', + '@agent-device/xml', + ]); + const xmlPackage = packages.find((pkg) => pkg.name === '@agent-device/xml'); + assert.ok(xmlPackage, 'xml package must exist'); + assert.deepEqual([...xmlPackage.exportTargets.keys()], ['@agent-device/xml']); + assert.deepEqual([...xmlPackage.workspaceDependencies], []); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/kernel'), 'root must declare the kernel workspace dependency', @@ -212,6 +243,14 @@ test('the real tree parses, declares, and passes R11', () => { rootWorkspaceDependencyNames(repoRoot).has('@agent-device/maestro'), 'root must declare the maestro workspace dependency', ); + assert.ok( + rootWorkspaceDependencyNames(repoRoot).has('@agent-device/provider-webdriver'), + 'root must declare the provider-webdriver workspace dependency', + ); + assert.ok( + rootWorkspaceDependencyNames(repoRoot).has('@agent-device/xml'), + 'root must declare the xml workspace dependency', + ); assert.deepEqual(checkPackageBoundaries(repoRoot, new Set()), []); }); @@ -228,6 +267,10 @@ test('Node resolution enforces the exports map at runtime', () => { '@agent-device/contracts/gesture-plan', '@agent-device/contracts/src/gesture-plan.ts', '@agent-device/contracts', + '@agent-device/provider-webdriver/runtime', + '@agent-device/provider-webdriver/src/runtime.ts', + '@agent-device/xml/internal/parser', + '@agent-device/xml/src/index.ts', ]) { assert.throws( () => import.meta.resolve(deep), @@ -241,4 +284,11 @@ test('Node resolution enforces the exports map at runtime', () => { contractsResolved.endsWith('packages/contracts/src/facades/interaction.ts'), contractsResolved, ); + const providerWebDriverResolved = import.meta.resolve('@agent-device/provider-webdriver'); + assert.ok( + providerWebDriverResolved.endsWith('packages/provider-webdriver/src/index.ts'), + providerWebDriverResolved, + ); + const xmlResolved = import.meta.resolve('@agent-device/xml'); + assert.ok(xmlResolved.endsWith('packages/xml/src/index.ts'), xmlResolved); }); diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index e3727464eb..8994b0fa6e 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -101,9 +101,9 @@ function specifierPackageName(specifier: string): string | undefined { } /** - * Rules for files INSIDE a package: no relative escape past the package dir, - * and any sibling-package import must be declared `workspace:*` and name an - * exported subpath. + * Rules for files INSIDE a package: no relative escape past the package dir; + * exported package self-references are legal; any sibling-package import must + * be declared `workspace:*`; and every package specifier must name an export. */ export function checkPackageInternalSites( pkg: WorkspacePackage, @@ -140,7 +140,7 @@ export function checkPackageInternalSites( }); continue; } - if (!pkg.workspaceDependencies.has(name)) { + if (name !== pkg.name && !pkg.workspaceDependencies.has(name)) { violations.push({ rule: 'R11 package-boundaries', file: site.file, diff --git a/src/__tests__/cli-network.test.ts b/src/__tests__/cli-network.test.ts index c43576d525..89b40dad71 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -813,7 +813,7 @@ test('test command writes JUnit report with failure metadata', async () => { file: '/tmp/03-skip.ad', status: 'skipped', durationMs: 0, - message: 'not runnable', + message: `not runnable & "now" 'soon'`, reason: 'skipped-by-filter', }, ], @@ -843,7 +843,10 @@ test('test command writes JUnit report with failure metadata', async () => { /details: \{"command":"open","reason":"selector_not_found"\}/, ); assert.match(xml, /flaky: true/); - assert.match(xml, //); + assert.match( + xml, + //, + ); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } diff --git a/src/__tests__/daemon-entrypoint.test.ts b/src/__tests__/daemon-entrypoint.test.ts index 3e3c69f354..f8b2547db9 100644 --- a/src/__tests__/daemon-entrypoint.test.ts +++ b/src/__tests__/daemon-entrypoint.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import { test } from 'vitest'; @@ -11,7 +12,7 @@ import { } from '../daemon/artifact-tracking.ts'; import { runCmdBackground } from '../utils/exec.ts'; import { isProcessAlive, waitForProcessExit } from '../utils/host-process.ts'; -import { waitForHttpOk } from './test-utils/index.ts'; +import { closeLoopbackServer, listenOnLoopback, waitForHttpOk } from './test-utils/index.ts'; type DaemonInfoFile = { httpPort?: number; @@ -168,6 +169,83 @@ test('daemon runtime publishes dual transport metadata', async () => { } }); +test('daemon default provider composition serves cloud artifacts over RPC', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-provider-')); + const providerRequests: string[] = []; + const providerServer = http.createServer((req, res) => { + providerRequests.push(req.url ?? ''); + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + automation_session: { video_url: 'https://browserstack.example/video.mp4' }, + }), + ); + }); + const providerPort = await listenOnLoopback(providerServer); + let runtime: Awaited> = null; + + try { + runtime = await startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + BROWSERSTACK_USERNAME: 'user', + BROWSERSTACK_ACCESS_KEY: 'key', + BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `http://127.0.0.1:${providerPort}/sessions`, + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + }); + assert.ok(runtime?.httpPort); + + const response = await fetch(`http://127.0.0.1:${runtime.httpPort}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'cloud-artifacts', + method: 'agent_device.command', + params: { + token: runtime.token, + session: 'default', + command: 'artifacts', + positionals: [], + flags: { provider: 'browserstack', providerSessionId: 'wd-1' }, + }, + }), + }); + const body = (await response.json()) as { + result?: { ok?: boolean; data?: Record }; + }; + + assert.equal(response.status, 200); + assert.equal(body.result?.ok, true); + assert.deepEqual(body.result?.data, { + provider: 'browserstack', + providerSessionId: 'wd-1', + status: 'ready', + cloudArtifacts: [ + { + provider: 'browserstack', + providerSessionId: 'wd-1', + kind: 'video', + name: 'Session video', + url: 'https://browserstack.example/video.mp4', + availability: 'ready', + }, + ], + }); + assert.deepEqual(providerRequests, ['/sessions/wd-1.json']); + } finally { + await runtime?.shutdown(); + await closeLoopbackServer(providerServer); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + test('daemon entrypoint publishes HTTP metadata and cleans up on shutdown', async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-entrypoint-')); const paths = resolveDaemonPaths(stateDir); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 5ed8fcfe41..c34ce38eab 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -30,7 +30,7 @@ import type { CloudProviderSessionResult } from '@agent-device/contracts/observa import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; import { connectionProviderRequiresRemoteDaemon } from '../connection/provider-policy.ts'; -import { isCloudWebDriverProviderName } from '../../cloud-webdriver/providers.ts'; +import { isCloudWebDriverProviderName } from '@agent-device/provider-webdriver'; const leaseDeferredCommands = new Set([ 'artifacts', diff --git a/src/cli/connection/cloud-webdriver-profile.ts b/src/cli/connection/cloud-webdriver-profile.ts index 661ff4f422..0a8501e133 100644 --- a/src/cli/connection/cloud-webdriver-profile.ts +++ b/src/cli/connection/cloud-webdriver-profile.ts @@ -1,5 +1,7 @@ -import { CLOUD_WEBDRIVER_PROVIDERS } from '../../cloud-webdriver/providers.ts'; -import type { CloudWebDriverKnownProviderName } from '../../cloud-webdriver/providers.ts'; +import { + CLOUD_WEBDRIVER_PROVIDERS, + type CloudWebDriverKnownProviderName, +} from '@agent-device/provider-webdriver'; import type { RemoteConfigProfile } from '../../remote/remote-config-schema.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { PlatformSelector } from '@agent-device/kernel/device'; diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 9c14812b37..7d00accebd 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -2,7 +2,7 @@ import { CLOUD_WEBDRIVER_PROVIDERS, isCloudWebDriverProviderName, type CloudWebDriverKnownProviderName, -} from '../../cloud-webdriver/providers.ts'; +} from '@agent-device/provider-webdriver'; export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; diff --git a/src/cloud-webdriver/provider-definitions.ts b/src/cloud-webdriver/provider-definitions.ts deleted file mode 100644 index 4a8cdebd04..0000000000 --- a/src/cloud-webdriver/provider-definitions.ts +++ /dev/null @@ -1,311 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import type { CloudArtifactsResult } from '@agent-device/contracts/observability'; -import type { LeaseLifecycleContext, ProviderDeviceRuntime } from '@agent-device/contracts/device'; -import { AppError } from '@agent-device/kernel/errors'; -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.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: LeaseLifecycleContext | undefined, - providerLabel: string, -): LeaseLifecycleContext { - if (req) return req; - throw new AppError( - 'INVALID_ARGS', - `${providerLabel} lease allocation requires provider profile flags on the request.`, - ); -} - -function requireRequestPlatform( - req: LeaseLifecycleContext, - 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: LeaseLifecycleContext, key: string, message: string): string { - const value = readFlag(req, key); - if (value) return value; - throw new AppError('INVALID_ARGS', message); -} - -function readFlag(req: LeaseLifecycleContext, key: string): 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: LeaseLifecycleContext, - env: DefaultCloudWebDriverProviderRuntimeEnv, - flagKey: string, - 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: LeaseLifecycleContext, -): '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 deleted file mode 100644 index f9557be39a..0000000000 --- a/src/cloud-webdriver/provider-registry.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { - CloudArtifactsQuery, - CloudArtifactsResult, -} from '@agent-device/contracts/observability'; -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 deleted file mode 100644 index 851205f122..0000000000 --- a/src/cloud-webdriver/provider-runtimes.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; -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/request-headers.ts b/src/cloud-webdriver/request-headers.ts deleted file mode 100644 index da4480b7a5..0000000000 --- a/src/cloud-webdriver/request-headers.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/daemon/__tests__/runtime-hints.test.ts b/src/daemon/__tests__/runtime-hints.test.ts index 86d3922de4..96c68f8cde 100644 --- a/src/daemon/__tests__/runtime-hints.test.ts +++ b/src/daemon/__tests__/runtime-hints.test.ts @@ -321,6 +321,28 @@ test('port-only hint on an Android emulator defaults host to 10.0.2.2 and writes }); }); +test('Android runtime hints escape XML text and quoted attribute delimiters', async () => { + await withMockedAdb(async ({ device, readWrittenPrefsFile }) => { + const packageName = 'com.example.demo'; + await applyRuntimeHintsToApp({ + device, + appId: packageName, + runtime: { + platform: 'android', + metroHost: `host&<>"'`, + metroPort: 8084, + }, + }); + + const payload = await readWrittenPrefsFile(defaultPrefsPath(packageName)); + assert.ok(payload, 'expected a write to the default RN preferences file'); + assert.match( + payload ?? '', + /host&<>"':8084<\/string>/, + ); + }); +}); + test('port-only hint on a physical Android device stays ambiguous and writes nothing', async () => { await withMockedAdb(async ({ device, argsLogPath, readWrittenPrefsFile }) => { const physicalDevice: DeviceInfo = { ...device, id: 'R5CN30', kind: 'device' }; diff --git a/src/daemon/runtime-hints.ts b/src/daemon/runtime-hints.ts index c4a74ad575..76be1fdf48 100644 --- a/src/daemon/runtime-hints.ts +++ b/src/daemon/runtime-hints.ts @@ -1,5 +1,6 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError, asAppError } from '@agent-device/kernel/errors'; +import { escapeXmlTextAndAttribute } from '@agent-device/xml'; import { execFailureDetails, type ExecResult } from '../utils/exec.ts'; import type { SessionRuntimeHints } from './types.ts'; import { @@ -273,12 +274,12 @@ function normalizeAndroidPrefsXml(xml: string): string { } function upsertAndroidStringPref(xml: string, key: string, value: string): string { - const entry = ` ${escapeXmlText(value)}`; + const entry = ` ${escapeXmlTextAndAttribute(value)}`; return insertAndroidPrefEntry(removeAndroidPrefEntry(xml, key), entry); } function upsertAndroidBooleanPref(xml: string, key: string, value: boolean): string { - const entry = ` `; + const entry = ` `; return insertAndroidPrefEntry(removeAndroidPrefEntry(xml, key), entry); } @@ -310,15 +311,6 @@ function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function escapeXmlText(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - function isAndroidRunAsDeniedOutput(stdout: string, stderr: string): boolean { const output = `${stdout}\n${stderr}`.toLowerCase(); return [ diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index e8a96259c3..71c7ac46e4 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -5,11 +5,7 @@ import { cleanupStaleAppLogProcesses } from '../app-log-process.ts'; import { resolveDaemonPaths, resolveDaemonServerMode } from '../config.ts'; import { createDaemonHttpServer } from './http-server.ts'; import { trackDownloadableArtifact } from '../artifact-tracking.ts'; -import { listCloudWebDriverArtifactsFromEnv } from '../../cloud-webdriver/provider-registry.ts'; -import { - composeCloudArtifactProviders, - createProviderDeviceRuntimeRequestProviders, -} from '../../provider-device-runtime.ts'; +import { createProviderDeviceRuntimeRequestProviders } from '../../provider-device-runtime.ts'; import { createDefaultProviderDeviceRuntimes, DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, @@ -185,10 +181,7 @@ export async function startDaemonRuntime( void expiredProviderLeaseReleaser.release(lease); }, }); - const cloudArtifactProvider = composeCloudArtifactProviders( - providerRuntimeProviders.cloudArtifactProvider, - { listCloudArtifacts: (query) => listCloudWebDriverArtifactsFromEnv(query, env) }, - ); + const cloudArtifactProvider = providerRuntimeProviders.cloudArtifactProvider; const dispatchRequest = createRequestHandler({ logPath, diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index c0c58ef405..5484c40368 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -1,5 +1,6 @@ import type { RawSnapshotNode, Rect, SnapshotOptions } from '@agent-device/kernel/snapshot'; import { parseBounds } from '@agent-device/kernel/bounds'; +import { decodeXmlCharacterReferences } from '@agent-device/xml'; import { isScrollableType } from '../../utils/scrollable.ts'; import { intersectArea } from '../../utils/screenshot-geometry.ts'; import { @@ -370,7 +371,7 @@ function readNextXmlAttribute( if (valueEnd < 0 || valueEnd >= end) return undefined; return { name, - value: decodeXmlAttributeValue(node.slice(valueStart, valueEnd)), + value: decodeXmlCharacterReferences(node.slice(valueStart, valueEnd)), nextCursor: valueEnd + 1, }; } @@ -405,74 +406,6 @@ function isXmlAttributeNameTerminator(char: string): boolean { return char === '=' || char === '/' || char === '>' || isXmlWhitespace(char); } -function decodeXmlAttributeValue(value: string): string { - let decoded = ''; - let cursor = 0; - while (cursor < value.length) { - const entityStart = value.indexOf('&', cursor); - if (entityStart < 0) { - decoded += value.slice(cursor); - break; - } - decoded += value.slice(cursor, entityStart); - const entityEnd = value.indexOf(';', entityStart + 1); - if (entityEnd < 0) { - decoded += value.slice(entityStart); - break; - } - const rawEntity = value.slice(entityStart + 1, entityEnd); - decoded += decodeXmlEntity(rawEntity) ?? value.slice(entityStart, entityEnd + 1); - cursor = entityEnd + 1; - } - return decoded; -} - -function decodeXmlEntity(entity: string): string | undefined { - switch (entity) { - case 'amp': - return '&'; - case 'lt': - return '<'; - case 'gt': - return '>'; - case 'quot': - return '"'; - case 'apos': - return "'"; - default: - return decodeNumericXmlEntity(entity); - } -} - -function decodeNumericXmlEntity(entity: string): string | undefined { - if (!entity.startsWith('#')) return undefined; - const radix = entity[1]?.toLowerCase() === 'x' ? 16 : 10; - const digits = radix === 16 ? entity.slice(2) : entity.slice(1); - if (!digits || !isValidNumericEntityDigits(digits, radix)) return undefined; - const codePoint = Number.parseInt(digits, radix); - if (!Number.isFinite(codePoint)) return undefined; - try { - return String.fromCodePoint(codePoint); - } catch { - return undefined; - } -} - -function isValidNumericEntityDigits(digits: string, radix: 10 | 16): boolean { - for (const digit of digits) { - const code = digit.charCodeAt(0); - const isDecimal = code >= 48 && code <= 57; - if (radix === 10) { - if (!isDecimal) return false; - continue; - } - const isUpperHex = code >= 65 && code <= 70; - const isLowerHex = code >= 97 && code <= 102; - if (!isDecimal && !isUpperHex && !isLowerHex) return false; - } - return true; -} - function readXmlAttr(attrs: Map, name: string): string | null { return attrs.get(name) ?? null; } diff --git a/src/platforms/apple/core/perf-frame.ts b/src/platforms/apple/core/perf-frame.ts index 5d4065c9bf..60620e5128 100644 --- a/src/platforms/apple/core/perf-frame.ts +++ b/src/platforms/apple/core/perf-frame.ts @@ -1,5 +1,5 @@ import { roundOneDecimal, roundPercent } from '../../perf-utils.ts'; -import { parseXmlDocumentSync, type XmlNode } from './xml.ts'; +import { parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; import { findAllXmlNodes, findFirstXmlNode, diff --git a/src/platforms/apple/core/perf-xctrace.ts b/src/platforms/apple/core/perf-xctrace.ts index 1445e0dccf..4868325df5 100644 --- a/src/platforms/apple/core/perf-xctrace.ts +++ b/src/platforms/apple/core/perf-xctrace.ts @@ -10,6 +10,7 @@ import { type PublicPlatform, } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { parseXmlDocumentSync } from '@agent-device/xml'; import { execFailureDetails, requireExecSuccess, @@ -28,7 +29,6 @@ import { resolveIosDevicePerfTarget, } from './perf.ts'; import { runXcrun } from './tool-provider.ts'; -import { parseXmlDocumentSync } from './xml.ts'; const IOS_DEVICE_PERF_EXPORT_TIMEOUT_MS = 15_000; const IOS_DEVICE_TRACE_RECORD_MAX_ATTEMPTS = 3; diff --git a/src/platforms/apple/core/perf-xml.ts b/src/platforms/apple/core/perf-xml.ts index e4e52f8691..8b25977ee7 100644 --- a/src/platforms/apple/core/perf-xml.ts +++ b/src/platforms/apple/core/perf-xml.ts @@ -1,4 +1,4 @@ -import type { XmlNode } from './xml.ts'; +import type { XmlNode } from '@agent-device/xml'; export function findFirstXmlNode( nodes: XmlNode[], diff --git a/src/platforms/apple/core/perf.ts b/src/platforms/apple/core/perf.ts index 969c342e26..3117b10efc 100644 --- a/src/platforms/apple/core/perf.ts +++ b/src/platforms/apple/core/perf.ts @@ -10,6 +10,7 @@ import { type PublicPlatform, } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; import { execFailureDetails, requireExecSuccess, type ExecResult } from '../../../utils/exec.ts'; import { splitNonEmptyTrimmedLines } from '../../../utils/parsing.ts'; import { roundPercent } from '../../perf-utils.ts'; @@ -20,7 +21,6 @@ import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; import { readInfoPlistString } from './plist.ts'; import { buildSimctlArgsForDevice } from './simctl.ts'; import { runAppleToolCommand, runXcrun } from './tool-provider.ts'; -import { parseXmlDocumentSync, type XmlNode } from './xml.ts'; import { findAllXmlNodes, findFirstXmlNode, diff --git a/src/platforms/apple/core/plist-xml.ts b/src/platforms/apple/core/plist-xml.ts new file mode 100644 index 0000000000..c81715709b --- /dev/null +++ b/src/platforms/apple/core/plist-xml.ts @@ -0,0 +1,19 @@ +import type { XmlNode } from '@agent-device/xml'; + +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); + } +} diff --git a/src/platforms/apple/core/plist.ts b/src/platforms/apple/core/plist.ts index 490436de99..e346a8d402 100644 --- a/src/platforms/apple/core/plist.ts +++ b/src/platforms/apple/core/plist.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs'; +import { parseXmlDocumentSync } from '@agent-device/xml'; import { readApplePlistJson, runAppleToolCommand } from './tool-provider.ts'; -import { parseXmlDocumentSync, visitXmlPlistEntries } from './xml.ts'; +import { visitXmlPlistEntries } from './plist-xml.ts'; export async function readInfoPlistString( infoPlistPath: string, diff --git a/src/platforms/apple/core/runner/runner-usbmux-protocol.ts b/src/platforms/apple/core/runner/runner-usbmux-protocol.ts index c5ebb93c1c..f49004ddc9 100644 --- a/src/platforms/apple/core/runner/runner-usbmux-protocol.ts +++ b/src/platforms/apple/core/runner/runner-usbmux-protocol.ts @@ -1,8 +1,8 @@ import net, { type Socket } from 'node:net'; import { AppError } from '@agent-device/kernel/errors'; +import { escapeXmlTextAndAttribute, parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; import { createRequestCanceledError } from '../../../../request/cancel.ts'; import { Deadline } from '../../../../utils/retry.ts'; -import { parseXmlDocumentSync, type XmlNode } from '../../../../utils/xml.ts'; const USBMUX_HEADER_BYTES = 16; const USBMUX_PROTOCOL_VERSION = 1; @@ -205,8 +205,8 @@ function buildPlistMessage( const body = entries .map(([key, value]) => typeof value === 'number' - ? `${escapeXml(key)}${value}` - : `${escapeXml(key)}${escapeXml(value)}`, + ? `${escapeXmlTextAndAttribute(key)}${value}` + : `${escapeXmlTextAndAttribute(key)}${escapeXmlTextAndAttribute(value)}`, ) .join(''); return `${body}`; @@ -267,12 +267,3 @@ function requireTimeRemaining(timeoutMs: number, action: string): void { timeoutMs, }); } - -function escapeXml(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} diff --git a/src/platforms/apple/core/runner/runner-xctestrun-products.ts b/src/platforms/apple/core/runner/runner-xctestrun-products.ts index cddb1b5610..d748b6a256 100644 --- a/src/platforms/apple/core/runner/runner-xctestrun-products.ts +++ b/src/platforms/apple/core/runner/runner-xctestrun-products.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { readApplePlistJson } from '../tool-provider.ts'; -import { parseXmlDocumentSync, visitXmlPlistEntries, type XmlNode } from '../xml.ts'; +import { parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; +import { visitXmlPlistEntries } from '../plist-xml.ts'; import { isRecord } from '../../../../utils/parsing.ts'; const XCTESTRUN_PRODUCT_REFERENCE_KEYS = new Set([ diff --git a/src/platforms/apple/core/xml.ts b/src/platforms/apple/core/xml.ts deleted file mode 100644 index 0f909341fa..0000000000 --- a/src/platforms/apple/core/xml.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../../utils/xml.ts'; diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index c89692b8f0..d3793bdf29 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -194,24 +194,6 @@ function uniqueProviderIds(providerIds: readonly string[]): string[] { return [...new Set(providerIds)]; } -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 { diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 42c30c9325..bcfbfdb2f4 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,21 +1,20 @@ -import { createDefaultCloudWebDriverProviderRuntimes } from './cloud-webdriver/provider-runtimes.ts'; -import { CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS } from './cloud-webdriver/provider-definitions.ts'; -import type { DefaultCloudWebDriverProviderRuntimeEnv } from './cloud-webdriver/provider-runtimes.ts'; +import type { DefaultCloudWebDriverProviderRuntimeEnv } from '@agent-device/provider-webdriver'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import { LIMRUN_PROVIDER } from './providers/limrun/device.ts'; +import { providerWebDriver } from './provider-webdriver.ts'; export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & NodeJS.ProcessEnv; export const DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS = [ - ...CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS.map((definition) => definition.provider), + ...providerWebDriver.providerIds, LIMRUN_PROVIDER, ] as const; export async function createDefaultProviderDeviceRuntimes( env: DefaultProviderDeviceRuntimeEnv = process.env, ): Promise { - const runtimes = createDefaultCloudWebDriverProviderRuntimes(env); + const runtimes = providerWebDriver.createDefaultRuntimes(env); const apiKey = env.LIMRUN_API_KEY?.trim(); if (!apiKey) return runtimes; diff --git a/src/provider-webdriver.test.ts b/src/provider-webdriver.test.ts new file mode 100644 index 0000000000..86b640d714 --- /dev/null +++ b/src/provider-webdriver.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'vitest'; +import { providerWebDriver } from './provider-webdriver.ts'; + +test('root provider facade runs AWS artifact lookup through the host command adapter', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-provider-webdriver-')); + const awsPath = path.join(tempDir, 'aws'); + const callsPath = path.join(tempDir, 'aws-calls.ndjson'); + const previousPath = process.env.PATH; + const previousCallsPath = process.env.AGENT_DEVICE_TEST_AWS_CALLS_PATH; + fs.writeFileSync( + awsPath, + [ + '#!/usr/bin/env node', + "const fs = require('node:fs');", + 'fs.appendFileSync(process.env.AGENT_DEVICE_TEST_AWS_CALLS_PATH, `${JSON.stringify(process.argv.slice(2))}\\n`);', + "process.stdout.write(JSON.stringify({ artifacts: [{ arn: 'artifact-1', name: 'video', type: 'VIDEO', url: 'https://aws.example/video.mp4' }] }));", + ].join('\n'), + ); + fs.chmodSync(awsPath, 0o755); + process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_AWS_CALLS_PATH = callsPath; + + try { + const result = await providerWebDriver.listArtifactsFromEnv( + { + provider: 'aws-device-farm', + providerSessionId: 'arn:aws:devicefarm:us-west-2:123:session/project/session/1', + }, + { AWS_REGION: 'us-west-2' }, + ); + + assert.equal(result?.provider, 'aws-device-farm'); + assert.equal(result?.status, 'ready'); + assert.deepEqual( + result?.cloudArtifacts.map(({ kind, url }) => ({ kind, url })), + [ + { kind: 'video', url: 'https://aws.example/video.mp4' }, + { kind: 'video', url: 'https://aws.example/video.mp4' }, + ], + ); + const calls = fs + .readFileSync(callsPath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as string[]) + .sort((left, right) => (left[7] ?? '').localeCompare(right[7] ?? '')); + assert.deepEqual( + calls.map((args) => args.slice(0, 6)), + [ + [ + 'devicefarm', + 'list-artifacts', + '--region', + 'us-west-2', + '--arn', + result?.providerSessionId, + ], + [ + 'devicefarm', + 'list-artifacts', + '--region', + 'us-west-2', + '--arn', + result?.providerSessionId, + ], + ], + ); + assert.deepEqual( + calls.map((args) => args.slice(6)), + [ + ['--type', 'FILE', '--output', 'json'], + ['--type', 'LOG', '--output', 'json'], + ], + ); + } finally { + restoreEnv('PATH', previousPath); + restoreEnv('AGENT_DEVICE_TEST_AWS_CALLS_PATH', previousCallsPath); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} diff --git a/src/provider-webdriver.ts b/src/provider-webdriver.ts new file mode 100644 index 0000000000..34712a5d26 --- /dev/null +++ b/src/provider-webdriver.ts @@ -0,0 +1,11 @@ +import { createProviderWebDriver } from '@agent-device/provider-webdriver'; +import { runCmd } from './utils/exec.ts'; +import { readVersion } from './utils/version.ts'; + +export const providerWebDriver = createProviderWebDriver({ + clientVersion: readVersion(), + runHostCommand: async (command, args) => { + const result = await runCmd(command, [...args], { maxBuffer: 10 * 1024 * 1024 }); + return { stdout: result.stdout }; + }, +}); diff --git a/src/replay/test/reporters/format.ts b/src/replay/test/reporters/format.ts index c9efac2e92..496f5dc6e7 100644 --- a/src/replay/test/reporters/format.ts +++ b/src/replay/test/reporters/format.ts @@ -119,15 +119,6 @@ export function formatJUnitSeconds(durationMs: number): string { return (Math.max(0, durationMs) / 1000).toFixed(3); } -export function xmlEscape(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - export function isDefinedString(value: string | undefined): value is string { return value !== undefined; } diff --git a/src/replay/test/reporters/junit.ts b/src/replay/test/reporters/junit.ts index 8b96cdc51d..33ff0312b8 100644 --- a/src/replay/test/reporters/junit.ts +++ b/src/replay/test/reporters/junit.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { ReplaySuiteResult, ReplaySuiteTestResult } from '@agent-device/contracts/replay'; import { AppError } from '@agent-device/kernel/errors'; +import { escapeXmlTextAndAttribute } from '@agent-device/xml'; import type { ReplayTestReporter } from './types.ts'; import { appendOptionalLine, @@ -15,7 +16,6 @@ import { replayArtifactsLine, replayTestCaseName, replayTestWarningLines, - xmlEscape, type FailedReplayTestResult, } from './format.ts'; @@ -67,11 +67,11 @@ function buildReplayJunitXml(suite: ReplaySuiteResult): string { } function renderJUnitTestCase(test: ReplaySuiteTestResult): string[] { - const name = xmlEscape(replayTestCaseName(test)); - const className = xmlEscape( + const name = escapeXmlTextAndAttribute(replayTestCaseName(test)); + const className = escapeXmlTextAndAttribute( `${path.dirname(test.file) === '.' ? test.file : path.dirname(test.file)}${formatReplayTestShardSuffix(test)}`, ); - const file = xmlEscape(test.file); + const file = escapeXmlTextAndAttribute(test.file); const time = formatJUnitSeconds(test.durationMs); const lines = [ ` `, @@ -79,15 +79,15 @@ function renderJUnitTestCase(test: ReplaySuiteTestResult): string[] { if (test.status === 'failed') { lines.push( - ` ${xmlEscape(buildFailureDetails(test))}`, + ` ${escapeXmlTextAndAttribute(buildFailureDetails(test))}`, ); } else if (test.status === 'skipped') { - lines.push(` `); + lines.push(` `); } const systemOut = buildSystemOut(test); if (systemOut) { - lines.push(` ${xmlEscape(systemOut)}`); + lines.push(` ${escapeXmlTextAndAttribute(systemOut)}`); } lines.push(' '); diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts index ab3778bfb3..eb25394f4e 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -4,22 +4,16 @@ import type { IncomingHttpHeaders } 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'; + CLOUD_WEBDRIVER_PROVIDERS, + createProviderWebDriver, + type RunHostCommand, +} from '@agent-device/provider-webdriver'; +import type { + DeviceLease, + LeaseLifecycleContext, + ProviderDeviceRuntime, +} from '@agent-device/contracts/device'; import type { CloudArtifactsResult } from '@agent-device/contracts/observability'; -import type { DeviceLease } from '@agent-device/contracts/device'; -import { withCommandExecutorOverride } from '../../../src/utils/exec.ts'; import { withProviderScenarioResource, withProviderScenarioTempDir } from './harness.ts'; import { CloudWebDriverTestServer, @@ -29,31 +23,43 @@ import { type StartedCloudWebDriverTestServer, } from './cloud-webdriver-test-server.ts'; -test('BrowserStack adapter prepares App Automate capabilities and uploads install artifacts', async () => { +const CLIENT_VERSION = '0.20.3-test'; + +test('BrowserStack facade prepares capabilities, uploads apps, and returns 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}`, + const provider = createProviderWebDriver({ + clientVersion: CLIENT_VERSION, + runHostCommand: unexpectedHostCommand, }); + const runtime = runtimeFor( + provider.createDefaultRuntimes({ + BROWSERSTACK_USERNAME: 'user', + BROWSERSTACK_ACCESS_KEY: 'key', + BROWSERSTACK_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`, + BROWSERSTACK_APP_UPLOAD_ENDPOINT: `${server.url}/app-automate/upload`, + BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `${server.url}/app-automate/sessions`, + }), + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + ); + const lease = makeLease(CLOUD_WEBDRIVER_PROVIDERS.browserStack); try { - await runtime.leaseLifecycle.allocate?.(lease); + const allocation = await runtime.leaseLifecycle.allocate?.( + lease, + browserStackContext(lease), + ); + assert.equal(allocation?.providerSessionId, 'wd-1'); + assert.equal(operationSupport(allocation, 'snapshot'), 'partial'); + assert.equal(operationSupport(allocation, 'install'), 'partial'); + assert.equal(operationSupport(allocation, 'artifacts'), 'supported'); + assert.equal(operationSupport(allocation, 'nativeSnapshotBackend'), 'unsupported'); + assert.match(operationNote(allocation, 'portReverse') ?? '', /BrowserStack Local/); + const [device] = (await runtime.deviceInventoryProvider({ - leaseProvider: 'browserstack', + leaseProvider: runtime.provider, leaseId: lease.leaseId, platform: 'android', })) ?? []; @@ -69,57 +75,107 @@ test('BrowserStack adapter prepares App Automate capabilities and uploads instal } 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 () => { +test('AWS Device Farm facade uses the injected host-command capability for its full lifecycle', 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, + const host = new FakeAwsHostCommand(`${server.url}/wd/hub/`); + const provider = createProviderWebDriver({ + clientVersion: CLIENT_VERSION, + runHostCommand: host.run, }); + const runtime = runtimeFor( + provider.createDefaultRuntimes({ AWS_REGION: 'us-west-2' }), + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + ); + const lease = makeLease(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm); try { - const allocation = await runtime.leaseLifecycle.allocate?.(lease); - assert.equal(allocation?.awsDeviceFarmSessionArn, client.sessionArn); + const allocation = await runtime.leaseLifecycle.allocate?.(lease, awsContext(lease)); + assert.equal(allocation?.awsDeviceFarmSessionArn, host.sessionArn); + assert.equal(operationSupport(allocation, 'install'), 'unsupported'); + assert.equal(operationSupport(allocation, 'artifacts'), 'supported'); + assert.equal(operationSupport(allocation, 'nativeSnapshotBackend'), 'unsupported'); + assert.match(operationNote(allocation, 'install') ?? '', /appArn/); + const release = await runtime.leaseLifecycle.release?.(lease); - assert.equal( - (release?.cloudArtifacts as CloudArtifactsResult | undefined)?.cloudArtifacts.length, - 3, + assert.deepEqual( + (release?.cloudArtifacts as CloudArtifactsResult | undefined)?.cloudArtifacts.map( + (artifact) => artifact.kind, + ), + ['video', 'device-log', 'appium-log'], ); } 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.deepEqual(host.calls, [ + [ + 'aws', + 'devicefarm', + 'create-remote-access-session', + '--region', + 'us-west-2', + '--project-arn', + 'arn:aws:devicefarm:us-west-2:123:project/project-id', + '--device-arn', + 'arn:aws:devicefarm:us-west-2::device/device-id', + '--name', + lease.leaseId, + '--output', + 'json', + ], + [ + 'aws', + 'devicefarm', + 'get-remote-access-session', + '--region', + 'us-west-2', + '--arn', + host.sessionArn, + '--output', + 'json', + ], + [ + 'aws', + 'devicefarm', + 'stop-remote-access-session', + '--region', + 'us-west-2', + '--arn', + host.sessionArn, + '--output', + 'json', + ], + [ + 'aws', + 'devicefarm', + 'list-artifacts', + '--region', + 'us-west-2', + '--arn', + host.sessionArn, + '--type', + 'FILE', + '--output', + 'json', + ], + [ + 'aws', + 'devicefarm', + 'list-artifacts', + '--region', + 'us-west-2', + '--arn', + host.sessionArn, + '--type', + 'LOG', + '--output', + 'json', + ], ]); assert.equal(server.calls[0]?.path, '/wd/hub/session'); assertAgentDeviceHeaders(server.calls[0]?.headers); @@ -127,271 +183,104 @@ test('AWS Device Farm adapter selects WebDriver endpoint and stops remote access }); }, 15_000); -test('AWS Device Farm adapter lists artifacts for a released lease without a new provider session', async () => { +test('facade artifact lookup uses released provider ids without allocating a runtime', 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', - pollIntervalMs: 1, + const provider = createProviderWebDriver({ + clientVersion: CLIENT_VERSION, + runHostCommand: unexpectedHostCommand, }); - try { - await runtime.leaseLifecycle.allocate?.(lease); - await runtime.leaseLifecycle.release?.(lease); - const artifacts = await runtime.cloudArtifacts?.listCloudArtifacts?.({ - provider: 'aws-device-farm', - leaseId: lease.leaseId, - }); - assert.equal(artifacts?.status, 'ready'); - assert.equal(artifacts?.cloudArtifacts.length, 3); - assert.equal(artifacts?.providerSessionId, client.sessionArn); - } finally { - await runtime.shutdown(); - } - const createCalls = client.calls.filter((call) => call.startsWith('create:')); - assert.equal(createCalls.length, 1); - }); -}, 15_000); - -test('AWS Device Farm adapter sends the requested platform in WebDriver capabilities', async () => { - await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { - const lease = makeLease('aws-device-farm'); - const client = new FakeAwsDeviceFarmClient(`${server.url}/wd/hub/`, { - name: 'Apple iPhone 13', - platform: 'IOS', - os: '16.0.2', - }); - 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: 'ios', - deviceName: 'AWS Device Farm device', - pollIntervalMs: 1, - }); - try { - await runtime.leaseLifecycle.allocate?.(lease); - } finally { - await runtime.leaseLifecycle.release?.(lease); - await runtime.shutdown(); - } - assert.equal(server.calls[0]?.path, '/wd/hub/session'); - assert.deepEqual(server.calls[0]?.body, { - capabilities: { - alwaysMatch: { - platformName: 'iOS', - 'appium:deviceName': 'Apple iPhone 13', - }, + const result = await provider.listArtifactsFromEnv( + { provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, providerSessionId: 'wd-1' }, + { + BROWSERSTACK_USERNAME: 'user', + BROWSERSTACK_ACCESS_KEY: 'key', + BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `${server.url}/app-automate/sessions`, }, - }); - }); -}, 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'], ); + assertAgentDeviceHeaders(server.calls[0]?.headers); }); }); -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'], - ); -}); +function runtimeFor( + runtimes: readonly ProviderDeviceRuntime[], + provider: string, +): ProviderDeviceRuntime { + const runtime = runtimes.find((candidate) => candidate.provider === provider); + assert.ok(runtime, `missing ${provider} runtime`); + return runtime; +} + +function operationSupport( + allocation: Record | undefined, + operation: string, +): string | undefined { + const capabilities = allocation?.capabilities; + if (!capabilities || typeof capabilities !== 'object') return undefined; + const operations = (capabilities as { operations?: unknown }).operations; + if (!operations || typeof operations !== 'object') return undefined; + const entry = (operations as Record)[operation]; + if (!entry || typeof entry !== 'object') return undefined; + const support = (entry as { support?: unknown }).support; + return typeof support === 'string' ? support : undefined; +} + +function operationNote( + allocation: Record | undefined, + operation: string, +): string | undefined { + const capabilities = allocation?.capabilities; + if (!capabilities || typeof capabilities !== 'object') return undefined; + const operations = (capabilities as { operations?: unknown }).operations; + if (!operations || typeof operations !== 'object') return undefined; + const entry = (operations as Record)[operation]; + if (!entry || typeof entry !== 'object') return undefined; + const note = (entry as { note?: unknown }).note; + return typeof note === 'string' ? note : undefined; +} -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, - }; +function browserStackContext(lease: DeviceLease): LeaseLifecycleContext { + return { + flags: { + platform: 'android', + device: 'Google Pixel 8', + providerApp: 'bs://preuploaded', + providerOsVersion: '14.0', + providerProject: 'agent-device', + providerBuild: `build-${lease.runId}`, + providerSessionName: `session-${lease.leaseId}`, }, - async () => { - await client.createRemoteAccessSession({ - projectArn: 'project', - deviceArn: 'device', - name: 'session', - }); - await client.getRemoteAccessSession('arn'); - await client.stopRemoteAccessSession('arn'); - await client.listArtifacts('arn', 'FILE'); + }; +} + +function awsContext(lease: DeviceLease): LeaseLifecycleContext { + return { + flags: { + platform: 'android', + awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project/project-id', + awsDeviceArn: 'arn:aws:devicefarm:us-west-2::device/device-id', + providerSessionName: lease.leaseId, }, - ); - 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 makeLease(provider: string): DeviceLease { + return { + leaseId: 'lease1', + tenantId: 'team-a', + runId: 'run-a', + clientId: 'client-a', + leaseProvider: provider, + backend: 'android-instance', + deviceKey: 'provider-device-a', + createdAt: 1, + expiresAt: 2, + heartbeatAt: 1, + }; +} function assertBrowserStackCalls( calls: readonly CloudWebDriverHttpCall[], @@ -408,7 +297,7 @@ function assertBrowserStackCalls( app: 'bs://preuploaded', 'bstack:options': { projectName: 'agent-device', - buildName: 'build-run-a', + buildName: `build-${lease.runId}`, sessionName: `session-${lease.leaseId}`, }, }, @@ -433,64 +322,63 @@ function assertCallPathAndHeaders( 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'], ''); + assert.equal(headers?.['x-agent-device-version'], CLIENT_VERSION); } -class FakeAwsDeviceFarmClient implements AwsDeviceFarmClient { +const unexpectedHostCommand: RunHostCommand = async () => { + throw new Error('unexpected host command'); +}; + +class FakeAwsHostCommand { readonly sessionArn = 'arn:aws:devicefarm:session/fake'; - readonly calls: string[] = []; + readonly calls: string[][] = []; private readonly webDriverEndpoint: string; - private readonly device: { name: string; platform: string; os: string }; - constructor( - webDriverEndpoint: string, - device: { name: string; platform: string; os: string } = { - name: 'Google Pixel 8', - platform: 'ANDROID', - os: '14', - }, - ) { + constructor(webDriverEndpoint: string) { this.webDriverEndpoint = webDriverEndpoint; - this.device = device; - } - - 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: this.device, - }; } - async stopRemoteAccessSession(arn: string) { - this.calls.push(`stop:${arn}`); - return { arn, status: 'STOPPING' }; - } + readonly run: RunHostCommand = async (command, args) => { + const call = [command, ...args]; + this.calls.push(call); + const subcommand = args[1]; + if (subcommand === 'create-remote-access-session') { + return this.result({ remoteAccessSession: { arn: this.sessionArn, status: 'PENDING' } }); + } + if (subcommand === 'get-remote-access-session') { + return this.result({ + remoteAccessSession: { + arn: this.sessionArn, + status: 'RUNNING', + endpoints: { appium: this.webDriverEndpoint }, + device: { name: 'Google Pixel 8', platform: 'ANDROID', os: '14' }, + }, + }); + } + if (subcommand === 'stop-remote-access-session') { + return this.result({ + remoteAccessSession: { arn: this.sessionArn, status: 'STOPPING' }, + }); + } + if (subcommand === 'list-artifacts') { + return this.result({ artifacts: this.artifactsFor(args) }); + } + throw new Error(`unexpected AWS command: ${call.join(' ')}`); + }; - async listArtifacts(arn: string, type: 'FILE' | 'LOG' | 'SCREENSHOT') { - this.calls.push(`list:${arn}:${type}`); + private artifactsFor(args: readonly string[]) { + const type = args[args.indexOf('--type') + 1]; if (type === 'FILE') { return [ { - arn: `${arn}/video`, + arn: `${this.sessionArn}/video`, name: 'VIDEO', type: 'VIDEO', extension: 'mp4', url: 'https://aws.example/video.mp4', }, { - arn: `${arn}/device-log`, + arn: `${this.sessionArn}/device-log`, name: 'DEVICE_LOG', type: 'DEVICE_LOG', extension: 'log', @@ -498,66 +386,47 @@ class FakeAwsDeviceFarmClient implements AwsDeviceFarmClient { }, ]; } - 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 []; + return [ + { + arn: `${this.sessionArn}/appium-log`, + name: 'APPIUM_SERVER_OUTPUT', + type: 'APPIUM_SERVER_OUTPUT', + extension: 'log', + url: 'https://aws.example/appium.log', + }, + ]; + } + + private result(value: unknown): { stdout: string } { + return { stdout: JSON.stringify(value) }; } } class FakeCloudProviderServer extends CloudWebDriverTestServer { - sessionFailuresRemaining = 0; - static async start(): Promise> { return await startCloudWebDriverTestServer(new FakeCloudProviderServer()); } protected respond(call: CloudWebDriverHttpCall) { - if (call.method === 'POST' && call.path === '/wd/hub/session') { - if (this.sessionFailuresRemaining > 0) { - this.sessionFailuresRemaining -= 1; - return cloudWebDriverTestJson({ value: { message: 'transient provider failure' } }, 503); - } - return cloudWebDriverTestJson({ value: { sessionId: 'wd-1', capabilities: {} } }); - } - if (call.method === 'POST' && call.path === '/app-automate/upload') { - return cloudWebDriverTestJson({ app_url: 'bs://uploaded-app' }); - } - if (call.method === 'GET' && call.path === '/app-automate/sessions/wd-1.json') { - return cloudWebDriverTestJson({ - 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', - }, - }); + switch (`${call.method} ${call.path}`) { + case 'POST /wd/hub/session': + return cloudWebDriverTestJson({ + value: { sessionId: 'wd-1', capabilities: { platformName: 'Android' } }, + }); + case 'POST /app-automate/upload': + return cloudWebDriverTestJson({ app_url: 'bs://uploaded-app' }); + case 'GET /app-automate/sessions/wd-1.json': + return cloudWebDriverTestJson({ + automation_session: { + video_url: 'https://provider.example/video.mp4', + appium_logs_url: 'https://provider.example/appium.log', + device_logs_url: 'https://provider.example/device.log', + browser_url: 'https://provider.example/session', + public_url: 'https://provider.example/public', + }, + }); + default: + return cloudWebDriverTestJson({ value: null }); } - return cloudWebDriverTestJson({ 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, - }; -} diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-regressions.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-regressions.test.ts new file mode 100644 index 0000000000..705e7e3933 --- /dev/null +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-regressions.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { + CLOUD_WEBDRIVER_PROVIDERS, + createProviderWebDriver, + type RunHostCommand, +} from '@agent-device/provider-webdriver'; +import { withProviderScenarioResource, withProviderScenarioTempDir } from './harness.ts'; +import { + AwsRemoteAccessHost, + PROVIDER_REGRESSION_CLIENT_VERSION, + ProviderRegressionServer, + awsRegressionContext, + browserStackRegressionContext, + providerRegressionLease, + providerRuntimeFor, +} from './cloud-webdriver-regression-fixtures.ts'; + +test('AWS Device Farm endpoint selection skips live-control WebSocket URLs', async () => { + await withProviderScenarioResource(ProviderRegressionServer.start, async (server) => { + const host = new AwsRemoteAccessHost({ + appiumEndpoint: `${server.url}/wd/hub/`, + }); + const runtime = providerRuntimeFor( + createProviderWebDriver({ + clientVersion: PROVIDER_REGRESSION_CLIENT_VERSION, + runHostCommand: host.run, + }).createDefaultRuntimes({ AWS_REGION: 'us-west-2' }), + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + ); + const lease = providerRegressionLease(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm); + try { + await runtime.leaseLifecycle.allocate?.(lease, awsRegressionContext()); + assert.equal(server.calls[0]?.path, '/wd/hub/session'); + assert.equal( + host.calls.filter((call) => call.includes('get-remote-access-session')).length, + 1, + ); + } finally { + await runtime.leaseLifecycle.release?.(lease); + await runtime.shutdown(); + } + }); +}); + +test('WebDriver session creation retries transient provider failures', async () => { + await withProviderScenarioResource(ProviderRegressionServer.start, async (server) => { + server.sessionFailuresRemaining = 1; + const runtime = providerRuntimeFor( + createProviderWebDriver({ + clientVersion: PROVIDER_REGRESSION_CLIENT_VERSION, + runHostCommand: unexpectedHostCommand, + }).createDefaultRuntimes({ + BROWSERSTACK_USERNAME: 'user', + BROWSERSTACK_ACCESS_KEY: 'key', + BROWSERSTACK_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`, + }), + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + ); + const lease = providerRegressionLease(CLOUD_WEBDRIVER_PROVIDERS.browserStack); + try { + const allocation = await runtime.leaseLifecycle.allocate?.( + lease, + browserStackRegressionContext(), + ); + assert.equal(allocation?.providerSessionId, 'wd-regression'); + assert.equal(server.calls.filter((call) => call.path === '/wd/hub/session').length, 2); + } finally { + await runtime.leaseLifecycle.release?.(lease); + await runtime.shutdown(); + } + }); +}); + +test('AWS Device Farm rejects local artifact install until upload support exists', async () => { + await withProviderScenarioResource(ProviderRegressionServer.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 host = new AwsRemoteAccessHost({ + appiumEndpoint: `${server.url}/wd/hub/`, + }); + const runtime = providerRuntimeFor( + createProviderWebDriver({ + clientVersion: PROVIDER_REGRESSION_CLIENT_VERSION, + runHostCommand: host.run, + }).createDefaultRuntimes({ AWS_REGION: 'us-west-2' }), + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + ); + const lease = providerRegressionLease(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm); + try { + await runtime.leaseLifecycle.allocate?.(lease, awsRegressionContext()); + const [device] = + (await runtime.deviceInventoryProvider({ + leaseProvider: runtime.provider, + 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(); + } + }); + }); +}); + +test('AWS Device Farm sends the requested platform in WebDriver capabilities', async () => { + await withProviderScenarioResource(ProviderRegressionServer.start, async (server) => { + const host = new AwsRemoteAccessHost({ + appiumEndpoint: `${server.url}/wd/hub/`, + device: { name: 'Apple iPhone 13', platform: 'IOS', os: '16.0.2' }, + }); + const runtime = providerRuntimeFor( + createProviderWebDriver({ + clientVersion: PROVIDER_REGRESSION_CLIENT_VERSION, + runHostCommand: host.run, + }).createDefaultRuntimes({ AWS_REGION: 'us-west-2' }), + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + ); + const lease = providerRegressionLease(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm); + try { + await runtime.leaseLifecycle.allocate?.(lease, awsRegressionContext('ios')); + assert.deepEqual(server.calls[0]?.body, { + capabilities: { + alwaysMatch: { + platformName: 'iOS', + 'appium:deviceName': 'Apple iPhone 13', + }, + }, + }); + } finally { + await runtime.leaseLifecycle.release?.(lease); + await runtime.shutdown(); + } + }); +}); + +const unexpectedHostCommand: RunHostCommand = async () => { + throw new Error('unexpected host command'); +}; diff --git a/test/integration/provider-scenarios/cloud-webdriver-regression-fixtures.ts b/test/integration/provider-scenarios/cloud-webdriver-regression-fixtures.ts new file mode 100644 index 0000000000..9083457fed --- /dev/null +++ b/test/integration/provider-scenarios/cloud-webdriver-regression-fixtures.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import type { + DeviceLease, + LeaseLifecycleContext, + ProviderDeviceRuntime, +} from '@agent-device/contracts/device'; +import type { RunHostCommand } from '@agent-device/provider-webdriver'; +import { + CloudWebDriverTestServer, + type CloudWebDriverHttpCall, + cloudWebDriverTestJson, + startCloudWebDriverTestServer, + type StartedCloudWebDriverTestServer, +} from './cloud-webdriver-test-server.ts'; + +export const PROVIDER_REGRESSION_CLIENT_VERSION = '0.20.3-regression-test'; + +export class ProviderRegressionServer extends CloudWebDriverTestServer { + sessionFailuresRemaining = 0; + + static async start(): Promise> { + return await startCloudWebDriverTestServer(new ProviderRegressionServer()); + } + + protected respond(call: CloudWebDriverHttpCall) { + if (`${call.method} ${call.path}` === 'POST /wd/hub/session') { + if (this.sessionFailuresRemaining > 0) { + this.sessionFailuresRemaining -= 1; + return cloudWebDriverTestJson({ value: { message: 'transient provider failure' } }, 503); + } + return cloudWebDriverTestJson({ + value: { sessionId: 'wd-regression', capabilities: { platformName: 'Android' } }, + }); + } + return cloudWebDriverTestJson({ value: null }); + } +} + +export class AwsRemoteAccessHost { + readonly sessionArn = 'arn:aws:devicefarm:session/regression'; + readonly calls: string[][] = []; + private readonly appiumEndpoint: string; + private readonly device: { name: string; platform: string; os: string }; + + constructor(options: { + appiumEndpoint: string; + device?: { name: string; platform: string; os: string }; + }) { + this.appiumEndpoint = options.appiumEndpoint; + this.device = options.device ?? { + name: 'Google Pixel 8', + platform: 'ANDROID', + os: '14', + }; + } + + readonly run: RunHostCommand = async (command, args) => { + this.calls.push([command, ...args]); + switch (args[1]) { + case 'create-remote-access-session': + return this.result({ + remoteAccessSession: { arn: this.sessionArn, status: 'PENDING' }, + }); + case 'get-remote-access-session': + return this.result({ + remoteAccessSession: { + arn: this.sessionArn, + status: 'RUNNING', + remoteDebugUrl: 'wss://live-control.example/socket', + endpoints: { + video: 'wss://video.example/socket', + appium: this.appiumEndpoint, + }, + device: this.device, + }, + }); + case 'stop-remote-access-session': + return this.result({ + remoteAccessSession: { arn: this.sessionArn, status: 'STOPPING' }, + }); + case 'list-artifacts': + return this.result({ artifacts: [] }); + default: + throw new Error(`unexpected AWS command: ${command} ${args.join(' ')}`); + } + }; + + private result(value: unknown): { stdout: string } { + return { stdout: JSON.stringify(value) }; + } +} + +export function providerRuntimeFor( + runtimes: readonly ProviderDeviceRuntime[], + provider: string, +): ProviderDeviceRuntime { + const runtime = runtimes.find((candidate) => candidate.provider === provider); + assert.ok(runtime, `missing ${provider} runtime`); + return runtime; +} + +export function providerRegressionLease(provider: string): DeviceLease { + return { + leaseId: 'regression-lease', + tenantId: 'team-a', + runId: 'run-a', + clientId: 'client-a', + leaseProvider: provider, + backend: 'android-instance', + deviceKey: 'provider-device-regression', + createdAt: 1, + expiresAt: 2, + heartbeatAt: 1, + }; +} + +export function browserStackRegressionContext(): LeaseLifecycleContext { + return { + flags: { + platform: 'android', + device: 'Google Pixel 8', + providerApp: 'bs://preuploaded', + providerOsVersion: '14.0', + }, + }; +} + +export function awsRegressionContext( + platform: 'android' | 'ios' = 'android', +): LeaseLifecycleContext { + return { + flags: { + platform, + awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project/project-id', + awsDeviceArn: 'arn:aws:devicefarm:us-west-2::device/device-id', + }, + }; +} diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 59a4e9cec2..7338e51503 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -2,17 +2,16 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; 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 { scrollFrameFromWebDriverSource } from '../../../src/cloud-webdriver/webdriver-scroll-frame.ts'; -import { parseWebDriverSource } from '../../../src/cloud-webdriver/webdriver-source.ts'; -import { CLOUD_WEBDRIVER_PROVIDERS } from '../../../src/cloud-webdriver/providers.ts'; +import { + CLOUD_WEBDRIVER_PROVIDERS, + createProviderWebDriver, +} from '@agent-device/provider-webdriver'; import type { CloudArtifact } from '@agent-device/contracts/observability'; +import type { DeviceLease } from '@agent-device/contracts/device'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; import { createExpiredProviderLeaseReleaser } from '../../../src/daemon/provider-lease-expiry.ts'; -import type { DeviceLease } from '@agent-device/contracts/device'; import type { DaemonRequest } from '../../../src/daemon/types.ts'; -import { assertRpcError, assertRpcOk } from './assertions.ts'; +import { assertRpcOk } from './assertions.ts'; import { createProviderScenarioHarness, withProviderScenarioResource, @@ -27,9 +26,10 @@ import { type StartedCloudWebDriverTestServer, } from './cloud-webdriver-test-server.ts'; -const WEBDRIVER_PROVIDER = 'webdriver-fake'; +const WEBDRIVER_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.browserStack; +const CLIENT_VERSION = '0.20.3-test'; -test('Cloud WebDriver runtime drives provider devices through daemon commands', async () => { +test('packaged Cloud WebDriver facade drives provider devices through daemon commands', async () => { await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => { const { daemon, server } = world; await withProviderScenarioTempDir('agent-device-cloud-webdriver-', async (tempDir) => { @@ -43,6 +43,7 @@ test('Cloud WebDriver runtime drives provider devices through daemon commands', flags: leaseFlags(lease.leaseId), meta: leaseMeta(lease.leaseId), }); + const inferredArtifacts = await daemon.callCommand('artifacts'); const inferredData = assertRpcOk<{ provider?: string; @@ -53,7 +54,7 @@ test('Cloud WebDriver runtime drives provider devices through daemon commands', assert.equal(inferredData.status, 'ready'); assert.equal(inferredData.providerSessionId, 'wd-1'); - world.failNextArtifactLookup(); + server.artifactFailuresRemaining = 1; const unavailableArtifacts = await daemon.callCommand('artifacts'); const unavailableData = assertRpcOk<{ provider?: string; @@ -70,12 +71,12 @@ test('Cloud WebDriver runtime drives provider devices through daemon commands', flags: leaseFlags(lease.leaseId), meta: leaseMeta(lease.leaseId), }); - assertWebDriverCalls(server.calls, lease.leaseId, appPath); + assertWebDriverCalls(server.calls, lease.leaseId); }); }); }, 15_000); -test('Cloud WebDriver release still returns artifacts when WebDriver session delete fails', async () => { +test('packaged Cloud WebDriver release still returns artifacts when session delete fails', async () => { await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => { const { daemon, server } = world; const lease = await allocateWebDriverLease(daemon); @@ -107,7 +108,7 @@ test('Cloud WebDriver release still returns artifacts when WebDriver session del }); }, 15_000); -test('Cloud WebDriver expiry releases the live provider session', async () => { +test('packaged Cloud WebDriver expiry releases the live provider session', async () => { await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => { const lease = await allocateWebDriverLease(world.daemon); const releaser = createExpiredProviderLeaseReleaser({ @@ -130,190 +131,32 @@ test('Cloud WebDriver expiry releases the live provider session', async () => { }); }, 15_000); -test('Cloud WebDriver allocation preserves create-session failure when cleanup fails', async () => { - let cleanupCalled = false; - await withProviderScenarioResource( - () => - createCloudWebDriverWorld({ - cleanup: async () => { - cleanupCalled = true; - throw new Error('provider cleanup failed'); - }, - }), - async (world) => { - const { daemon, server } = world; - server.createSessionFailuresRemaining = 2; - - const allocate = await daemon.callCommand('lease_allocate', [], leaseFlags(), { - meta: leaseMeta(), - }); - - const error = assertRpcError(allocate, 'COMMAND_FAILED', /create session failed/) as { - details?: { cleanupError?: unknown }; - }; - assert.equal(error.details?.cleanupError, 'provider cleanup failed'); - assert.equal(cleanupCalled, true); - }, - ); -}, 15_000); - -test('default BrowserStack provider runtime builds sessions from daemon request profile flags', async () => { +async function createCloudWebDriverWorld() { const server = await FakeWebDriverServer.start(); - const runtimes = createDefaultCloudWebDriverProviderRuntimes({ + const providerWebDriver = createProviderWebDriver({ + clientVersion: CLIENT_VERSION, + runHostCommand: async () => { + throw new Error('BrowserStack scenario must not run host commands'); + }, + }); + const runtimes = providerWebDriver.createDefaultRuntimes({ BROWSERSTACK_USERNAME: 'browser-user', BROWSERSTACK_ACCESS_KEY: 'browser-key', BROWSERSTACK_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`, + BROWSERSTACK_APP_UPLOAD_ENDPOINT: `${server.url}/app-automate/upload`, + BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `${server.url}/app-automate/sessions`, }); 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__"/, - ); -}); - -test('WebDriver scroll frame prefers visible scrollable containers', () => { - assert.deepEqual( - scrollFrameFromWebDriverSource( - '' + - '' + - '' + - '' + - '', - ), - { x: 0, y: 393, width: 1080, height: 1103 }, - ); -}); - -async function createCloudWebDriverWorld( - options: { cleanup?: () => Promise | undefined> } = {}, -) { - 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, - }, - }), - prepareSession: options.cleanup - ? async ({ base }) => ({ ...base, cleanup: options.cleanup }) - : undefined, - 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, providers, - failNextArtifactLookup: () => { - artifactFailuresRemaining += 1; - }, close: async () => { - await runtime.shutdown(); + await Promise.allSettled(runtimes.map(async (runtime) => await runtime.shutdown())); await daemon.close(); await server.close(); }, @@ -347,10 +190,7 @@ function cloudWebDriverScenarioSteps(appPath: string, lease: DeviceLease): Provi name: 'install', command: 'install', positionals: ['com.example.demo', appPath], - expectData: { - platform: 'android', - packageName: 'com.example.demo', - }, + expectData: { platform: 'android', packageName: 'com.example.demo' }, }, { name: 'open', @@ -358,8 +198,8 @@ function cloudWebDriverScenarioSteps(appPath: string, lease: DeviceLease): Provi positionals: ['com.example.demo'], expectData: { platform: 'android', - id: `webdriver-fake:android:${lease.leaseId}`, - serial: `webdriver-fake:android:${lease.leaseId}`, + id: `${WEBDRIVER_PROVIDER}:android:${lease.leaseId}`, + serial: `${WEBDRIVER_PROVIDER}:android:${lease.leaseId}`, }, }, { name: 'click', command: 'click', positionals: ['10', '20'], expectData: { x: 10, y: 20 } }, @@ -430,71 +270,48 @@ function cloudWebDriverScenarioSteps(appPath: string, lease: DeviceLease): Provi ]; } -function assertWebDriverCalls( - calls: readonly CloudWebDriverHttpCall[], - 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/appium/device/activate_app', - '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', - 'POST /wd/hub/session/wd-1/appium/device/hide_keyboard', - 'GET /wd/hub/session/wd-1/source', - '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, { +function assertWebDriverCalls(calls: readonly CloudWebDriverHttpCall[], leaseId: string): void { + const paths = calls.map((call) => `${call.method} ${call.path}`); + for (const expected of [ + 'POST /wd/hub/session', + 'POST /app-automate/upload', + 'POST /wd/hub/session/wd-1/appium/device/install_app', + 'POST /wd/hub/session/wd-1/appium/device/activate_app', + 'POST /wd/hub/session/wd-1/actions', + 'POST /wd/hub/session/wd-1/keys', + 'GET /wd/hub/session/wd-1/source', + 'DELETE /wd/hub/session/wd-1', + 'GET /app-automate/sessions/wd-1.json', + ]) { + assert.ok(paths.includes(expected), `missing WebDriver transcript call: ${expected}`); + } + const create = calls.find((call) => call.path === '/wd/hub/session'); + assert.deepEqual(create?.body, { capabilities: { alwaysMatch: { platformName: 'Android', - 'appium:deviceName': 'BrowserStack Google Pixel 8', - 'appium:automationName': 'UiAutomator2', + 'appium:deviceName': 'Google Pixel 8', + device: 'Google Pixel 8', + os_version: '14.0', + app: 'bs://app-id', 'bstack:options': { + projectName: 'agent-device', buildName: 'run-a', sessionName: leaseId, }, }, }, }); - assert.deepEqual(calls[1]?.body, { appPath }); - assert.deepEqual(calls[2]?.body, { appId: 'com.example.demo' }); - assert.deepEqual(calls[7]?.body, { value: Array.from('hello cloud') }); - assert.equal(calls[9]?.body, undefined); - assert.deepEqual(calls[11]?.body, { - actions: [ - { - type: 'pointer', - id: 'swipe', - parameters: { pointerType: 'touch' }, - actions: [ - { type: 'pointerMove', duration: 0, x: 540, y: 988 }, - { type: 'pointerDown', button: 0 }, - { type: 'pointerMove', duration: 350, x: 540, y: 788 }, - { type: 'pointerUp', button: 0 }, - ], - }, - ], - }); + const install = calls.find((call) => call.path.endsWith('/appium/device/install_app')); + assert.deepEqual(install?.body, { appPath: 'bs://uploaded-app' }); 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'], ''); + assert.equal(call.headers['x-agent-device-version'], CLIENT_VERSION); } } class FakeWebDriverServer extends CloudWebDriverTestServer { - createSessionFailuresRemaining = 0; + artifactFailuresRemaining = 0; sessionDeleteFailuresRemaining = 0; static async start(): Promise> { @@ -502,56 +319,54 @@ class FakeWebDriverServer extends CloudWebDriverTestServer { } protected respond(call: CloudWebDriverHttpCall) { - return respondToFakeWebDriverCall(this, call); + switch (`${call.method} ${call.path}`) { + case 'POST /wd/hub/session': + return cloudWebDriverTestJson({ + value: { sessionId: 'wd-1', capabilities: { platformName: 'Android' } }, + }); + case 'POST /app-automate/upload': + return cloudWebDriverTestJson({ app_url: 'bs://uploaded-app' }); + case 'GET /wd/hub/session/wd-1/source': + return cloudWebDriverTestJson({ value: fakeWebDriverSource() }); + case 'GET /wd/hub/session/wd-1/window/rect': + return cloudWebDriverTestJson({ value: { x: 0, y: 0, width: 1080, height: 1920 } }); + case 'DELETE /wd/hub/session/wd-1/actions': + return cloudWebDriverTestJson( + { value: { message: 'The requested resource could not be found.' } }, + 500, + ); + case 'DELETE /wd/hub/session/wd-1': + return this.deleteSessionResponse(); + case 'GET /app-automate/sessions/wd-1.json': + return this.artifactResponse(); + default: + return cloudWebDriverTestJson({ value: null }); + } } -} -function respondToFakeWebDriverCall( - server: FakeWebDriverServer, - call: CloudWebDriverHttpCall, -): ReturnType { - switch (`${call.method} ${call.path}`) { - case 'POST /wd/hub/session': - return fakeCreateSessionResponse(server); - case 'GET /wd/hub/session/wd-1/source': - return cloudWebDriverTestJson({ value: fakeWebDriverSource() }); - case 'GET /wd/hub/session/wd-1/window/rect': - return cloudWebDriverTestJson({ value: { x: 0, y: 0, width: 1080, height: 1920 } }); - case 'DELETE /wd/hub/session/wd-1/actions': - return cloudWebDriverTestJson( - { value: { message: 'The requested resource could not be found.' } }, - 500, - ); - case 'DELETE /wd/hub/session/wd-1': - return fakeDeleteSessionResponse(server); - default: - return cloudWebDriverTestJson({ value: null }); - } -} - -function fakeCreateSessionResponse( - server: FakeWebDriverServer, -): ReturnType { - if (server.createSessionFailuresRemaining > 0) { - server.createSessionFailuresRemaining -= 1; - return cloudWebDriverTestJson({ value: { message: 'create session failed' } }, 500); + private deleteSessionResponse() { + if (this.sessionDeleteFailuresRemaining > 0) { + this.sessionDeleteFailuresRemaining -= 1; + return cloudWebDriverTestJson({ value: { message: 'stale webdriver session' } }, 500); + } + return cloudWebDriverTestJson({ value: null }); } - return cloudWebDriverTestJson({ - value: { - sessionId: 'wd-1', - capabilities: { platformName: 'Android' }, - }, - }); -} -function fakeDeleteSessionResponse( - server: FakeWebDriverServer, -): ReturnType { - if (server.sessionDeleteFailuresRemaining > 0) { - server.sessionDeleteFailuresRemaining -= 1; - return cloudWebDriverTestJson({ value: { message: 'stale webdriver session' } }, 500); + private artifactResponse() { + if (this.artifactFailuresRemaining > 0) { + this.artifactFailuresRemaining -= 1; + return cloudWebDriverTestJson({ message: 'provider artifact lookup failed' }, 500); + } + return cloudWebDriverTestJson({ + automation_session: { + video_url: 'https://provider.example/video.mp4', + appium_logs_url: 'https://provider.example/appium.log', + device_logs_url: 'https://provider.example/device.log', + browser_url: 'https://provider.example/session', + public_url: 'https://provider.example/public', + }, + }); } - return cloudWebDriverTestJson({ value: null }); } function fakeWebDriverSource(): string { @@ -570,6 +385,12 @@ function leaseFlags(leaseId?: string): DaemonRequest['flags'] { runId: 'run-a', leaseId, leaseProvider: WEBDRIVER_PROVIDER, + device: 'Google Pixel 8', + providerApp: 'bs://app-id', + providerOsVersion: '14.0', + providerProject: 'agent-device', + providerBuild: 'run-a', + providerSessionName: leaseId, }; }