From 6e0dc1adfa2b37f94b4cea0cd411be557452e40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 17:25:40 +0200 Subject: [PATCH 1/2] refactor: extract webdriver provider package --- CONTEXT.md | 11 +- fallow-baselines/health.json | 2 +- package.json | 3 +- packages/contracts/src/device-provider.ts | 2 +- packages/provider-webdriver/package.json | 17 + .../src}/artifact-results.ts | 0 .../src}/aws-device-farm-artifacts.ts | 0 .../src}/aws-device-farm.ts | 32 +- .../provider-webdriver/src}/browserstack.ts | 10 +- .../provider-webdriver/src}/capabilities.ts | 0 .../provider-webdriver/src/dependencies.ts | 13 + packages/provider-webdriver/src/index.ts | 46 ++ .../src/provider-definitions.ts | 329 +++++++++ .../provider-webdriver/src}/providers.ts | 0 .../provider-webdriver/src/request-headers.ts | 8 + .../src}/runtime-helpers.ts | 0 .../provider-webdriver/src/runtime.test.ts | 63 ++ .../provider-webdriver/src}/runtime.ts | 2 + .../src}/webdriver-client.ts | 5 +- .../src}/webdriver-gestures.ts | 0 .../src}/webdriver-interactor.ts | 0 .../src}/webdriver-scroll-frame.ts | 0 .../src/webdriver-source.test.ts | 31 + .../src}/webdriver-source.ts | 10 +- .../src}/webdriver-utils.test.ts | 2 +- .../src}/webdriver-utils.ts | 0 .../provider-webdriver/src/webdriver-xml.ts | 215 ++++++ packages/provider-webdriver/tsconfig.json | 12 + pnpm-lock.yaml | 12 + scripts/layering/check.ts | 9 +- scripts/layering/model.ts | 22 +- scripts/layering/package-boundaries.test.ts | 23 + src/cli/commands/connection-runtime.ts | 2 +- src/cli/connection/cloud-webdriver-profile.ts | 6 +- src/cli/connection/provider-policy.ts | 2 +- src/cloud-webdriver/provider-definitions.ts | 311 -------- src/cloud-webdriver/provider-registry.ts | 21 - src/cloud-webdriver/provider-runtimes.ts | 13 - src/cloud-webdriver/request-headers.ts | 10 - src/daemon/server/daemon-runtime.ts | 4 +- src/provider-device-runtimes.ts | 9 +- src/provider-webdriver.ts | 11 + .../cloud-webdriver-provider-adapters.test.ts | 671 +++++++----------- .../cloud-webdriver-runtime.test.ts | 385 +++------- 44 files changed, 1223 insertions(+), 1101 deletions(-) create mode 100644 packages/provider-webdriver/package.json rename {src/cloud-webdriver => packages/provider-webdriver/src}/artifact-results.ts (100%) rename {src/cloud-webdriver => packages/provider-webdriver/src}/aws-device-farm-artifacts.ts (100%) rename {src/cloud-webdriver => packages/provider-webdriver/src}/aws-device-farm.ts (93%) rename {src/cloud-webdriver => packages/provider-webdriver/src}/browserstack.ts (96%) rename {src/cloud-webdriver => packages/provider-webdriver/src}/capabilities.ts (100%) create mode 100644 packages/provider-webdriver/src/dependencies.ts create mode 100644 packages/provider-webdriver/src/index.ts create mode 100644 packages/provider-webdriver/src/provider-definitions.ts rename {src/cloud-webdriver => packages/provider-webdriver/src}/providers.ts (100%) create mode 100644 packages/provider-webdriver/src/request-headers.ts rename {src/cloud-webdriver => packages/provider-webdriver/src}/runtime-helpers.ts (100%) create mode 100644 packages/provider-webdriver/src/runtime.test.ts rename {src/cloud-webdriver => packages/provider-webdriver/src}/runtime.ts (99%) rename {src/cloud-webdriver => packages/provider-webdriver/src}/webdriver-client.ts (98%) rename {src/cloud-webdriver => packages/provider-webdriver/src}/webdriver-gestures.ts (100%) rename {src/cloud-webdriver => packages/provider-webdriver/src}/webdriver-interactor.ts (100%) rename {src/cloud-webdriver => packages/provider-webdriver/src}/webdriver-scroll-frame.ts (100%) create mode 100644 packages/provider-webdriver/src/webdriver-source.test.ts rename {src/cloud-webdriver => packages/provider-webdriver/src}/webdriver-source.ts (94%) rename {src/cloud-webdriver/__tests__ => packages/provider-webdriver/src}/webdriver-utils.test.ts (89%) rename {src/cloud-webdriver => packages/provider-webdriver/src}/webdriver-utils.ts (100%) create mode 100644 packages/provider-webdriver/src/webdriver-xml.ts create mode 100644 packages/provider-webdriver/tsconfig.json delete mode 100644 src/cloud-webdriver/provider-definitions.ts delete mode 100644 src/cloud-webdriver/provider-registry.ts delete mode 100644 src/cloud-webdriver/provider-runtimes.ts delete mode 100644 src/cloud-webdriver/request-headers.ts create mode 100644 src/provider-webdriver.ts diff --git a/CONTEXT.md b/CONTEXT.md index 8aef40728..92e3e1e9b 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, and the former `cloud-webdriver` leaf lives behind the single `@agent-device/provider-webdriver` facade since W1b; R11 package-boundaries owns both 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 ac677ef8e..b87e45597 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 7d98f3347..350a4f661 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/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,7 @@ "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*", "@agent-device/maestro": "workspace:*", + "@agent-device/provider-webdriver": "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 8d9f4bd6c..457003188 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 000000000..efc7c762b --- /dev/null +++ b/packages/provider-webdriver/package.json @@ -0,0 +1,17 @@ +{ + "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:*" + }, + "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 cce2e9933..83bc3138e 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 c4490e0e4..c41564ffa 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 000000000..95507f93e --- /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 000000000..70bc8a1e4 --- /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 000000000..401c62cef --- /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 000000000..7990f26de --- /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 000000000..88e3a4422 --- /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 2821d424c..73d7c7b39 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 6401505e1..de2134ef4 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 000000000..cc624625f --- /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 94% rename from src/cloud-webdriver/webdriver-source.ts rename to packages/provider-webdriver/src/webdriver-source.ts index a5b8065a3..ae8745754 100644 --- a/src/cloud-webdriver/webdriver-source.ts +++ b/packages/provider-webdriver/src/webdriver-source.ts @@ -1,12 +1,12 @@ 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 { parseWebDriverXml, type WebDriverXmlNode } from './webdriver-xml.ts'; export function parseWebDriverSource(source: string): RawSnapshotNode[] { - let roots: XmlNode[]; + let roots: WebDriverXmlNode[]; try { - roots = parseXmlDocumentSync(source); + roots = parseWebDriverXml(source); } catch (error) { throw new AppError( 'COMMAND_FAILED', @@ -24,7 +24,7 @@ export function parseWebDriverSource(source: string): RawSnapshotNode[] { function appendSourceNodes( nodes: RawSnapshotNode[], - xmlNode: XmlNode, + xmlNode: WebDriverXmlNode, parentIndex?: number, depth = 0, ): void { @@ -40,7 +40,7 @@ function appendSourceNodes( function appendSourceNode( nodes: RawSnapshotNode[], - xmlNode: XmlNode, + xmlNode: WebDriverXmlNode, parentIndex: number | undefined, depth: number, ): number { 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 5b619e0a2..58d326c24 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/src/webdriver-xml.ts b/packages/provider-webdriver/src/webdriver-xml.ts new file mode 100644 index 000000000..0b02b6de3 --- /dev/null +++ b/packages/provider-webdriver/src/webdriver-xml.ts @@ -0,0 +1,215 @@ +export type WebDriverXmlNode = { + name: string; + attributes: Record; + children: WebDriverXmlNode[]; +}; + +const MAX_DOCUMENT_CHARS = 128 * 1024 * 1024; +const MAX_NESTING_DEPTH = 256; +const UNSAFE_ATTRIBUTE_NAMES = new Set([ + '__defineGetter__', + '__defineSetter__', + '__proto__', + 'constructor', + 'prototype', +]); + +export function parseWebDriverXml(source: string): WebDriverXmlNode[] { + if (source.length > MAX_DOCUMENT_CHARS) { + throw new Error( + `XML document exceeds maximum supported size of ${MAX_DOCUMENT_CHARS} characters.`, + ); + } + const roots: WebDriverXmlNode[] = []; + const stack: WebDriverXmlNode[] = []; + let offset = source.charCodeAt(0) === 0xfeff ? 1 : 0; + + while (offset < source.length) { + const opening = source.indexOf('<', offset); + if (opening === -1) break; + offset = opening; + if (source.startsWith('', 'Comment is not closed.'); + continue; + } + if (source.startsWith('', 'Processing instruction is not closed.'); + continue; + } + if (source.startsWith('', 'CDATA section is not closed.'); + continue; + } + if (source.startsWith('= MAX_NESTING_DEPTH) { + throw new Error(`Maximum XML nesting depth of ${MAX_NESTING_DEPTH} exceeded.`); + } + stack.push(parsed.node); + } + offset = parsed.end; + } + + const unclosed = stack.at(-1); + if (unclosed) throw new Error(`Unclosed XML tag <${unclosed.name}>.`); + return roots; +} + +function readOpeningNode( + source: string, + opening: number, +): { node: WebDriverXmlNode; selfClosing: boolean; end: number } { + let cursor = opening + 1; + cursor = skipWhitespace(source, cursor); + const name = readName(source, cursor); + if (!name.value) throw new Error(`Missing XML tag name at offset ${cursor}.`); + cursor = name.end; + const attributes: Record = {}; + + while (cursor < source.length) { + cursor = skipWhitespace(source, cursor); + if (source[cursor] === '>') { + return { + node: { name: name.value, attributes, children: [] }, + selfClosing: false, + end: cursor + 1, + }; + } + if (source[cursor] === '/' && source[cursor + 1] === '>') { + return { + node: { name: name.value, attributes, children: [] }, + selfClosing: true, + end: cursor + 2, + }; + } + const attribute = readName(source, cursor); + if (!attribute.value) throw new Error(`Invalid XML attribute at offset ${cursor}.`); + assertSafeAttributeName(attribute.value); + cursor = skipWhitespace(source, attribute.end); + if (source[cursor] !== '=') { + throw new Error(`Missing value for XML attribute "${attribute.value}".`); + } + cursor = skipWhitespace(source, cursor + 1); + const value = readQuotedValue(source, cursor, attribute.value); + attributes[attribute.value] = decodeXmlEntities(value.value.trim()); + cursor = value.end; + } + throw new Error('Opening XML tag is not closed.'); +} + +function closeNode(source: string, opening: number, stack: WebDriverXmlNode[]): number { + let cursor = skipWhitespace(source, opening + 2); + const name = readName(source, cursor); + cursor = skipWhitespace(source, name.end); + if (source[cursor] !== '>') { + throw new Error(`Closing XML tag is not closed.`); + } + const node = stack.pop(); + if (!node) throw new Error(`Unexpected closing XML tag .`); + if (node.name !== name.value) { + throw new Error(`Expected before .`); + } + return cursor + 1; +} + +function readQuotedValue( + source: string, + offset: number, + attributeName: string, +): { value: string; end: number } { + const quote = source[offset]; + if (quote !== '"' && quote !== "'") { + throw new Error(`XML attribute "${attributeName}" must use a quoted value.`); + } + const end = source.indexOf(quote, offset + 1); + if (end === -1) throw new Error(`XML attribute "${attributeName}" is not closed.`); + return { value: source.slice(offset + 1, end), end: end + 1 }; +} + +function readName(source: string, offset: number): { value: string; end: number } { + let end = offset; + while (end < source.length && /[A-Za-z0-9_.:-]/.test(source[end]!)) end += 1; + return { value: source.slice(offset, end), end }; +} + +function skipWhitespace(source: string, offset: number): number { + let cursor = offset; + while (cursor < source.length && /\s/.test(source[cursor]!)) cursor += 1; + return cursor; +} + +function skipDelimited( + source: string, + offset: number, + delimiter: string, + errorMessage: string, +): number { + const end = source.indexOf(delimiter, offset + delimiter.length); + if (end === -1) throw new Error(errorMessage); + return end + delimiter.length; +} + +function declarationEnd(source: string, offset: number): number { + let quote: string | undefined; + let bracketDepth = 0; + for (let cursor = offset + 2; cursor < source.length; cursor += 1) { + const char = source[cursor]!; + if (quote) { + if (char === quote) quote = undefined; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '[') bracketDepth += 1; + else if (char === ']' && bracketDepth > 0) bracketDepth -= 1; + else if (char === '>' && bracketDepth === 0) return cursor + 1; + } + throw new Error('XML declaration is not closed.'); +} + +function assertSafeAttributeName(name: string): void { + if (UNSAFE_ATTRIBUTE_NAMES.has(name)) { + throw new Error(`Unsupported XML attribute name "${name}".`); + } +} + +function decodeXmlEntities(value: string): string { + return value.replace( + /&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g, + (entity, body: string) => { + switch (body) { + case 'amp': + return '&'; + case 'lt': + return '<'; + case 'gt': + return '>'; + case 'quot': + return '"'; + case 'apos': + return "'"; + default: { + const codePoint = body.startsWith('#x') + ? Number.parseInt(body.slice(2), 16) + : Number(body.slice(1)); + return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff + ? String.fromCodePoint(codePoint) + : entity; + } + } + }, + ); +} diff --git a/packages/provider-webdriver/tsconfig.json b/packages/provider-webdriver/tsconfig.json new file mode 100644 index 000000000..935c871a4 --- /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/pnpm-lock.yaml b/pnpm-lock.yaml index e43bad29d..8c083affb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@agent-device/maestro': specifier: workspace:* version: link:packages/maestro + '@agent-device/provider-webdriver': + specifier: workspace:* + version: link:packages/provider-webdriver '@chenglou/freerange': specifier: ^0.0.1 version: 0.0.1 @@ -99,6 +102,15 @@ 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 + website: devDependencies: '@callstack/rspress-preset': diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 814ca2f63..a40aa8145 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 57e79f66c..f6d9b388a 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,22 @@ 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` and `provider-webdriver` are no longer 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', +]); export type ZoneClassification = 'ranked' | 'unranked' | 'unclassified'; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index e99e2943b..89eeacba4 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -200,6 +200,18 @@ 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', + ]); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/kernel'), 'root must declare the kernel workspace dependency', @@ -212,6 +224,10 @@ 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.deepEqual(checkPackageBoundaries(repoRoot, new Set()), []); }); @@ -228,6 +244,8 @@ 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', ]) { assert.throws( () => import.meta.resolve(deep), @@ -241,4 +259,9 @@ 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, + ); }); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 5ed8fcfe4..c34ce38ea 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 661ff4f42..0a8501e13 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 9c14812b3..7d00acceb 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 4a8cdebd0..000000000 --- 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 f9557be39..000000000 --- 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 851205f12..000000000 --- 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 da4480b7a..000000000 --- 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/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index e8a96259c..eaab28082 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -5,7 +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 { providerWebDriver } from '../../provider-webdriver.ts'; import { composeCloudArtifactProviders, createProviderDeviceRuntimeRequestProviders, @@ -187,7 +187,7 @@ export async function startDaemonRuntime( }); const cloudArtifactProvider = composeCloudArtifactProviders( providerRuntimeProviders.cloudArtifactProvider, - { listCloudArtifacts: (query) => listCloudWebDriverArtifactsFromEnv(query, env) }, + { listCloudArtifacts: (query) => providerWebDriver.listArtifactsFromEnv(query, env) }, ); const dispatchRequest = createRequestHandler({ diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 42c30c932..bcfbfdb2f 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.ts b/src/provider-webdriver.ts new file mode 100644 index 000000000..34712a5d2 --- /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/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts index ab3778bfb..5696aa574 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,41 @@ 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'); + const [device] = (await runtime.deviceInventoryProvider({ - leaseProvider: 'browserstack', + leaseProvider: runtime.provider, leaseId: lease.leaseId, platform: 'android', })) ?? []; @@ -69,43 +73,30 @@ 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'); + const release = await runtime.leaseLifecycle.release?.(lease); assert.equal( (release?.cloudArtifacts as CloudArtifactsResult | undefined)?.cloudArtifacts.length, @@ -114,12 +105,71 @@ test('AWS Device Farm adapter selects WebDriver endpoint and stops remote access } 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 +177,90 @@ 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 () => { - 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, - }); - 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', - }, - }, - }); - }); -}, 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 () => { +test('facade artifact lookup uses released provider ids without allocating a runtime', 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, - }, + const provider = createProviderWebDriver({ + clientVersion: CLIENT_VERSION, + runHostCommand: unexpectedHostCommand, }); - 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/', + 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`, }, - }), - '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; +} -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 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 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 +277,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 +302,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 +366,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-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 59a4e9cec..7338e5150 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, }; } From e1a540aea22a5be05b85ea9c98c0c9079c2d412a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 19:06:51 +0200 Subject: [PATCH 2/2] refactor: consolidate shared XML codec --- CONTEXT.md | 2 +- package.json | 3 +- packages/provider-webdriver/package.json | 3 +- .../src/webdriver-source.ts | 10 +- .../provider-webdriver/src/webdriver-xml.ts | 215 ------------------ packages/xml/package.json | 13 ++ .../xml/src/index.test.ts | 22 +- packages/xml/src/index.ts | 3 + packages/xml/src/internal/entities.ts | 45 ++++ .../xml/src/internal/parser.ts | 73 +----- packages/xml/src/internal/types.ts | 10 + packages/xml/tsconfig.json | 12 + pnpm-lock.yaml | 8 + scripts/layering/model.ts | 3 +- scripts/layering/package-boundaries.test.ts | 27 +++ scripts/layering/package-boundaries.ts | 8 +- src/__tests__/cli-network.test.ts | 7 +- src/__tests__/daemon-entrypoint.test.ts | 80 ++++++- src/daemon/__tests__/runtime-hints.test.ts | 22 ++ src/daemon/runtime-hints.ts | 14 +- src/daemon/server/daemon-runtime.ts | 11 +- src/platforms/android/ui-hierarchy.ts | 71 +----- src/platforms/apple/core/perf-frame.ts | 2 +- src/platforms/apple/core/perf-xctrace.ts | 2 +- src/platforms/apple/core/perf-xml.ts | 2 +- src/platforms/apple/core/perf.ts | 2 +- src/platforms/apple/core/plist-xml.ts | 19 ++ src/platforms/apple/core/plist.ts | 3 +- .../core/runner/runner-usbmux-protocol.ts | 15 +- .../core/runner/runner-xctestrun-products.ts | 3 +- src/platforms/apple/core/xml.ts | 1 - src/provider-device-runtime.ts | 18 -- src/provider-webdriver.test.ts | 92 ++++++++ src/replay/test/reporters/format.ts | 9 - src/replay/test/reporters/junit.ts | 14 +- .../cloud-webdriver-provider-adapters.test.ts | 26 ++- ...oud-webdriver-provider-regressions.test.ts | 148 ++++++++++++ .../cloud-webdriver-regression-fixtures.ts | 138 +++++++++++ 38 files changed, 713 insertions(+), 443 deletions(-) delete mode 100644 packages/provider-webdriver/src/webdriver-xml.ts create mode 100644 packages/xml/package.json rename src/platforms/apple/core/__tests__/xml.test.ts => packages/xml/src/index.test.ts (78%) create mode 100644 packages/xml/src/index.ts create mode 100644 packages/xml/src/internal/entities.ts rename src/utils/xml.ts => packages/xml/src/internal/parser.ts (83%) create mode 100644 packages/xml/src/internal/types.ts create mode 100644 packages/xml/tsconfig.json create mode 100644 src/platforms/apple/core/plist-xml.ts delete mode 100644 src/platforms/apple/core/xml.ts create mode 100644 src/provider-webdriver.test.ts create mode 100644 test/integration/provider-scenarios/cloud-webdriver-provider-regressions.test.ts create mode 100644 test/integration/provider-scenarios/cloud-webdriver-regression-fixtures.ts diff --git a/CONTEXT.md b/CONTEXT.md index 92e3e1e9b..ff8fbbdcc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -270,7 +270,7 @@ 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 } ◄ { 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, and the former `cloud-webdriver` leaf lives behind the single `@agent-device/provider-webdriver` facade since W1b; R11 package-boundaries owns both physical seams) — + `{ 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 diff --git a/package.json b/package.json index 350a4f661..db1a23302 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 packages/provider-webdriver && 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", @@ -249,6 +249,7 @@ "@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/provider-webdriver/package.json b/packages/provider-webdriver/package.json index efc7c762b..35c6b546d 100644 --- a/packages/provider-webdriver/package.json +++ b/packages/provider-webdriver/package.json @@ -6,7 +6,8 @@ "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/kernel": "workspace:*", + "@agent-device/xml": "workspace:*" }, "exports": { ".": { diff --git a/packages/provider-webdriver/src/webdriver-source.ts b/packages/provider-webdriver/src/webdriver-source.ts index ae8745754..fa833796b 100644 --- a/packages/provider-webdriver/src/webdriver-source.ts +++ b/packages/provider-webdriver/src/webdriver-source.ts @@ -1,12 +1,12 @@ import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; import { AppError } from '@agent-device/kernel/errors'; import { parseBounds } from '@agent-device/kernel/bounds'; -import { parseWebDriverXml, type WebDriverXmlNode } from './webdriver-xml.ts'; +import { parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; export function parseWebDriverSource(source: string): RawSnapshotNode[] { - let roots: WebDriverXmlNode[]; + let roots: XmlNode[]; try { - roots = parseWebDriverXml(source); + roots = parseXmlDocumentSync(source); } catch (error) { throw new AppError( 'COMMAND_FAILED', @@ -24,7 +24,7 @@ export function parseWebDriverSource(source: string): RawSnapshotNode[] { function appendSourceNodes( nodes: RawSnapshotNode[], - xmlNode: WebDriverXmlNode, + xmlNode: XmlNode, parentIndex?: number, depth = 0, ): void { @@ -40,7 +40,7 @@ function appendSourceNodes( function appendSourceNode( nodes: RawSnapshotNode[], - xmlNode: WebDriverXmlNode, + xmlNode: XmlNode, parentIndex: number | undefined, depth: number, ): number { diff --git a/packages/provider-webdriver/src/webdriver-xml.ts b/packages/provider-webdriver/src/webdriver-xml.ts deleted file mode 100644 index 0b02b6de3..000000000 --- a/packages/provider-webdriver/src/webdriver-xml.ts +++ /dev/null @@ -1,215 +0,0 @@ -export type WebDriverXmlNode = { - name: string; - attributes: Record; - children: WebDriverXmlNode[]; -}; - -const MAX_DOCUMENT_CHARS = 128 * 1024 * 1024; -const MAX_NESTING_DEPTH = 256; -const UNSAFE_ATTRIBUTE_NAMES = new Set([ - '__defineGetter__', - '__defineSetter__', - '__proto__', - 'constructor', - 'prototype', -]); - -export function parseWebDriverXml(source: string): WebDriverXmlNode[] { - if (source.length > MAX_DOCUMENT_CHARS) { - throw new Error( - `XML document exceeds maximum supported size of ${MAX_DOCUMENT_CHARS} characters.`, - ); - } - const roots: WebDriverXmlNode[] = []; - const stack: WebDriverXmlNode[] = []; - let offset = source.charCodeAt(0) === 0xfeff ? 1 : 0; - - while (offset < source.length) { - const opening = source.indexOf('<', offset); - if (opening === -1) break; - offset = opening; - if (source.startsWith('', 'Comment is not closed.'); - continue; - } - if (source.startsWith('', 'Processing instruction is not closed.'); - continue; - } - if (source.startsWith('', 'CDATA section is not closed.'); - continue; - } - if (source.startsWith('= MAX_NESTING_DEPTH) { - throw new Error(`Maximum XML nesting depth of ${MAX_NESTING_DEPTH} exceeded.`); - } - stack.push(parsed.node); - } - offset = parsed.end; - } - - const unclosed = stack.at(-1); - if (unclosed) throw new Error(`Unclosed XML tag <${unclosed.name}>.`); - return roots; -} - -function readOpeningNode( - source: string, - opening: number, -): { node: WebDriverXmlNode; selfClosing: boolean; end: number } { - let cursor = opening + 1; - cursor = skipWhitespace(source, cursor); - const name = readName(source, cursor); - if (!name.value) throw new Error(`Missing XML tag name at offset ${cursor}.`); - cursor = name.end; - const attributes: Record = {}; - - while (cursor < source.length) { - cursor = skipWhitespace(source, cursor); - if (source[cursor] === '>') { - return { - node: { name: name.value, attributes, children: [] }, - selfClosing: false, - end: cursor + 1, - }; - } - if (source[cursor] === '/' && source[cursor + 1] === '>') { - return { - node: { name: name.value, attributes, children: [] }, - selfClosing: true, - end: cursor + 2, - }; - } - const attribute = readName(source, cursor); - if (!attribute.value) throw new Error(`Invalid XML attribute at offset ${cursor}.`); - assertSafeAttributeName(attribute.value); - cursor = skipWhitespace(source, attribute.end); - if (source[cursor] !== '=') { - throw new Error(`Missing value for XML attribute "${attribute.value}".`); - } - cursor = skipWhitespace(source, cursor + 1); - const value = readQuotedValue(source, cursor, attribute.value); - attributes[attribute.value] = decodeXmlEntities(value.value.trim()); - cursor = value.end; - } - throw new Error('Opening XML tag is not closed.'); -} - -function closeNode(source: string, opening: number, stack: WebDriverXmlNode[]): number { - let cursor = skipWhitespace(source, opening + 2); - const name = readName(source, cursor); - cursor = skipWhitespace(source, name.end); - if (source[cursor] !== '>') { - throw new Error(`Closing XML tag is not closed.`); - } - const node = stack.pop(); - if (!node) throw new Error(`Unexpected closing XML tag .`); - if (node.name !== name.value) { - throw new Error(`Expected before .`); - } - return cursor + 1; -} - -function readQuotedValue( - source: string, - offset: number, - attributeName: string, -): { value: string; end: number } { - const quote = source[offset]; - if (quote !== '"' && quote !== "'") { - throw new Error(`XML attribute "${attributeName}" must use a quoted value.`); - } - const end = source.indexOf(quote, offset + 1); - if (end === -1) throw new Error(`XML attribute "${attributeName}" is not closed.`); - return { value: source.slice(offset + 1, end), end: end + 1 }; -} - -function readName(source: string, offset: number): { value: string; end: number } { - let end = offset; - while (end < source.length && /[A-Za-z0-9_.:-]/.test(source[end]!)) end += 1; - return { value: source.slice(offset, end), end }; -} - -function skipWhitespace(source: string, offset: number): number { - let cursor = offset; - while (cursor < source.length && /\s/.test(source[cursor]!)) cursor += 1; - return cursor; -} - -function skipDelimited( - source: string, - offset: number, - delimiter: string, - errorMessage: string, -): number { - const end = source.indexOf(delimiter, offset + delimiter.length); - if (end === -1) throw new Error(errorMessage); - return end + delimiter.length; -} - -function declarationEnd(source: string, offset: number): number { - let quote: string | undefined; - let bracketDepth = 0; - for (let cursor = offset + 2; cursor < source.length; cursor += 1) { - const char = source[cursor]!; - if (quote) { - if (char === quote) quote = undefined; - continue; - } - if (char === '"' || char === "'") { - quote = char; - continue; - } - if (char === '[') bracketDepth += 1; - else if (char === ']' && bracketDepth > 0) bracketDepth -= 1; - else if (char === '>' && bracketDepth === 0) return cursor + 1; - } - throw new Error('XML declaration is not closed.'); -} - -function assertSafeAttributeName(name: string): void { - if (UNSAFE_ATTRIBUTE_NAMES.has(name)) { - throw new Error(`Unsupported XML attribute name "${name}".`); - } -} - -function decodeXmlEntities(value: string): string { - return value.replace( - /&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g, - (entity, body: string) => { - switch (body) { - case 'amp': - return '&'; - case 'lt': - return '<'; - case 'gt': - return '>'; - case 'quot': - return '"'; - case 'apos': - return "'"; - default: { - const codePoint = body.startsWith('#x') - ? Number.parseInt(body.slice(2), 16) - : Number(body.slice(1)); - return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff - ? String.fromCodePoint(codePoint) - : entity; - } - } - }, - ); -} diff --git a/packages/xml/package.json b/packages/xml/package.json new file mode 100644 index 000000000..20dd93a79 --- /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 c498ea5dd..8104878fc 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 000000000..5d96cfe36 --- /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 000000000..fa879fe2e --- /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 84f80e108..91c230e8b 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 000000000..0bedf71aa --- /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 000000000..935c871a4 --- /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 8c083affb..acfdbc18d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@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 @@ -110,6 +113,11 @@ importers: '@agent-device/kernel': specifier: workspace:* version: link:../kernel + '@agent-device/xml': + specifier: workspace:* + version: link:../xml + + packages/xml: {} website: devDependencies: diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index f6d9b388a..ca8e041aa 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -76,12 +76,13 @@ export function zoneRank(zone: string): number | null { // 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` and `provider-webdriver` are no longer src/ zones: R11 owns their +// `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 89eeacba4..064d703bf 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', @@ -211,7 +225,12 @@ test('the real tree parses, declares, and passes R11', () => { 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', @@ -228,6 +247,10 @@ test('the real tree parses, declares, and passes R11', () => { 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()), []); }); @@ -246,6 +269,8 @@ test('Node resolution enforces the exports map at runtime', () => { '@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), @@ -264,4 +289,6 @@ test('Node resolution enforces the exports map at runtime', () => { 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 e3727464e..8994b0fa6 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 c43576d52..89b40dad7 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 3e3c69f35..f8b2547db 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/daemon/__tests__/runtime-hints.test.ts b/src/daemon/__tests__/runtime-hints.test.ts index 86d3922de..96c68f8cd 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 c4a74ad57..76be1fdf4 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 eaab28082..71c7ac46e 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 { providerWebDriver } from '../../provider-webdriver.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) => providerWebDriver.listArtifactsFromEnv(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 c0c58ef40..5484c4036 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 5d4065c9b..60620e512 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 1445e0dcc..4868325df 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 e4e52f869..8b25977ee 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 969c342e2..3117b10ef 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 000000000..c81715709 --- /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 490436de9..e346a8d40 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 c5ebb93c1..f49004ddc 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 cddb1b561..d748b6a25 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 0f909341f..000000000 --- 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 c89692b8f..d3793bdf2 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-webdriver.test.ts b/src/provider-webdriver.test.ts new file mode 100644 index 000000000..86b640d71 --- /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/replay/test/reporters/format.ts b/src/replay/test/reporters/format.ts index c9efac2e9..496f5dc6e 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 8b96cdc51..33ff0312b 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 5696aa574..eb25394f4 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -54,6 +54,8 @@ test('BrowserStack facade prepares capabilities, uploads apps, and returns artif 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({ @@ -96,11 +98,15 @@ test('AWS Device Farm facade uses the injected host-command capability for its f 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(); @@ -222,6 +228,20 @@ function operationSupport( 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; +} + function browserStackContext(lease: DeviceLease): LeaseLifecycleContext { return { flags: { 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 000000000..705e7e393 --- /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 000000000..9083457fe --- /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', + }, + }; +}