diff --git a/.fallowrc.json b/.fallowrc.json index c6505c8327..895de95c84 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -2,6 +2,10 @@ "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", "entry": [ "tsdown.config.ts", + "scripts/check-provider-plugin.mjs", + "packages/provider-doublespeed/test/package-smoke.mjs", + "packages/provider-doublespeed/tsdown.config.ts", + "packages/provider-doublespeed/src/plugin.ts", "vitest.mutation.config.ts", "src/sdk/index.ts", "src/sdk/io.ts", @@ -10,6 +14,7 @@ "src/sdk/remote-config.ts", "src/sdk/install-source.ts", "src/sdk/plugins.ts", + "src/sdk/plugin-webdriver.ts", "src/sdk/android-adb.ts", "src/sdk/contracts.ts", "src/sdk/selectors.ts", @@ -69,6 +74,11 @@ "@arethetypeswrong/cli" ], "ignoreExports": [ + { + "comment": "The standalone packed-install harness loads this fixture from the selected package path and reads its request scenarios dynamically.", + "file": "packages/provider-*/test/package-smoke.mjs", + "exports": ["*"] + }, { "comment": "Android perf mechanics are selected through the lazy platform host so importing agent-device does not eagerly load adb mechanics. Fallow cannot follow the dynamic property read in src/platform-runtime-perf-host.ts.", "file": "packages/platform-android/src/perf.ts", @@ -290,7 +300,8 @@ }, { "comment": "Tool config default exports, loaded by the tool rather than imported.", - "file": "{oxlint.config.ts,tsdown.config.ts,packages/proxy/tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts}", + "file": "{oxlint.config.ts,tsdown.config.ts,packages/provider-*/tsdown.config.ts,packages/proxy/tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts}", + "exports": ["default"] }, { diff --git a/README.md b/README.md index a5f9cb069c..543d8c1450 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ The same session and evidence model works at every step: the agent explores the | --- | --- | --- | | Local | Trying commands and debugging apps on simulators, emulators, physical devices, macOS, and Linux. | Follow the Quick Start. | | CI/CD | Automated pull request and merge validation with replay scripts and captured artifacts. | Try the [EAS workflow template](https://github.com/callstackincubator/eas-agent-device/blob/main/.eas/workflows/agent-qa-mobile.yml). | -| Cloud / remote | Linux runners, managed devices, and remote jobs. | Set up a [remote proxy](https://oss.callstack.com/agent-device/docs/remote-proxy), connect a [device cloud](https://oss.callstack.com/agent-device/docs/device-clouds) (BrowserStack, AWS Device Farm, Limrun), or [contact Callstack](mailto:hello@callstack.com) for team QA. | +| Cloud / remote | Linux runners, managed devices, and remote jobs. | Set up a [remote proxy](https://oss.callstack.com/agent-device/docs/remote-proxy), connect a [device cloud](https://oss.callstack.com/agent-device/docs/device-clouds) (BrowserStack, AWS Device Farm, Limrun, Doublespeed), or [contact Callstack](mailto:hello@callstack.com) for team QA. | ## How it works @@ -145,7 +145,7 @@ The same session and evidence model works at every step: the agent explores the Support depth varies by target. Newer backends such as HarmonyOS and Vega OS cover a subset of commands; run `agent-device capabilities --platform ` to see what a target supports. -Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Inspect ownership without a daemon via `agent-device device status`, and settle provably dead owners with `agent-device device release --stale`. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds). +Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Inspect ownership without a daemon via `agent-device device status`, and settle provably dead owners with `agent-device device release --stale`. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, Limrun, and Doublespeed](https://oss.callstack.com/agent-device/docs/device-clouds). `agent-device` uses the inspect-act-verify process from Vercel's [agent-browser](https://github.com/vercel-labs/agent-browser) for mobile, TV, and desktop apps. Basic `--platform web` support runs `agent-browser` in the same session and replay system. diff --git a/package.json b/package.json index 0a48ce5322..9222b8c91b 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,10 @@ "./plugins": { "types": "./dist/src/plugins.d.ts", "import": "./dist/src/plugins.js" + }, + "./plugins/webdriver": { + "types": "./dist/src/plugins/webdriver.d.ts", + "import": "./dist/src/plugins/webdriver.js" } }, "engines": { @@ -175,7 +179,7 @@ "check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "npm_config_dry_run=false pnpm release:prepare", - "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/device-selection packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/session-journal packages/ad-replay packages/maestro packages/replay-port packages/replay-test packages/provider-webdriver packages/provider-limrun packages/proxy && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/device-selection packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/session-journal packages/ad-replay packages/maestro packages/replay-port packages/replay-test packages/provider-webdriver packages/provider-limrun packages/proxy packages/provider-doublespeed && 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", @@ -342,6 +346,7 @@ "vite": "^8.2.1", "vitest": "^4.1.11", "yaml": "^2.9.0", - "yauzl": "^3.4.0" + "yauzl": "^3.4.0", + "@agent-device/doublespeed": "workspace:*" } } diff --git a/packages/capture-kit/src/ios-snapshot-acquisition.ts b/packages/capture-kit/src/ios-snapshot-acquisition.ts index 106c5a0b4f..9a01dbc9dc 100644 --- a/packages/capture-kit/src/ios-snapshot-acquisition.ts +++ b/packages/capture-kit/src/ios-snapshot-acquisition.ts @@ -41,6 +41,14 @@ const IOS_PROVIDER_ACQUISITION_CAPABILITY_VALUES = { }, hittabilityEvidence: 'unavailable', }, + 'doublespeed-ios-tree': { + producer: 'doublespeed-ios-tree', + acquisitionDepth: { + rawTraversal: { kind: 'incomplete' }, + regularPresented: { kind: 'incomplete' }, + }, + hittabilityEvidence: 'unavailable', + }, } as const satisfies Record; /** @@ -60,6 +68,7 @@ const IOS_SNAPSHOT_TRUNCATION_EVIDENCE = { 'simulator-ax-bridge': 'available', 'appium-source': 'unavailable', 'limrun-ios-tree': 'unavailable', + 'doublespeed-ios-tree': 'unavailable', } as const satisfies Record; export function iosSnapshotTruncationEvidence( diff --git a/packages/capture-kit/src/ios-snapshot-planning.test.ts b/packages/capture-kit/src/ios-snapshot-planning.test.ts index 99b4e29731..d228a0ba8a 100644 --- a/packages/capture-kit/src/ios-snapshot-planning.test.ts +++ b/packages/capture-kit/src/ios-snapshot-planning.test.ts @@ -103,6 +103,7 @@ test('truncation evidence is declared for every iOS producer', () => { 'simulator-ax-bridge': 'available', 'appium-source': 'unavailable', 'limrun-ios-tree': 'unavailable', + 'doublespeed-ios-tree': 'unavailable', } as const satisfies Record; for (const producer of Object.keys(expected) as IosSnapshotProducer[]) { diff --git a/packages/contracts/src/facades/remote.ts b/packages/contracts/src/facades/remote.ts index 277da93e58..15f96d5501 100644 --- a/packages/contracts/src/facades/remote.ts +++ b/packages/contracts/src/facades/remote.ts @@ -11,6 +11,7 @@ export type { ResolvedMetroKind, } from '../metro.ts'; export type { + ConnectionProviderCapabilities, ProviderConnectionResource, ProviderConnectionVerification, } from '../provider-connection.ts'; diff --git a/packages/contracts/src/ios-snapshot.ts b/packages/contracts/src/ios-snapshot.ts index 07bdb2b68f..b7e6ed054c 100644 --- a/packages/contracts/src/ios-snapshot.ts +++ b/packages/contracts/src/ios-snapshot.ts @@ -4,12 +4,13 @@ export type IosSnapshotProducer = | 'apple-runner' | 'simulator-ax-bridge' | 'appium-source' - | 'limrun-ios-tree'; + | 'limrun-ios-tree' + | 'doublespeed-ios-tree'; export type IosAcquisitionProducer = Exclude; export type IosProviderAcquisitionProducer = Extract< IosAcquisitionProducer, - 'appium-source' | 'limrun-ios-tree' + 'appium-source' | 'limrun-ios-tree' | 'doublespeed-ios-tree' >; export type IosAcquisitionIntent = 'full' | 'surface-observation'; export type IosSnapshotProjection = 'regular' | 'raw'; diff --git a/packages/contracts/src/provider-connection.ts b/packages/contracts/src/provider-connection.ts index 3a3fa1125d..a0ff737e9e 100644 --- a/packages/contracts/src/provider-connection.ts +++ b/packages/contracts/src/provider-connection.ts @@ -17,3 +17,13 @@ export type ProviderConnectionVerification = { device: ProviderConnectionResource; app: ProviderConnectionResource; }; + +export type ConnectionProviderCapabilities = { + leaseKind: 'proxy' | 'direct-device-provider' | 'remote-provider'; + requiresAppAttachment: boolean; + requiresRemoteDaemon: boolean; + supportsArtifacts: boolean; + supportsDeferredAppSelection: boolean; + supportsDirectPortReverse: boolean; + usesCloudWebDriverLease: boolean; +}; diff --git a/packages/kernel/src/errors.test.ts b/packages/kernel/src/errors.test.ts index fbefc6b8c2..0d47220b90 100644 --- a/packages/kernel/src/errors.test.ts +++ b/packages/kernel/src/errors.test.ts @@ -178,3 +178,20 @@ test('discloseDispatchAfterSteps keeps no only while no step of the series was d const plain = new Error('socket closed'); assert.equal(discloseDispatchAfterSteps(plain, 4), plain); }); + +test('bundled plugin errors preserve codes and details without changing subclass checks', async () => { + const copyPath = './errors.ts?plugin-copy'; + const { AppError: PluginError } = await import(copyPath); + assert.notEqual(PluginError, AppError); + const foreign = new PluginError('INVALID_ARGS', 'bad plugin profile', { provider: 'example' }); + assert.ok(foreign instanceof AppError); + const normalized = normalizeError(foreign); + assert.equal(normalized.code, 'INVALID_ARGS'); + assert.equal(normalized.message, 'bad plugin profile'); + assert.deepEqual(normalized.details, { provider: 'example' }); + const ordinary = Object.assign(new Error('bad plugin profile'), { code: 'INVALID_ARGS' }); + assert.equal(ordinary instanceof AppError, false); + class SpecificError extends AppError {} + assert.ok(new SpecificError('COMMAND_FAILED', 'specific') instanceof SpecificError); + assert.equal(foreign instanceof SpecificError, false); +}); diff --git a/packages/kernel/src/errors.ts b/packages/kernel/src/errors.ts index 7a15f00fdf..56ed18b6bd 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -36,6 +36,31 @@ export type KnownAppErrorCode = (typeof KNOWN_APP_ERROR_CODES)[number]; // include a default branch. export type AppErrorCode = KnownAppErrorCode | (string & {}); +const APP_ERROR_BRAND = Symbol.for('agent-device.AppError'); + +export class AppError extends Error { + static [Symbol.hasInstance](value: unknown): boolean { + if (this !== AppError) return Function.prototype[Symbol.hasInstance].call(this, value); + return ( + typeof value === 'object' && + value !== null && + (value as Record)[APP_ERROR_BRAND] === true + ); + } + + code: AppErrorCode; + details?: AppErrorDetails; + cause?: unknown; + + constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) { + super(message); + Object.defineProperty(this, APP_ERROR_BRAND, { value: true }); + this.code = code; + this.details = details; + this.cause = cause; + } +} + export function toAppErrorCode( code: string | undefined, fallback: AppErrorCode = 'COMMAND_FAILED', @@ -219,19 +244,6 @@ export type DaemonError = { supportedOn?: string; }; -export class AppError extends Error { - code: AppErrorCode; - details?: AppErrorDetails; - cause?: unknown; - - constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) { - super(message); - this.code = code; - this.details = details; - this.cause = cause; - } -} - /** Rehydrate a daemon transport error into the error type used by local callers. */ export function throwDaemonError(error: DaemonError): never { throw new AppError( diff --git a/packages/kernel/src/snapshot-provenance.test.ts b/packages/kernel/src/snapshot-provenance.test.ts index 33eebe362c..fd056529bb 100644 --- a/packages/kernel/src/snapshot-provenance.test.ts +++ b/packages/kernel/src/snapshot-provenance.test.ts @@ -30,4 +30,8 @@ test('snapshotStateProvenance extracts exactly the pair', () => { backend: 'xctest', producer: 'limrun-ios-tree', }); + expect(snapshotStateProvenance({ backend: 'xctest', producer: 'doublespeed-ios-tree' })).toEqual({ + backend: 'xctest', + producer: 'doublespeed-ios-tree', + }); }); diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index 684e9e55d1..649c8a6ba1 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -478,7 +478,7 @@ export function formatRole(type: string): string { /** * The channel↔producer pairs that can actually occur. One channel is fed by several producers * with different guarantees: `xctest` trees come from the local Apple runner, Appium - * page-source XML, or a limrun element tree, and only the runner's output has been through the + * page-source XML, or a Limrun or Doublespeed element tree, and only the runner's output has been through the * runner's presentation (clip fold, effective geometry, scope). Logic that assumes * presentation, scope, or geometry guarantees must key on the producer, never on the channel * alone. @@ -492,7 +492,12 @@ export function formatRole(type: string): string { export type SnapshotProvenance = | { backend: 'xctest'; - producer: 'apple-runner' | 'simulator-ax-bridge' | 'appium-source' | 'limrun-ios-tree'; + producer: + | 'apple-runner' + | 'simulator-ax-bridge' + | 'appium-source' + | 'limrun-ios-tree' + | 'doublespeed-ios-tree'; } | { backend: 'android'; producer: 'android-uiautomator' | 'appium-source' } | { backend: 'harmonyos-arkui'; producer: 'harmonyos-uitest' } diff --git a/packages/provider-doublespeed/README.md b/packages/provider-doublespeed/README.md new file mode 100644 index 0000000000..d8e65d7afe --- /dev/null +++ b/packages/provider-doublespeed/README.md @@ -0,0 +1,16 @@ +# @agent-device/doublespeed + +Use Doublespeed iOS simulators with [agent-device](https://agent-device.dev). +You need a Doublespeed account and its API credentials. + +```sh +npm install -g agent-device +agent-device plugins add @agent-device/doublespeed +export DOUBLESPEED_API_KEY=your-api-key +agent-device connect doublespeed --platform ios +``` + +See the [Doublespeed guide](https://agent-device.dev/docs/doublespeed) for setup and supported operations. +To update the plugin, run `agent-device plugins update @agent-device/doublespeed`. + +After adding or updating a plugin, close your sessions and run `agent-device daemon stop` before reconnecting. diff --git a/packages/provider-doublespeed/package.json b/packages/provider-doublespeed/package.json new file mode 100644 index 0000000000..fe58087094 --- /dev/null +++ b/packages/provider-doublespeed/package.json @@ -0,0 +1,60 @@ +{ + "name": "@agent-device/doublespeed", + "version": "0.21.22", + "private": false, + "type": "module", + "description": "Doublespeed iOS simulator plugin for agent-device.", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./connection-verification": { + "types": "./src/connection-verification.ts", + "default": "./src/connection-verification.ts" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown --config tsdown.config.ts", + "prepack": "pnpm build" + }, + "devDependencies": { + "@agent-device/capture-kit": "workspace:*", + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*", + "agent-device": "workspace:*" + }, + "agentDevicePlugin": { + "apiVersion": 1, + "provider": "doublespeed", + "entry": "./dist/plugin.mjs", + "connection": { + "leaseKind": "direct-device-provider", + "requiresAppAttachment": false, + "requiresRemoteDaemon": false, + "supportsArtifacts": false, + "supportsDeferredAppSelection": true, + "supportsDirectPortReverse": false, + "usesCloudWebDriverLease": false + } + }, + "publishConfig": { + "exports": { + ".": { + "import": "./dist/plugin.mjs" + } + }, + "access": "public" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/callstack/agent-device.git", + "directory": "packages/provider-doublespeed" + }, + "homepage": "https://agent-device.dev/docs/doublespeed" +} diff --git a/packages/provider-doublespeed/src/api-client.test.ts b/packages/provider-doublespeed/src/api-client.test.ts new file mode 100644 index 0000000000..4f230543c0 --- /dev/null +++ b/packages/provider-doublespeed/src/api-client.test.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs'; +import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { DoublespeedApiClient } from './api-client.ts'; +import { readySimulator, scriptedFetch } from './runtime.fixtures.ts'; + +function client(fetchImpl: typeof fetch, apiUrl?: string) { + return new DoublespeedApiClient({ + apiKey: 'dsx_test_key', + clientVersion: '1.2.3', + fetch: fetchImpl, + ...(apiUrl ? { apiUrl } : {}), + }); +} + +test('creates a simulator, identifies the CLI, and polls until the session is ready', async () => { + const { fetch, calls } = scriptedFetch([ + () => ({ + status: 202, + body: readySimulator({ ready: false, status: 'queued', api_url: null }), + }), + () => ({ body: readySimulator({ ready: false, status: 'preparing', api_url: null }) }), + () => ({ body: readySimulator() }), + ]); + + const simulator = await client(fetch, 'https://api.example/').createSimulator({ + device: 'iPhone 16 Pro', + labels: { leaseId: 'lease-a' }, + idleTimeoutSeconds: 600, + }); + + expect(simulator.ready).toBe(true); + expect(simulator.api_url).toBe('https://worker.example/i/token-a'); + expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([ + 'POST https://api.example/v1/xcode/simulators', + 'GET https://api.example/v1/xcode/simulators/sim-a?wait=1', + 'GET https://api.example/v1/xcode/simulators/sim-a?wait=1', + ]); + expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ + device: 'iPhone 16 Pro', + labels: { leaseId: 'lease-a' }, + idle_timeout_seconds: 600, + wait: true, + }); + expect(calls[0]?.init.headers).toMatchObject({ + authorization: 'Bearer dsx_test_key', + 'x-agent-device-client': 'agent-device-cli', + 'x-agent-device-version': '1.2.3', + }); +}); + +test('fails closed when the simulator job ends before it is ready', async () => { + const { fetch } = scriptedFetch([ + () => ({ + status: 202, + body: readySimulator({ + ready: false, + status: 'failed', + api_url: null, + error: { code: 'PREVIEW_SETUP', message: 'no simulator named "iPhone 3"' }, + }), + }), + ]); + await expect(client(fetch).createSimulator({ labels: {} })).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { status: 'failed', providerError: { code: 'PREVIEW_SETUP' } }, + }); +}); + +test('classifies authentication and credit failures without echoing the key', async () => { + const unauthorized = scriptedFetch([ + () => ({ status: 401, body: { error: { code: 'UNAUTHORIZED', message: 'bad key' } } }), + ]); + await expect(client(unauthorized.fetch).listSimulators({})).rejects.toSatisfy( + (error: unknown) => { + expect(error).toMatchObject({ code: 'UNAUTHORIZED' }); + expect(JSON.stringify(error)).not.toContain('dsx_test_key'); + return true; + }, + ); + const credits = scriptedFetch([ + () => ({ + status: 402, + body: { error: { code: 'INSUFFICIENT_CREDITS', message: 'no credits' } }, + }), + ]); + await expect(client(credits.fetch).createSimulator({ labels: {} })).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { status: 402, providerCode: 'INSUFFICIENT_CREDITS' }, + }); +}); + +test('lists simulators by label selector and deletes by id', async () => { + const { fetch, calls } = scriptedFetch([ + () => ({ body: { simulators: [readySimulator()] } }), + () => ({ body: readySimulator({ status: 'cancelled', ready: false }) }), + ]); + const api = client(fetch); + const simulators = await api.listSimulators({ provider: 'doublespeed', leaseId: 'lease-a' }); + await api.deleteSimulator('sim-a'); + expect(simulators.map((simulator) => simulator.id)).toEqual(['sim-a']); + expect(calls[0]?.url).toBe( + 'https://api.mac.doublespeed.ai/v1/xcode/simulators?label_selector=provider%3Ddoublespeed%2CleaseId%3Dlease-a', + ); + expect(`${calls[1]?.init.method} ${calls[1]?.url}`).toBe( + 'DELETE https://api.mac.doublespeed.ai/v1/xcode/simulators/sim-a', + ); +}); + +test('registers, uploads and completes an asset with the signed URL as the only capability', async () => { + const tempDir = mkdtempForTestSync('doublespeed-asset-'); + const filePath = path.join(tempDir, 'app.zip'); + fs.writeFileSync(filePath, 'zip-bytes'); + const { fetch, calls } = scriptedFetch([ + () => ({ + body: { + sha256: 'abc', + exists: false, + upload_url: 'https://blob.example/upload?token=x', + download_url: null, + }, + }), + () => ({ body: {} }), + () => ({ + body: { + sha256: 'abc', + exists: true, + upload_url: null, + download_url: 'https://blob.example/get', + }, + }), + ]); + const api = client(fetch); + const registered = await api.registerAsset({ sha256: 'abc', size: 9, name: 'app.zip' }); + await api.uploadAsset(registered.upload_url!, filePath); + const completed = await api.completeAsset('abc', 9); + + expect(completed.download_url).toBe('https://blob.example/get'); + expect(`${calls[1]?.init.method} ${calls[1]?.url}`).toBe( + 'PUT https://blob.example/upload?token=x', + ); + expect(calls[1]?.init.headers).not.toHaveProperty('authorization'); + expect(String(calls[1]?.init.body)).toBe('zip-bytes'); + expect(calls[2]?.url).toBe('https://api.mac.doublespeed.ai/v1/xcode/assets/abc/complete'); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); diff --git a/packages/provider-doublespeed/src/api-client.ts b/packages/provider-doublespeed/src/api-client.ts new file mode 100644 index 0000000000..e7102fa0c4 --- /dev/null +++ b/packages/provider-doublespeed/src/api-client.ts @@ -0,0 +1,245 @@ +import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; + +export const DOUBLESPEED_DEFAULT_API_URL = 'https://api.mac.doublespeed.ai'; +export const DOUBLESPEED_CLIENT_HEADER = 'agent-device-cli'; + +const REQUEST_TIMEOUT_MS = 90_000; +const UPLOAD_TIMEOUT_MS = 10 * 60_000; +const SIMULATOR_READY_TIMEOUT_MS = 10 * 60_000; +const SIMULATORS_PATH = '/v1/xcode/simulators'; +const ASSETS_PATH = '/v1/xcode/assets'; + +export type DoublespeedSimulatorStatus = + | 'queued' + | 'preparing' + | 'running' + | 'succeeded' + | 'failed' + | 'cancelled'; + +export type DoublespeedSimulator = { + id: string; + status: DoublespeedSimulatorStatus; + ready: boolean; + device: string; + labels: Record; + api_url: string | null; + token: string | null; + viewer_url: string | null; + screen: { width: number; height: number; scale: number } | null; + expires_at: string | null; + error: { code: string; message: string } | null; +}; + +export type DoublespeedAssetRegistration = { + sha256: string; + exists: boolean; + upload_url: string | null; + download_url: string | null; +}; + +export type DoublespeedClientOptions = { + apiKey: string; + apiUrl?: string; + clientVersion: string; + fetch?: typeof fetch; +}; + +function doublespeedClientHeaders(options: { + apiKey: string; + clientVersion: string; +}): Record { + return { + authorization: `Bearer ${options.apiKey}`, + 'content-type': 'application/json', + 'x-agent-device-client': DOUBLESPEED_CLIENT_HEADER, + 'x-agent-device-version': options.clientVersion, + }; +} + +function resolveDoublespeedApiUrl(apiUrl: string | undefined): string { + return (apiUrl?.trim() || DOUBLESPEED_DEFAULT_API_URL).replace(/\/+$/, ''); +} + +/** The control-plane half of the provider: simulator sessions and content-addressed app assets. */ +export class DoublespeedApiClient { + private readonly baseUrl: string; + private readonly headers: Record; + private readonly fetchImpl: typeof fetch; + + constructor(options: DoublespeedClientOptions) { + this.baseUrl = resolveDoublespeedApiUrl(options.apiUrl); + this.headers = doublespeedClientHeaders(options); + this.fetchImpl = options.fetch ?? fetch; + } + + async createSimulator( + input: { device?: string; labels: Record; idleTimeoutSeconds?: number }, + signal?: AbortSignal, + ): Promise { + const created = await this.request( + 'POST', + SIMULATORS_PATH, + { + ...(input.device ? { device: input.device } : {}), + labels: input.labels, + ...(input.idleTimeoutSeconds ? { idle_timeout_seconds: input.idleTimeoutSeconds } : {}), + wait: true, + }, + signal, + ); + return await this.awaitReady(created, signal); + } + + async getSimulator( + id: string, + options?: { wait?: boolean; signal?: AbortSignal }, + ): Promise { + const suffix = options?.wait ? '?wait=1' : ''; + return await this.request( + 'GET', + `${SIMULATORS_PATH}/${encodeURIComponent(id)}${suffix}`, + undefined, + options?.signal, + ); + } + + async listSimulators( + labels: Record, + signal?: AbortSignal, + ): Promise { + const selector = Object.entries(labels) + .map(([key, value]) => `${key}=${value}`) + .join(','); + const query = selector ? `?label_selector=${encodeURIComponent(selector)}` : ''; + const page = await this.request<{ simulators: DoublespeedSimulator[] }>( + 'GET', + `${SIMULATORS_PATH}${query}`, + undefined, + signal, + ); + return page.simulators; + } + + async deleteSimulator(id: string, signal?: AbortSignal): Promise { + await this.request('DELETE', `${SIMULATORS_PATH}/${encodeURIComponent(id)}`, undefined, signal); + } + + async registerAsset( + input: { sha256: string; size: number; name: string }, + signal?: AbortSignal, + ): Promise { + return await this.request('POST', ASSETS_PATH, input, signal); + } + + async completeAsset( + sha256: string, + size: number, + signal?: AbortSignal, + ): Promise { + return await this.request( + 'POST', + `${ASSETS_PATH}/${sha256}/complete`, + { size }, + signal, + ); + } + + /** The signed upload URL is the capability; it carries no account credential. */ + async uploadAsset(uploadUrl: string, filePath: string, signal?: AbortSignal): Promise { + const body = await fs.promises.readFile(filePath); + const response = await this.fetchImpl(uploadUrl, { + method: 'PUT', + headers: { 'content-type': 'application/zip' }, + body, + signal: boundedSignal(UPLOAD_TIMEOUT_MS, signal), + }); + if (!response.ok) { + throw new AppError('COMMAND_FAILED', 'Doublespeed asset upload was rejected.', { + status: response.status, + }); + } + } + + private async awaitReady( + simulator: DoublespeedSimulator, + signal?: AbortSignal, + ): Promise { + const deadline = Date.now() + SIMULATOR_READY_TIMEOUT_MS; + let current = simulator; + while (!current.ready) { + if (!isLiveSimulatorStatus(current.status)) { + throw new AppError('COMMAND_FAILED', 'Doublespeed simulator did not become ready.', { + simulatorId: current.id, + status: current.status, + ...(current.error ? { providerError: current.error } : {}), + }); + } + if (Date.now() > deadline) { + throw new AppError('COMMAND_FAILED', 'Timed out waiting for a Doublespeed simulator.', { + simulatorId: current.id, + status: current.status, + }); + } + current = await this.getSimulator(current.id, { wait: true, signal }); + } + return current; + } + + private async request( + method: 'GET' | 'POST' | 'DELETE', + path: string, + body?: unknown, + signal?: AbortSignal, + ): Promise { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers: this.headers, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: boundedSignal(REQUEST_TIMEOUT_MS, signal), + }); + const payload = (await response.json().catch(() => undefined)) as + | { error?: { code?: string; message?: string } } + | undefined; + if (!response.ok) throw doublespeedApiError(response.status, payload?.error); + return payload as Result; + } +} + +function isLiveSimulatorStatus(status: DoublespeedSimulatorStatus): boolean { + return status === 'queued' || status === 'preparing' || status === 'running'; +} + +function doublespeedApiError( + status: number, + error: { code?: string; message?: string } | undefined, +): AppError { + const details = { status, ...(error?.code ? { providerCode: error.code } : {}) }; + if (status === 401 || status === 403) { + return new AppError('UNAUTHORIZED', 'Doublespeed rejected the API key.', { + ...details, + hint: 'Check DOUBLESPEED_API_KEY and its organization access.', + }); + } + if (status === 402) { + return new AppError( + 'COMMAND_FAILED', + 'Doublespeed refused the request: insufficient credits.', + { + ...details, + hint: 'Add credits at https://mac.doublespeed.ai/dashboard/billing and retry.', + }, + ); + } + return new AppError( + 'COMMAND_FAILED', + `Doublespeed request failed: ${error?.message ?? `HTTP ${status}`}`, + details, + ); +} + +export function boundedSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} diff --git a/packages/provider-doublespeed/src/app-log-descriptor.ts b/packages/provider-doublespeed/src/app-log-descriptor.ts new file mode 100644 index 0000000000..95e415b7e9 --- /dev/null +++ b/packages/provider-doublespeed/src/app-log-descriptor.ts @@ -0,0 +1,80 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + DurableDescriptorCodec, + DurableResourceEnvelope, +} from '@agent-device/contracts/durable-resource-envelope'; +import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; +import { APP_LOG_RESOURCE_KIND } from '@agent-device/contracts/app-log-runtime'; +import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit'; + +export type DoublespeedAppLogDescriptor = Readonly<{ + transport: 'doublespeed-log-poller'; + leaseId: string; + simulatorId: string; + appBundleId: string; + outputPath: string; +}>; + +export const doublespeedAppLogDescriptorCodec: DurableDescriptorCodec< + DoublespeedAppLogDescriptor, + typeof APP_LOG_RESOURCE_KIND +> = Object.freeze({ + resourceKind: APP_LOG_RESOURCE_KIND, + version: 1, + encode: (descriptor) => ({ ...descriptor }), + decode: (body) => { + if ( + body.transport !== 'doublespeed-log-poller' || + !isNonEmptyString(body.leaseId) || + !isNonEmptyString(body.simulatorId) || + !isNonEmptyString(body.appBundleId) || + !isNonEmptyString(body.outputPath) + ) { + return { status: 'invalid', message: 'Invalid Doublespeed app-log descriptor' }; + } + return { + status: 'decoded', + descriptor: Object.freeze({ + transport: 'doublespeed-log-poller', + leaseId: body.leaseId, + simulatorId: body.simulatorId, + appBundleId: body.appBundleId, + outputPath: body.outputPath, + }), + }; + }, +}); + +export function createDoublespeedAppLogEnvelope(input: { + sessionId: string; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: { token: string; generation: number }; + descriptor: DoublespeedAppLogDescriptor; +}): DurableResourceEnvelope<'app-log'> { + if (input.device.platform === 'apple' && !input.device.appleOs) { + throw new TypeError('Doublespeed app-log persistence requires an explicit appleOs identity'); + } + return createDurableResourceEnvelope({ + resourceKind: APP_LOG_RESOURCE_KIND, + sessionId: input.sessionId, + device: { + id: input.device.id, + family: input.device.platform, + ...(input.device.appleOs === undefined ? {} : { appleOs: input.device.appleOs }), + kind: input.device.kind, + ...(input.device.target === undefined ? {} : { target: input.device.target }), + ...(input.device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: input.device.iosPhysicalDeviceBackend }), + }, + owner: input.owner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(doublespeedAppLogDescriptorCodec, input.descriptor), + }); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/packages/provider-doublespeed/src/app-log-poller.test.ts b/packages/provider-doublespeed/src/app-log-poller.test.ts new file mode 100644 index 0000000000..0d0fc5cbd1 --- /dev/null +++ b/packages/provider-doublespeed/src/app-log-poller.test.ts @@ -0,0 +1,208 @@ +import type { AppLogRuntimeHost } from '@agent-device/contracts/app-log-runtime'; +import { describe, expect, test, vi } from 'vitest'; +import { startDoublespeedAppLogPoller, type DoublespeedAppLogReader } from './app-log-poller.ts'; + +function reader(overrides: Partial = {}): DoublespeedAppLogReader { + return { + leaseId: 'lease-1', + simulatorId: 'sim-1', + readLogs: async () => '', + [Symbol.asyncDispose]: async () => {}, + ...overrides, + }; +} + +describe('Doublespeed app-log poller', () => { + test('deduplicates the persisted tail and stops before disposing its resources', async () => { + const sleeps = deferredSleeps(); + const writes: string[] = []; + let outputDisposed = false; + let readerDisposed = false; + const logReader = reader({ + readLogs: vi.fn(async () => 'old line\nshared\nnew line\n'), + [Symbol.asyncDispose]: async () => { + readerDisposed = true; + }, + }); + const handle = await startDoublespeedAppLogPoller({ + host: pollerHost({ + existingTail: 'old line\nshared\n[agent-device][mark][time] checkpoint\n', + writes, + sleeps, + onOutputDispose: () => { + outputDisposed = true; + }, + }), + reader: logReader, + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }); + await vi.waitFor(() => expect(writes).toEqual(['new line\n'])); + expect(handle.inspect().backend).toBe('ios-simulator'); + + const finishing = handle.finish(); + expect(readerDisposed).toBe(false); + expect(outputDisposed).toBe(false); + sleeps.resolveNext(1_000); + await finishing; + expect(logReader.readLogs).toHaveBeenCalledTimes(1); + expect(readerDisposed).toBe(true); + expect(outputDisposed).toBe(true); + }); + + test.each(['readTail', 'openAppend'] as const)( + 'rolls back the reader when %s fails during acquisition', + async (failure) => { + const dispose = vi.fn(async () => {}); + const host = pollerHost({ existingTail: '', writes: [], sleeps: deferredSleeps(), failure }); + await expect( + startDoublespeedAppLogPoller({ + host, + reader: reader({ [Symbol.asyncDispose]: dispose }), + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }), + ).rejects.toThrow(`${failure === 'readTail' ? 'tail' : 'open'} failed`); + expect(dispose).toHaveBeenCalledOnce(); + }, + ); + + test('uses linear overlap matching for a near-limit tail without overlap', async () => { + const sleeps = deferredSleeps(); + const writes: string[] = []; + const handle = await startDoublespeedAppLogPoller({ + host: pollerHost({ existingTail: `${'a'.repeat(240_000)}\n`, writes, sleeps }), + reader: reader({ readLogs: async () => `${'b'.repeat(240_000)}\n` }), + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }); + await vi.waitFor(() => expect(writes[0]?.length).toBe(240_001)); + const finishing = handle.finish(); + sleeps.resolveNext(1_000); + await finishing; + }); + + test('still disposes the output when reader cleanup rejects', async () => { + const sleeps = deferredSleeps(); + let outputDisposed = false; + const handle = await startDoublespeedAppLogPoller({ + host: pollerHost({ + existingTail: '', + writes: [], + sleeps, + onOutputDispose: () => { + outputDisposed = true; + }, + }), + reader: reader({ + [Symbol.asyncDispose]: async () => { + throw new Error('reader cleanup failed'); + }, + }), + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }); + await vi.waitFor(() => expect(sleeps.hasPending(1_000)).toBe(true)); + const finishing = handle.finish(); + sleeps.resolveNext(1_000); + await expect(finishing).resolves.toMatchObject({ status: 'cleanup-pending' }); + expect(outputDisposed).toBe(true); + }); + + test('allows one bounded read only and settles it before disposal', async () => { + const sleeps = deferredSleeps(); + let readerDisposed = false; + const readLogs = vi.fn(async () => await new Promise(() => {})); + const handle = await startDoublespeedAppLogPoller({ + host: pollerHost({ existingTail: '', writes: [], sleeps }), + reader: reader({ + readLogs, + [Symbol.asyncDispose]: async () => { + readerDisposed = true; + }, + }), + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }); + const finishing = handle.finish(); + expect(readerDisposed).toBe(false); + sleeps.resolveNext(5_000); + await finishing; + expect(readLogs).toHaveBeenCalledTimes(1); + expect(readerDisposed).toBe(true); + }); +}); + +function pollerHost(options: { + existingTail: string; + writes: string[]; + sleeps: ReturnType; + onOutputDispose?: () => void; + failure?: 'readTail' | 'openAppend'; +}): AppLogRuntimeHost { + return { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => { + throw new Error('unused'); + }, + }, + toolchains: { prepare: async () => undefined }, + artifacts: { + resolveSession: () => ({ + outputPath: '/sessions/one/app.log', + pidPath: '/sessions/one/app-log.pid', + }), + }, + commands: { + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + outputs: { + readTail: async () => { + if (options.failure === 'readTail') throw new Error('tail failed'); + return options.existingTail; + }, + openAppend: async () => { + if (options.failure === 'openAppend') throw new Error('open failed'); + return { + write: async (chunk) => { + options.writes.push(String(chunk)); + }, + [Symbol.asyncDispose]: async () => options.onOutputDispose?.(), + }; + }, + }, + processTransports: { + resolve: async () => ({ mode: 'local' }), + }, + processes: { + start: async () => { + throw new Error('unused'); + }, + readMarker: async () => ({ status: 'missing' }), + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + clock: { + now: () => 100, + sleep: async (milliseconds) => await options.sleeps.wait(milliseconds), + }, + }; +} + +function deferredSleeps() { + const pending: Array<{ milliseconds: number; resolve: () => void }> = []; + return { + wait: async (milliseconds: number) => + await new Promise((resolve) => pending.push({ milliseconds, resolve })), + resolveNext: (milliseconds: number) => { + const index = pending.findIndex((entry) => entry.milliseconds === milliseconds); + if (index < 0) throw new Error(`No ${milliseconds}ms sleep is pending`); + pending.splice(index, 1)[0]?.resolve(); + }, + hasPending: (milliseconds: number) => + pending.some((entry) => entry.milliseconds === milliseconds), + }; +} diff --git a/packages/provider-doublespeed/src/app-log-poller.ts b/packages/provider-doublespeed/src/app-log-poller.ts new file mode 100644 index 0000000000..d63cac91b8 --- /dev/null +++ b/packages/provider-doublespeed/src/app-log-poller.ts @@ -0,0 +1,206 @@ +import type { + AppLogLiveHandle, + AppLogLiveSnapshot, + AppLogOutputSink, + AppLogRuntimeHost, +} from '@agent-device/contracts/app-log-runtime'; +import type { FinishOutcome } from '@agent-device/contracts/durable-resource'; +import { AsyncCleanupStack } from '@agent-device/contracts/async-lifecycle'; +import { createAppLogLiveHandleFromFinish } from '@agent-device/capture-kit'; +import type { LogBackend } from '@agent-device/contracts/observability'; + +const APP_LOG_BACKEND: LogBackend = 'ios-simulator'; +const READ_TIMEOUT_MS = 5_000; +const POLL_INTERVAL_MS = 1_000; +const READ_LINE_LIMIT = 1_000; + +export type DoublespeedAppLogReader = AsyncDisposable & + Readonly<{ + leaseId: string; + simulatorId: string; + readLogs(appBundleId: string, lineLimit: number, signal?: AbortSignal): Promise; + }>; + +export async function startDoublespeedAppLogPoller(options: { + host: AppLogRuntimeHost; + reader: DoublespeedAppLogReader; + appBundleId: string; + outputPath: string; +}): Promise { + const rollback = new AsyncCleanupStack(); + let adopted = false; + rollback.defer(async () => { + if (!adopted) await options.reader[Symbol.asyncDispose](); + }); + try { + const existingTail = await options.host.outputs.readTail(options.outputPath, 256 * 1024); + const output = await options.host.outputs.openAppend(options.outputPath); + rollback.defer(async () => { + if (!adopted) await output[Symbol.asyncDispose](); + }); + const handle = createPollerHandle(options, output, remoteOnlyTail(existingTail)); + adopted = true; + return handle; + } finally { + await rollback[Symbol.asyncDispose](); + } +} + +function createPollerHandle( + options: { + host: AppLogRuntimeHost; + reader: DoublespeedAppLogReader; + appBundleId: string; + outputPath: string; + }, + output: AppLogOutputSink, + existingTail: string, +): AppLogLiveHandle { + const startedAt = options.host.clock.now(); + let state: AppLogLiveSnapshot['state'] = 'active'; + let stopped = false; + let previous = existingTail; + const polling = (async () => { + while (!stopped) { + try { + const read = await boundedRead(options); + if (read.status === 'timeout') { + state = 'failed'; + return; + } + if (stopped) return; + const delta = appendedTail(previous, read.text); + previous = read.text; + if (delta) await output.write(delta.endsWith('\n') ? delta : `${delta}\n`); + state = 'active'; + } catch { + if (stopped) return; + state = 'recovering'; + } + await options.host.clock.sleep(POLL_INTERVAL_MS); + } + })(); + let finishPromise: + | Promise> + | undefined; + const finish = async () => + (finishPromise ??= (async () => { + stopped = true; + await polling; + const failures = await disposeAll([options.reader, output]); + if (failures.length > 0) { + state = 'failed'; + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: 'Doublespeed app-log cleanup did not settle every owned resource', + } as const; + } + state = 'ended'; + return { + status: 'completed', + result: { + backend: APP_LOG_BACKEND, + outputPath: options.outputPath, + completedAt: options.host.clock.now(), + }, + } as const; + })()); + return createAppLogLiveHandleFromFinish({ + inspect: () => ({ backend: APP_LOG_BACKEND, state, startedAt }), + finish, + }); +} + +async function boundedRead(options: { + host: AppLogRuntimeHost; + reader: DoublespeedAppLogReader; + appBundleId: string; +}): Promise | Readonly<{ status: 'timeout' }>> { + const controller = new AbortController(); + const read = settleOnAbort( + options.reader.readLogs(options.appBundleId, READ_LINE_LIMIT, controller.signal), + controller.signal, + ).then((text) => ({ status: 'read' as const, text })); + try { + const result = await Promise.race([ + read, + options.host.clock + .sleep(READ_TIMEOUT_MS, controller.signal) + .then(() => ({ status: 'timeout' as const })), + ]); + if (result.status === 'timeout') { + controller.abort(); + await read.catch(() => undefined); + } + return result; + } finally { + controller.abort(); + } +} + +/** A reader that ignores its signal must still let the bounded read settle on abort. */ +async function settleOnAbort(source: Promise, signal: AbortSignal): Promise { + return await new Promise((resolve, reject) => { + const aborted = () => reject(signal.reason ?? new Error('App-log provider read aborted')); + if (signal.aborted) { + aborted(); + return; + } + signal.addEventListener('abort', aborted, { once: true }); + void source.then( + (value) => { + signal.removeEventListener('abort', aborted); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', aborted); + reject(error); + }, + ); + }); +} + +/** The new tail minus its overlap with the previous one (longest suffix/prefix match). */ +function appendedTail(previous: string, current: string): string { + if (!previous || !current) return current; + const prefix = buildPrefixTable(current); + const maximum = Math.min(previous.length, current.length); + const suffix = previous.slice(previous.length - maximum); + return current.slice(suffixPrefixOverlap(suffix, current, prefix)); +} + +function buildPrefixTable(text: string): Uint32Array { + const prefix = new Uint32Array(text.length); + let matched = 0; + for (let index = 1; index < text.length; index += 1) { + while (matched > 0 && text[index] !== text[matched]) matched = prefix[matched - 1]!; + if (text[index] === text[matched]) matched += 1; + prefix[index] = matched; + } + return prefix; +} + +function suffixPrefixOverlap(suffix: string, current: string, prefix: Uint32Array): number { + let matched = 0; + for (let index = 0; index < suffix.length; index += 1) { + while (matched > 0 && suffix[index] !== current[matched]) matched = prefix[matched - 1]!; + if (suffix[index] === current[matched]) matched += 1; + if (matched === current.length && index < suffix.length - 1) matched = prefix[matched - 1]!; + } + return matched; +} + +function remoteOnlyTail(tail: string): string { + return tail + .split('\n') + .filter((line) => !line.startsWith('[agent-device][mark]')) + .join('\n'); +} + +async function disposeAll(resources: readonly AsyncDisposable[]): Promise { + const results = await Promise.allSettled( + resources.map(async (resource) => await resource[Symbol.asyncDispose]()), + ); + return results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])); +} diff --git a/packages/provider-doublespeed/src/app-log-reconnect.test.ts b/packages/provider-doublespeed/src/app-log-reconnect.test.ts new file mode 100644 index 0000000000..e6e3d21696 --- /dev/null +++ b/packages/provider-doublespeed/src/app-log-reconnect.test.ts @@ -0,0 +1,67 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { expect, test, vi } from 'vitest'; +import type { DoublespeedApiClient } from './api-client.ts'; +import { reconnectDoublespeedAppLogReader } from './app-log-reconnect.ts'; +import { readySimulator, scriptedFetch } from './runtime.fixtures.ts'; + +const descriptor = { + transport: 'doublespeed-log-poller', + leaseId: 'lease-a', + simulatorId: 'sim-a', + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', +} as const; + +test('reattaches an owned simulator through its session URL', async () => { + const { fetch } = scriptedFetch([ + () => ({ body: { bundle_id: 'com.example.app', text: 'provider line\n' } }), + ]); + vi.stubGlobal('fetch', fetch); + const getSimulator = vi.fn(async () => readySimulator()); + try { + const signal = new AbortController().signal; + const outcome = await reconnectDoublespeedAppLogReader({ + api: { getSimulator } as unknown as DoublespeedApiClient, + descriptor, + signal, + }); + expect(getSimulator).toHaveBeenCalledWith('sim-a', { signal }); + expect(outcome.status).toBe('opened'); + if (outcome.status !== 'opened') return; + expect(await outcome.reader.readLogs('com.example.app', 20)).toBe('provider line\n'); + await outcome.reader[Symbol.asyncDispose](); + } finally { + vi.unstubAllGlobals(); + } +}); + +test('fails closed when the simulator labels do not match the descriptor lease', async () => { + const outcome = await reconnectDoublespeedAppLogReader({ + api: { + getSimulator: async () => + readySimulator({ labels: { provider: 'doublespeed', leaseId: 'other' } }), + } as unknown as DoublespeedApiClient, + descriptor, + }); + expect(outcome).toEqual({ status: 'ownership-lost' }); +}); + +test('reports a missing reader for an ended or unknown simulator', async () => { + const ended = await reconnectDoublespeedAppLogReader({ + api: { + getSimulator: async () => + readySimulator({ ready: false, status: 'cancelled', api_url: null }), + } as unknown as DoublespeedApiClient, + descriptor, + }); + expect(ended).toEqual({ status: 'missing' }); + const unknown = await reconnectDoublespeedAppLogReader({ + api: { + getSimulator: async () => { + throw new AppError('COMMAND_FAILED', 'not found', { status: 404 }); + }, + } as unknown as DoublespeedApiClient, + descriptor, + }); + expect(unknown).toEqual({ status: 'missing' }); +}); diff --git a/packages/provider-doublespeed/src/app-log-reconnect.ts b/packages/provider-doublespeed/src/app-log-reconnect.ts new file mode 100644 index 0000000000..ed3efb09dc --- /dev/null +++ b/packages/provider-doublespeed/src/app-log-reconnect.ts @@ -0,0 +1,44 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { DoublespeedApiClient } from './api-client.ts'; +import type { DoublespeedAppLogDescriptor } from './app-log-descriptor.ts'; +import type { DoublespeedAppLogReader } from './app-log-poller.ts'; +import type { DoublespeedAppLogReconnectOutcome } from './app-log-runtime.ts'; +import { DOUBLESPEED_PROVIDER } from './device.ts'; +import { createDoublespeedSessionClient } from './session-client.ts'; + +export async function reconnectDoublespeedAppLogReader(options: { + api: DoublespeedApiClient; + descriptor: DoublespeedAppLogDescriptor; + signal?: AbortSignal; +}): Promise { + let simulator; + try { + simulator = await options.api.getSimulator(options.descriptor.simulatorId, { + signal: options.signal, + }); + } catch (error) { + if (error instanceof AppError && isMissingStatus(error)) return { status: 'missing' }; + throw error; + } + if ( + simulator.labels.provider !== DOUBLESPEED_PROVIDER || + simulator.labels.leaseId !== options.descriptor.leaseId + ) { + return { status: 'ownership-lost' }; + } + if (!simulator.ready || !simulator.api_url) return { status: 'missing' }; + const client = createDoublespeedSessionClient(simulator.api_url); + const reader: DoublespeedAppLogReader = { + leaseId: options.descriptor.leaseId, + simulatorId: options.descriptor.simulatorId, + readLogs: async (appBundleId, lineLimit, signal) => + await client.appLogTail(appBundleId, lineLimit, signal), + [Symbol.asyncDispose]: async () => undefined, + }; + return { status: 'opened', reader }; +} + +function isMissingStatus(error: AppError): boolean { + const status = (error.details as { status?: unknown } | undefined)?.status; + return status === 404; +} diff --git a/packages/provider-doublespeed/src/app-log-runtime.test.ts b/packages/provider-doublespeed/src/app-log-runtime.test.ts new file mode 100644 index 0000000000..4d585ed4d6 --- /dev/null +++ b/packages/provider-doublespeed/src/app-log-runtime.test.ts @@ -0,0 +1,163 @@ +import { narrowDeviceBinding } from '@agent-device/contracts/platform-runtime'; +import { + appStateUse, + appsRuntimeUse, + bootTargetUse, +} from '@agent-device/contracts/platform-runtime-operations'; +import { expect, test, vi } from 'vitest'; +import { createDoublespeedAppLogEnvelope } from './app-log-descriptor.ts'; +import { createDoublespeedPlatformRuntimeOwner } from './app-log-runtime.ts'; +import { + doublespeedIosDevice as device, + doublespeedOwnerOptions, + doublespeedScope as scope, +} from './runtime.fixtures.ts'; + +const descriptor = { + transport: 'doublespeed-log-poller', + leaseId: 'lease-a', + simulatorId: 'sim-a', + appBundleId: 'com.example.app', + outputPath: '/sessions/session/app.log', +} as const; + +test('rejects a descriptor for another lease before provider reconnection', async () => { + const reconnect = vi.fn(async () => ({ status: 'missing' as const })); + const owner = createDoublespeedPlatformRuntimeOwner(doublespeedOwnerOptions({ reconnect })); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + expect(binding.facts.device.providerMode).toBe('provider-runtime'); + const envelope = createDoublespeedAppLogEnvelope({ + sessionId: 'session', + device, + owner: owner.owner, + fence: { token: 'fence', generation: 1 }, + descriptor: { ...descriptor, leaseId: 'lease-b' }, + }); + await expect(binding.operations.appLogReattach?.({ envelope })).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'descriptor-invalid', + }); + await expect(binding.operations.appLogCleanup?.({ envelope })).resolves.toMatchObject({ + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + }); + expect(reconnect).not.toHaveBeenCalled(); +}); + +test('rejects cross-session paths before reconnecting or opening a provider reader', async () => { + const reconnect = vi.fn(async () => ({ status: 'missing' as const })); + const openCurrent = vi.fn(async () => undefined); + const owner = createDoublespeedPlatformRuntimeOwner( + doublespeedOwnerOptions({ openCurrent, reconnect }), + ); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + const envelope = createDoublespeedAppLogEnvelope({ + sessionId: 'one', + device, + owner: owner.owner, + fence: { token: 'fence', generation: 1 }, + descriptor: { ...descriptor, outputPath: '/sessions/two/app.log' }, + }); + await expect(binding.operations.appLogReattach?.({ envelope })).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'descriptor-invalid', + }); + await expect( + binding.operations.appLogStart?.({ + sessionId: 'one', + appBundleId: 'com.example.app', + outputPath: '/sessions/two/app.log', + fence: { token: 'fence', generation: 1 }, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(reconnect).not.toHaveBeenCalled(); + expect(openCurrent).not.toHaveBeenCalled(); +}); + +test('keeps exact-owner app-log recovery available without a process-local session', async () => { + const openCurrent = vi.fn(async () => undefined); + const reconnect = vi.fn(async () => ({ status: 'missing' as const })); + const owner = createDoublespeedPlatformRuntimeOwner( + doublespeedOwnerOptions({ hasLiveSession: () => false, openCurrent, reconnect }), + ); + const binding = await owner.bind({ + device, + intent: { kind: 'exact-owner', owner: owner.owner, fence: { token: 'fence', generation: 1 } }, + scope, + }); + const envelope = createDoublespeedAppLogEnvelope({ + sessionId: 'session', + device, + owner: owner.owner, + fence: { token: 'fence', generation: 1 }, + descriptor, + }); + + expect(binding.operations.appLogReattach).toEqual(expect.any(Function)); + expect(binding.operations.appState).toBeUndefined(); + expect(binding.operations.listApps).toBeUndefined(); + expect(binding.facts.operations.appLogInspect).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + }); + expect(binding.facts.operations.appLogReattach).toEqual({ available: true }); + expect(() => narrowDeviceBinding(binding, appStateUse)).toThrow( + expect.objectContaining({ code: 'UNSUPPORTED_OPERATION' }), + ); + expect(() => narrowDeviceBinding(binding, bootTargetUse)).toThrow( + expect.objectContaining({ code: 'UNSUPPORTED_OPERATION' }), + ); + expect(() => narrowDeviceBinding(binding, appsRuntimeUse)).toThrow( + expect.objectContaining({ code: 'UNSUPPORTED_OPERATION' }), + ); + await expect(binding.operations.appLogReattach?.({ envelope })).resolves.toEqual({ + status: 'missing', + }); + await expect(binding.operations.appLogCleanup?.({ envelope })).resolves.toEqual({ + status: 'cleaned', + }); + expect(openCurrent).not.toHaveBeenCalled(); + expect(reconnect).toHaveBeenCalledOnce(); +}); + +test('a live binding serves app state and inventory through the provider session', async () => { + const listApps = vi.fn(async () => [{ id: 'com.example.app', name: 'Example' }]); + const getAppState = vi.fn(async () => ({ package: 'com.example.app' })); + const owner = createDoublespeedPlatformRuntimeOwner( + doublespeedOwnerOptions({ listApps, getAppState }), + ); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + expect(binding.facts.operations.appState).toEqual({ available: true }); + await expect(binding.operations.appState?.()).resolves.toEqual({ package: 'com.example.app' }); + await expect( + binding.operations.listApps?.({ device, filter: 'user-installed' }), + ).resolves.toEqual([{ id: 'com.example.app', name: 'Example' }]); +}); + +test.each([ + { + name: 'non-iOS Apple leaf', + device: { ...device, appleOs: 'macos' as const, target: 'desktop' as const }, + }, + { name: 'physical Apple kind', device: { ...device, kind: 'device' as const } }, + { + name: 'Android-shaped identity', + device: { + ...device, + platform: 'android' as const, + appleOs: undefined, + kind: 'emulator' as const, + }, + }, +])('rejects exact binding for an impossible $name', async ({ device: invalidDevice }) => { + const reconnect = vi.fn(async () => ({ status: 'missing' as const })); + const owner = createDoublespeedPlatformRuntimeOwner(doublespeedOwnerOptions({ reconnect })); + await expect( + owner.bind({ + device: invalidDevice, + intent: { kind: 'exact-owner', owner: owner.owner, fence: { token: 'fence', generation: 1 } }, + scope, + }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_PLATFORM' }); + expect(reconnect).not.toHaveBeenCalled(); +}); diff --git a/packages/provider-doublespeed/src/app-log-runtime.ts b/packages/provider-doublespeed/src/app-log-runtime.ts new file mode 100644 index 0000000000..13937afaf0 --- /dev/null +++ b/packages/provider-doublespeed/src/app-log-runtime.ts @@ -0,0 +1,336 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppsFilter } from '@agent-device/contracts/device'; +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { AppError } from '@agent-device/kernel/errors'; +import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; +import { + type DeviceBinding, + providerRuntimeOwner, + sameRuntimeOwner, +} from '@agent-device/contracts/platform-runtime'; +import type { + PlatformRuntimeHost, + PlatformRuntimeOperations, + PlatformRuntimeOwner, +} from '@agent-device/contracts/platform-runtime-operations'; +import { + appLogSessionArtifactsMatch, + assertAppLogSessionArtifacts, + createAppLogRecoveryOperations, + createAppLogStartResult, + readRecentNetworkTrafficFromText, +} from '@agent-device/capture-kit'; +import { availableApplicationLifecycleOperations } from '@agent-device/contracts/application-lifecycle-runtime'; +import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; +import { + createDoublespeedAppLogEnvelope, + doublespeedAppLogDescriptorCodec, + type DoublespeedAppLogDescriptor, +} from './app-log-descriptor.ts'; +import { startDoublespeedAppLogPoller, type DoublespeedAppLogReader } from './app-log-poller.ts'; +import { + createDoublespeedAppDeploymentOperations, + type DoublespeedAppDeploymentRuntimeOptions, +} from './deployment-runtime.ts'; +import { + DOUBLESPEED_PROVIDER, + isSupportedDoublespeedDevice, + parseDoublespeedDeviceId, +} from './device.ts'; +import { + deploymentOptions, + doublespeedLifecycleFacts, + doublespeedRecoveryFacts, + doublespeedRuntimeFacts, + liveSessionUnavailable, +} from './facts-runtime.ts'; +import { bindDoublespeedInteractionOperations } from './interaction-operations.ts'; +import { bindDoublespeedApplicationLifecycle } from './lifecycle.ts'; + +const APP_LOG_BACKEND = 'ios-simulator' as const; + +export type DoublespeedAppLogReconnectOutcome = + | Readonly<{ status: 'opened'; reader: DoublespeedAppLogReader }> + | Readonly<{ status: 'missing' }> + | Readonly<{ status: 'ownership-lost' }>; + +export type DoublespeedPlatformRuntimeOwnerOptions = Omit< + DoublespeedAppDeploymentRuntimeOptions, + 'isSessionActive' +> & + Readonly<{ + host: PlatformRuntimeHost; + runtimeInstance: string; + ownsDevice(device: DeviceInfo): boolean; + getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; + openCurrent(device: DeviceInfo): Promise; + hasLiveSession(device: DeviceInfo): boolean; + reconnect( + descriptor: DoublespeedAppLogDescriptor, + signal?: AbortSignal, + ): Promise; + listApps( + device: DeviceInfo, + filter: AppsFilter, + signal: AbortSignal, + ): Promise; + getAppState(device: DeviceInfo, signal: AbortSignal): Promise; + }>; + +export function createDoublespeedPlatformRuntimeOwner( + options: DoublespeedPlatformRuntimeOwnerOptions, +): PlatformRuntimeOwner { + const owner = providerRuntimeOwner(DOUBLESPEED_PROVIDER, options.runtimeInstance); + const ownsDevice = (device: DeviceInfo) => + isSupportedDoublespeedDevice(device) && options.ownsDevice(device); + const hasLiveSession = (device: DeviceInfo) => + ownsDevice(device) && options.hasLiveSession(device); + return Object.freeze({ + owner, + ownsDevice, + inspectFacts: async (device) => + hasLiveSession(device) + ? doublespeedRuntimeFacts(options, device) + : createUnavailablePlatformRuntimeFacts(device, owner, { + appLog: liveSessionUnavailable, + appState: liveSessionUnavailable, + appDeployment: liveSessionUnavailable, + network: liveSessionUnavailable, + screenshot: liveSessionUnavailable, + viewport: liveSessionUnavailable, + focus: liveSessionUnavailable, + gesture: liveSessionUnavailable, + scroll: liveSessionUnavailable, + typeText: liveSessionUnavailable, + touch: liveSessionUnavailable, + elementText: liveSessionUnavailable, + back: liveSessionUnavailable, + systemButton: liveSessionUnavailable, + orientation: liveSessionUnavailable, + fold: liveSessionUnavailable, + tvRemote: liveSessionUnavailable, + keyboard: liveSessionUnavailable, + clipboard: liveSessionUnavailable, + triggerAppEvent: liveSessionUnavailable, + settings: liveSessionUnavailable, + readAlert: liveSessionUnavailable, + awaitAlert: liveSessionUnavailable, + acceptAlert: liveSessionUnavailable, + dismissAlert: liveSessionUnavailable, + audioProbeCapture: liveSessionUnavailable, + audioProbeQuery: liveSessionUnavailable, + perf: liveSessionUnavailable, + readiness: liveSessionUnavailable, + shutdown: liveSessionUnavailable, + lifecycle: doublespeedLifecycleFacts(device, false), + }), + bind: async (request) => { + if (request.intent.kind === 'exact-owner' && !sameRuntimeOwner(request.intent.owner, owner)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Doublespeed app-log owner identity does not match', + ); + } + if (!isSupportedDoublespeedDevice(request.device)) { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + 'Doublespeed app logs require an iOS simulator device identity', + ); + } + const hasMatchingLiveSession = hasLiveSession(request.device); + if (request.intent.kind !== 'exact-owner' && !hasMatchingLiveSession) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Doublespeed provider session is no longer live for the selected device', + { reason: 'provider-session-unavailable' }, + ); + } + return bindDoublespeedAppLogs( + options, + owner, + request.device, + request.scope.signal, + !hasMatchingLiveSession, + ); + }, + shutdown: async () => undefined, + }); +} + +function bindDoublespeedAppLogs( + options: DoublespeedPlatformRuntimeOwnerOptions, + owner: ReturnType, + device: DeviceInfo, + signal: AbortSignal, + recoveryOnly: boolean, +): DeviceBinding { + const runtimeFacts = recoveryOnly + ? doublespeedRecoveryFacts(options, device) + : doublespeedRuntimeFacts(options, device); + const recovery = createAppLogRecoveryOperations({ + codec: doublespeedAppLogDescriptorCodec, + reattach: async (descriptor, context) => { + if ( + !descriptorMatchesDevice(descriptor, device) || + !appLogSessionArtifactsMatch(options.host, context.sessionId, descriptor) + ) { + return { + status: 'unreattachable', + reason: 'descriptor-invalid', + message: + 'Doublespeed app-log descriptor does not match the bound device or owning session', + }; + } + const reconnected = await options.reconnect(descriptor, signal); + if (reconnected.status === 'missing') return { status: 'missing' }; + if (reconnected.status === 'ownership-lost') { + return { status: 'unreattachable', reason: 'ownership-fence-lost' }; + } + return { + status: 'active', + handle: await startDoublespeedAppLogPoller({ + host: options.host, + reader: reconnected.reader, + appBundleId: descriptor.appBundleId, + outputPath: descriptor.outputPath, + }), + }; + }, + cleanup: async (descriptor, context) => + descriptorMatchesDevice(descriptor, device) && + appLogSessionArtifactsMatch(options.host, context.sessionId, descriptor) + ? { status: 'cleaned' } + : { + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + message: + 'Doublespeed app-log descriptor does not match the bound device or owning session', + }, + }); + const operations = { + appLogInspect: async () => ({ backend: APP_LOG_BACKEND }), + appLogDoctor: async () => ({ + backend: APP_LOG_BACKEND, + checks: { + doublespeedSessionAvailable: await currentSessionAvailable(options, device, signal), + }, + notes: [], + }), + appLogStart: async (input) => { + assertAppLogSessionArtifacts(options.host, input); + signal.throwIfAborted(); + const reader = await options.openCurrent(device); + if (!reader) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Doublespeed app logs require an active simulator', + ); + } + const descriptor: DoublespeedAppLogDescriptor = { + transport: 'doublespeed-log-poller', + leaseId: reader.leaseId, + simulatorId: reader.simulatorId, + appBundleId: input.appBundleId, + outputPath: input.outputPath, + }; + let pollerOwnsReader = false; + try { + signal.throwIfAborted(); + const envelope = createDoublespeedAppLogEnvelope({ + sessionId: input.sessionId, + device, + owner, + fence: input.fence, + descriptor, + }); + pollerOwnsReader = true; + const handle = await startDoublespeedAppLogPoller({ + host: options.host, + reader, + appBundleId: input.appBundleId, + outputPath: input.outputPath, + }); + return createAppLogStartResult(handle, envelope); + } catch (error) { + if (!pollerOwnsReader) await reader[Symbol.asyncDispose](); + throw error; + } + }, + ...recovery, + networkDump: async (input) => { + const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); + const { dump } = readRecentNetworkTrafficFromText(recent.text, { + ...input, + path: recent.path, + exists: recent.exists, + lineNumberOffset: recent.skippedLines, + backend: APP_LOG_BACKEND, + }); + const notes = + dump.entries.length === 0 + ? ['No HTTP(s) entries were found in recent session app logs.'] + : []; + return Object.freeze({ source: 'app-log' as const, backend: APP_LOG_BACKEND, dump, notes }); + }, + ensureReady: async () => ({ ...device, booted: true }), + bootTarget: async () => ({ ...device, booted: true }), + listApps: async (input) => await options.listApps(input.device, input.filter, signal), + appState: async () => await options.getAppState(device, signal), + ...availableApplicationLifecycleOperations( + bindDoublespeedApplicationLifecycle({ + device, + signal, + getInteractor: options.getInteractor, + }), + runtimeFacts.operations, + ), + ...bindDoublespeedInteractionOperations({ + device, + signal, + getInteractor: options.getInteractor, + }), + ...bindAdmittedProviderInteractorOperations({ + device, + signal, + resolveInteractor: (runner) => options.getInteractor(device, runner), + facts: runtimeFacts.operations, + }), + ...createDoublespeedAppDeploymentOperations(deploymentOptions(options), device, signal), + } satisfies DeviceBinding['operations']; + return Object.freeze({ + device, + owner, + facts: runtimeFacts, + operations: Object.freeze( + recoveryOnly + ? { appLogReattach: recovery.appLogReattach, appLogCleanup: recovery.appLogCleanup } + : operations, + ), + [Symbol.asyncDispose]: async () => undefined, + }); +} + +async function currentSessionAvailable( + options: DoublespeedPlatformRuntimeOwnerOptions, + device: DeviceInfo, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const reader = await options.openCurrent(device); + if (!reader) return false; + try { + signal.throwIfAborted(); + } finally { + await reader[Symbol.asyncDispose](); + } + return true; +} + +function descriptorMatchesDevice( + descriptor: DoublespeedAppLogDescriptor, + device: DeviceInfo, +): boolean { + if (!isSupportedDoublespeedDevice(device)) return false; + return parseDoublespeedDeviceId(device.id)?.leaseId === descriptor.leaseId; +} diff --git a/packages/provider-doublespeed/src/connection-verification.test.ts b/packages/provider-doublespeed/src/connection-verification.test.ts new file mode 100644 index 0000000000..83acdb6e4b --- /dev/null +++ b/packages/provider-doublespeed/src/connection-verification.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { expect, test, vi } from 'vitest'; +import { verifyDoublespeedConnection } from './connection-verification.ts'; +import { scriptedFetch } from './runtime.fixtures.ts'; + +test('verification reads the simulator list without creating a simulator', async () => { + const { fetch, calls } = scriptedFetch([() => ({ body: { simulators: [] } })]); + vi.stubGlobal('fetch', fetch); + try { + const result = await verifyDoublespeedConnection({ + apiKey: 'dsx_test_key', + clientVersion: '1.2.3', + device: 'iPhone 16 Pro', + }); + assert.deepEqual(result, { + provider: 'doublespeed', + service: 'Doublespeed', + verificationMessage: 'Credentials and iOS simulator access verified.', + device: { status: 'deferred', name: 'Doublespeed iPhone 16 Pro simulator', platform: 'ios' }, + app: { + status: 'missing', + message: 'A new Doublespeed simulator does not have your app yet.', + }, + }); + expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([ + 'GET https://api.mac.doublespeed.ai/v1/xcode/simulators', + ]); + expect(calls[0]?.init.headers).toMatchObject({ + 'x-agent-device-client': 'agent-device-cli', + 'x-agent-device-version': '1.2.3', + }); + } finally { + vi.unstubAllGlobals(); + } +}); + +test('verification classifies authentication failures without echoing the key', async () => { + const { fetch } = scriptedFetch([ + () => ({ status: 401, body: { error: { code: 'UNAUTHORIZED' } } }), + ]); + vi.stubGlobal('fetch', fetch); + try { + await assert.rejects( + verifyDoublespeedConnection({ apiKey: 'dsx_bad_key', clientVersion: '1.2.3' }), + (error: unknown) => { + assert.equal((error as { code?: string }).code, 'UNAUTHORIZED'); + assert.doesNotMatch(JSON.stringify(error), /dsx_bad_key/); + return true; + }, + ); + } finally { + vi.unstubAllGlobals(); + } +}); diff --git a/packages/provider-doublespeed/src/connection-verification.ts b/packages/provider-doublespeed/src/connection-verification.ts new file mode 100644 index 0000000000..152b335fb7 --- /dev/null +++ b/packages/provider-doublespeed/src/connection-verification.ts @@ -0,0 +1,63 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { ProviderConnectionVerification } from '@agent-device/contracts/remote'; +import { DoublespeedApiClient } from './api-client.ts'; + +export type DoublespeedConnectionVerification = ProviderConnectionVerification & { + provider: 'doublespeed'; + service: 'Doublespeed'; + device: { + status: 'deferred'; + name: string; + platform: 'ios'; + }; + app: { + status: 'missing'; + message: string; + }; +}; + +export type DoublespeedConnectionVerificationOptions = { + apiKey: string; + apiUrl?: string; + clientVersion: string; + device?: string; +}; + +export async function verifyDoublespeedConnection( + options: DoublespeedConnectionVerificationOptions, +): Promise { + const client = new DoublespeedApiClient(options); + try { + await client.listSimulators({}); + } catch (error) { + if (error instanceof AppError && error.code === 'UNAUTHORIZED') { + throw new AppError('UNAUTHORIZED', 'Doublespeed rejected connection verification.', { + hint: 'Check DOUBLESPEED_API_KEY and its organization access.', + }); + } + throw new AppError( + 'COMMAND_FAILED', + 'Doublespeed connection verification failed.', + { + hint: 'Check Doublespeed service access, DOUBLESPEED_API_URL, and network connectivity, then retry.', + }, + error, + ); + } + return { + provider: 'doublespeed', + service: 'Doublespeed', + verificationMessage: 'Credentials and iOS simulator access verified.', + device: { + status: 'deferred', + name: options.device + ? `Doublespeed ${options.device} simulator` + : 'Provider-selected iOS simulator', + platform: 'ios', + }, + app: { + status: 'missing', + message: 'A new Doublespeed simulator does not have your app yet.', + }, + }; +} diff --git a/packages/provider-doublespeed/src/deployment-runtime.test.ts b/packages/provider-doublespeed/src/deployment-runtime.test.ts new file mode 100644 index 0000000000..e36e040cb2 --- /dev/null +++ b/packages/provider-doublespeed/src/deployment-runtime.test.ts @@ -0,0 +1,109 @@ +import { expect, test, vi } from 'vitest'; +import { + createDoublespeedAppDeploymentOperations, + doublespeedAppDeploymentFacts, +} from './deployment-runtime.ts'; +import { doublespeedIosDevice, unusedDoublespeedHost } from './runtime.fixtures.ts'; + +test('classifies deployment as unavailable without provider deployment callbacks', () => { + const facts = doublespeedAppDeploymentFacts( + { host: unusedDoublespeedHost(), ownsDevice: () => true }, + doublespeedIosDevice, + ); + for (const operation of [ + facts.deployApp, + facts.materializeAppSource, + facts.deployMaterializedApp, + ]) { + expect(operation).toMatchObject({ available: false, reason: 'owner-capability-missing' }); + } + expect(facts.sendPushNotification).toMatchObject({ reason: 'unsupported-provider-mode' }); +}); + +test('uses the admitted provider deployment without a local fallback', async () => { + const deployApp = vi.fn(async () => ({ + bundleId: 'com.example.app', + launchTarget: 'com.example.app', + })); + const deployMaterializedApp = vi.fn(async () => ({ + bundleId: 'com.example.app', + launchTarget: 'com.example.app', + })); + const materializeApple = vi.fn(async () => ({ + installablePath: '/tmp/App.app', + cleanup: async () => {}, + })); + const base = unusedDoublespeedHost(); + const options = { + host: { + ...base, + appleDeployment: { ...base.appleDeployment, prepareArtifact: materializeApple }, + }, + ownsDevice: () => true, + deployApp, + deployMaterializedApp, + }; + const facts = doublespeedAppDeploymentFacts(options, doublespeedIosDevice); + const operations = createDoublespeedAppDeploymentOperations( + options, + doublespeedIosDevice, + new AbortController().signal, + ); + + for (const fact of [facts.deployApp, facts.materializeAppSource, facts.deployMaterializedApp]) { + expect(fact).toEqual({ available: true }); + } + await operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/app', + replaceExisting: false, + }); + const artifact = await operations.materializeAppSource?.({ + source: { kind: 'path', path: '/tmp/app' }, + }); + await operations.deployMaterializedApp?.({ artifact: artifact! }); + expect(deployApp).toHaveBeenCalledOnce(); + expect(deployMaterializedApp).toHaveBeenCalledOnce(); + expect(materializeApple).toHaveBeenCalledOnce(); +}); + +test('refuses deployment on a device that is not a Doublespeed simulator', () => { + const facts = doublespeedAppDeploymentFacts( + { + host: unusedDoublespeedHost(), + ownsDevice: () => true, + deployApp: async () => undefined, + deployMaterializedApp: async () => undefined, + }, + { ...doublespeedIosDevice, kind: 'device' }, + ); + expect(facts.deployApp).toMatchObject({ available: false }); +}); + +test('aborts an in-flight deployment with the binding signal', async () => { + const controller = new AbortController(); + const abortReason = new Error('request cancelled during Doublespeed deployment'); + const deployApp = vi.fn(async (_device, _input, signal: AbortSignal) => { + expect(signal).toBe(controller.signal); + return await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + const operations = createDoublespeedAppDeploymentOperations( + { + host: unusedDoublespeedHost(), + ownsDevice: () => true, + deployApp, + deployMaterializedApp: async () => undefined, + }, + doublespeedIosDevice, + controller.signal, + ); + const pending = operations.deployApp?.({ + app: 'com.example.app', + appPath: '/tmp/app', + replaceExisting: false, + }); + controller.abort(abortReason); + await expect(pending).rejects.toBe(abortReason); +}); diff --git a/packages/provider-doublespeed/src/deployment-runtime.ts b/packages/provider-doublespeed/src/deployment-runtime.ts new file mode 100644 index 0000000000..30cfcc44c7 --- /dev/null +++ b/packages/provider-doublespeed/src/deployment-runtime.ts @@ -0,0 +1,118 @@ +import type { + AppDeploymentInput, + AppDeploymentResult, + DeployMaterializedAppInput, + MaterializeAppSourceInput, +} from '@agent-device/contracts/app-deployment-runtime'; +import type { + PlatformRuntimeHost, + PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import type { ProviderDeviceInstallResult } from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { isSupportedDoublespeedDevice } from './device.ts'; + +const available = Object.freeze({ available: true } as const); +const deploymentUnavailable = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'The Doublespeed provider session is no longer active for this device.', +} as const); +const pushUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Push notifications are unavailable for Doublespeed provider-owned devices.', +} as const); + +export type DoublespeedAppDeploymentRuntimeOptions = Readonly<{ + host: PlatformRuntimeHost; + ownsDevice(device: DeviceInfo): boolean; + /** A currently live provider session, distinct from the provider-owned device namespace. */ + isSessionActive?(device: DeviceInfo): boolean; + deployApp?( + device: DeviceInfo, + input: AppDeploymentInput, + signal: AbortSignal, + ): Promise; + deployMaterializedApp?( + device: DeviceInfo, + input: DeployMaterializedAppInput, + signal: AbortSignal, + ): Promise; +}>; + +export function doublespeedAppDeploymentFacts( + options: DoublespeedAppDeploymentRuntimeOptions, + device: DeviceInfo, +): Readonly<{ + deployApp: RuntimeOperationFact; + materializeAppSource: RuntimeOperationFact; + deployMaterializedApp: RuntimeOperationFact; + sendPushNotification: RuntimeOperationFact; +}> { + const deployment = doublespeedDeploymentFact(options, device); + return Object.freeze({ + deployApp: deployment, + materializeAppSource: deployment, + deployMaterializedApp: deployment, + sendPushNotification: isActiveSession(options, device) + ? pushUnavailable + : deploymentUnavailable, + }); +} + +export function createDoublespeedAppDeploymentOperations( + options: DoublespeedAppDeploymentRuntimeOptions, + device: DeviceInfo, + signal: AbortSignal, +): Partial { + const deployApp = options.deployApp; + const deployMaterializedApp = options.deployMaterializedApp; + if ( + !doublespeedAppDeploymentFacts(options, device).deployApp.available || + !deployApp || + !deployMaterializedApp + ) { + return Object.freeze({}); + } + return Object.freeze({ + deployApp: async (input: AppDeploymentInput) => + deploymentResult(await deployApp(device, input, signal)), + materializeAppSource: async (input: MaterializeAppSourceInput) => + await options.host.appleDeployment.prepareArtifact(input, { signal }), + deployMaterializedApp: async (input: DeployMaterializedAppInput) => + deploymentResult(await deployMaterializedApp(device, input, signal)), + }); +} + +function doublespeedDeploymentFact( + options: DoublespeedAppDeploymentRuntimeOptions, + device: DeviceInfo, +): RuntimeOperationFact { + return isSupportedDoublespeedDevice(device) && + isActiveSession(options, device) && + options.deployApp && + options.deployMaterializedApp + ? available + : deploymentUnavailable; +} + +/** Admission liveness is synchronous metadata from the provider runtime, never a bind-time probe. */ +function isActiveSession( + options: Pick, + device: DeviceInfo, +): boolean { + return options.isSessionActive?.(device) ?? options.ownsDevice(device); +} + +function deploymentResult(result: ProviderDeviceInstallResult | undefined): AppDeploymentResult { + if (!result) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'The Doublespeed provider session is no longer active.', + ); + } + return result; +} diff --git a/packages/provider-doublespeed/src/device-session.test.ts b/packages/provider-doublespeed/src/device-session.test.ts new file mode 100644 index 0000000000..c2f1e89e20 --- /dev/null +++ b/packages/provider-doublespeed/src/device-session.test.ts @@ -0,0 +1,38 @@ +import { expect, test, vi } from 'vitest'; +import { createDoublespeedDeviceSession } from './device-session.ts'; +import type { DoublespeedIosSession } from './ios.ts'; +import { + doublespeedIosDevice, + doublespeedLease, + doublespeedTestDependencies, +} from './runtime.fixtures.ts'; + +const IOS_APPS = [ + { bundleId: 'com.example.ios', name: 'Example', installType: 'User' }, + { bundleId: 'com.apple.Preferences', name: 'Settings', installType: 'System' }, +]; + +test('exposes inventory, logs and foreground state without the raw client', async () => { + const appLogTail = vi.fn(async () => 'line one\nline two\n'); + const foregroundApp = vi.fn(async () => ({ bundleId: 'com.example.ios' })); + const session = createDoublespeedDeviceSession({ + lease: doublespeedLease(), + simulatorId: 'sim-a', + device: doublespeedIosDevice, + client: { listApps: async () => IOS_APPS, appLogTail, foregroundApp }, + screen: { width: 393, height: 852, scale: 3 }, + dependencies: doublespeedTestDependencies, + } as unknown as DoublespeedIosSession); + + expect(await session.listApps()).toEqual([ + { id: 'com.example.ios', name: 'Example', installType: 'User' }, + ]); + expect(await session.listApps('all')).toEqual([ + { id: 'com.apple.Preferences', name: 'Settings', installType: 'System' }, + { id: 'com.example.ios', name: 'Example', installType: 'User' }, + ]); + expect(await session.readLogs('com.example.ios', 200)).toBe('line one\nline two\n'); + expect(await session.getForegroundApp()).toEqual({ appId: 'com.example.ios' }); + expect(appLogTail.mock.calls[0]).toEqual(['com.example.ios', 200, undefined]); + expect('client' in session).toBe(false); +}); diff --git a/packages/provider-doublespeed/src/device-session.ts b/packages/provider-doublespeed/src/device-session.ts new file mode 100644 index 0000000000..2c0888647d --- /dev/null +++ b/packages/provider-doublespeed/src/device-session.ts @@ -0,0 +1,40 @@ +import { resolveAppsFilter, type AppsFilter } from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { isUserInstalledIosApp, type DoublespeedIosSession } from './ios.ts'; + +export type DoublespeedInstalledApp = { + id: string; + name?: string; + installType: string; +}; + +/** The semantic capabilities of one live session that the platform-runtime owner composes. */ +export type DoublespeedDeviceSession = { + readonly device: DeviceInfo; + listApps(filter?: AppsFilter, signal?: AbortSignal): Promise; + readLogs(appId: string, lineLimit: number, signal?: AbortSignal): Promise; + getForegroundApp(signal?: AbortSignal): Promise<{ appId?: string }>; +}; + +export function createDoublespeedDeviceSession( + session: DoublespeedIosSession, +): DoublespeedDeviceSession { + return { + device: session.device, + listApps: async (filter, signal) => + (await session.client.listApps(signal)) + .filter((app) => resolveAppsFilter(filter) === 'all' || isUserInstalledIosApp(app)) + .map((app) => ({ + id: app.bundleId, + ...(app.name ? { name: app.name } : {}), + installType: app.installType, + })) + .sort((left, right) => left.id.localeCompare(right.id)), + readLogs: async (appId, lineLimit, signal) => + await session.client.appLogTail(appId, lineLimit, signal), + getForegroundApp: async (signal) => { + const foreground = await session.client.foregroundApp(signal); + return foreground.bundleId ? { appId: foreground.bundleId } : {}; + }, + }; +} diff --git a/packages/provider-doublespeed/src/device.test.ts b/packages/provider-doublespeed/src/device.test.ts new file mode 100644 index 0000000000..dee597873d --- /dev/null +++ b/packages/provider-doublespeed/src/device.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from 'vitest'; +import { + buildDoublespeedDevice, + isDoublespeedLeaseBackend, + isSupportedDoublespeedDevice, + parseDoublespeedDeviceId, +} from './device.ts'; +import { doublespeedIosDevice, doublespeedLease } from './runtime.fixtures.ts'; + +test('builds and parses the provider-owned device identity', () => { + const device = buildDoublespeedDevice(doublespeedLease(), { + id: 'sim-abcdef0123', + device: 'iPhone 16', + }); + expect(device).toEqual({ + platform: 'apple', + appleOs: 'ios', + id: 'doublespeed:ios:lease-a', + name: 'Doublespeed iPhone 16 sim-abcd', + kind: 'simulator', + target: 'mobile', + booted: true, + }); + expect(parseDoublespeedDeviceId(device.id)).toEqual({ leaseId: 'lease-a' }); + expect(parseDoublespeedDeviceId('limrun:ios:lease-a')).toBeUndefined(); + expect(parseDoublespeedDeviceId('doublespeed:android:lease-a')).toBeUndefined(); + expect(parseDoublespeedDeviceId('doublespeed:ios:')).toBeUndefined(); +}); + +test('supports exactly the mobile iOS simulator leaf carrying its own id', () => { + expect(isSupportedDoublespeedDevice(doublespeedIosDevice)).toBe(true); + expect(isSupportedDoublespeedDevice({ ...doublespeedIosDevice, kind: 'device' })).toBe(false); + expect( + isSupportedDoublespeedDevice({ ...doublespeedIosDevice, appleOs: 'tvos', target: 'tv' }), + ).toBe(false); + expect(isSupportedDoublespeedDevice({ ...doublespeedIosDevice, target: 'tv' })).toBe(false); + expect(isSupportedDoublespeedDevice({ ...doublespeedIosDevice, id: 'limrun:ios:lease-a' })).toBe( + false, + ); + expect( + isSupportedDoublespeedDevice({ ...doublespeedIosDevice, iosPhysicalDeviceBackend: 'xctest' }), + ).toBe(false); +}); + +test('owns the iOS instance lease backend only', () => { + expect(isDoublespeedLeaseBackend('ios-instance')).toBe(true); + expect(isDoublespeedLeaseBackend('android-instance')).toBe(false); + expect(isDoublespeedLeaseBackend('simulator')).toBe(false); +}); diff --git a/packages/provider-doublespeed/src/device.ts b/packages/provider-doublespeed/src/device.ts new file mode 100644 index 0000000000..32d8a865e7 --- /dev/null +++ b/packages/provider-doublespeed/src/device.ts @@ -0,0 +1,50 @@ +import type { DeviceLease } from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +export const DOUBLESPEED_PROVIDER = 'doublespeed'; +const DOUBLESPEED_IOS_LEASE_BACKEND = 'ios-instance'; + +export function isDoublespeedLeaseBackend(backend: string): boolean { + return backend === DOUBLESPEED_IOS_LEASE_BACKEND; +} + +export function buildDoublespeedDevice( + lease: DeviceLease, + simulator: { id: string; device: string }, +): DeviceInfo { + return { + platform: 'apple', + appleOs: 'ios', + id: doublespeedDeviceId(lease.leaseId), + name: `Doublespeed ${simulator.device} ${simulator.id.slice(0, 8)}`, + kind: 'simulator', + target: 'mobile', + booted: true, + }; +} + +export function parseDoublespeedDeviceId(value: string): { leaseId: string } | undefined { + const [prefix, platform, leaseId] = value.split(':'); + if (prefix !== DOUBLESPEED_PROVIDER || platform !== 'ios' || !leaseId) return undefined; + return { leaseId }; +} + +function doublespeedDeviceId(leaseId: string): string { + return `${DOUBLESPEED_PROVIDER}:ios:${leaseId}`; +} + +/** + * The one device identity a Doublespeed runtime owns: a mobile iOS simulator carrying an id this + * module itself would have built. Every owner, facts, and lifecycle module asks this single + * definition so device-identity support cannot drift between them. + */ +export function isSupportedDoublespeedDevice(device: DeviceInfo): boolean { + return ( + parseDoublespeedDeviceId(device.id) !== undefined && + device.platform === 'apple' && + device.appleOs === 'ios' && + device.kind === 'simulator' && + device.target === 'mobile' && + device.iosPhysicalDeviceBackend === undefined + ); +} diff --git a/packages/provider-doublespeed/src/facts-runtime.test.ts b/packages/provider-doublespeed/src/facts-runtime.test.ts new file mode 100644 index 0000000000..be43825194 --- /dev/null +++ b/packages/provider-doublespeed/src/facts-runtime.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'vitest'; +import { + deploymentOptions, + doublespeedLifecycleFacts, + doublespeedRecoveryFacts, + doublespeedRuntimeFacts, + liveSessionUnavailable, +} from './facts-runtime.ts'; +import { doublespeedIosDevice as device, doublespeedOwnerOptions } from './runtime.fixtures.ts'; + +test('live facts admit app logs, app state, snapshots and lifecycle but no port reverse', () => { + const facts = doublespeedRuntimeFacts(doublespeedOwnerOptions(), device); + expect(facts.device.providerMode).toBe('provider-runtime'); + expect(facts.operations.appLogStart).toEqual({ available: true }); + expect(facts.operations.appState).toEqual({ available: true }); + expect(facts.operations.networkDump).toEqual({ available: true }); + expect(facts.operations.captureSnapshot).toEqual({ available: true }); + expect(facts.operations.captureSnapshotWithCustomActions).toEqual({ available: true }); + expect(facts.operations.listApps).toEqual({ available: true }); + expect(facts.operations.openApplication).toEqual({ available: true }); + expect(facts.operations.configureProviderPortReverse).toMatchObject({ + available: false, + hint: 'Doublespeed iOS sessions cannot reach local host ports; use a bridge public URL.', + }); + expect(facts.operations.shutdownTarget).toMatchObject({ available: false }); +}); + +test('recovery facts close the live-only cells but keep reattach/cleanup available', () => { + const facts = doublespeedRecoveryFacts(doublespeedOwnerOptions(), device); + expect(facts.operations.appLogInspect).toEqual(liveSessionUnavailable); + expect(facts.operations.appLogStart).toEqual(liveSessionUnavailable); + expect(facts.operations.appState).toEqual(liveSessionUnavailable); + expect(facts.operations.deployApp).toEqual(liveSessionUnavailable); + expect(facts.operations.appLogReattach).toEqual({ available: true }); + expect(facts.operations.appLogCleanup).toEqual({ available: true }); +}); + +test('deploymentOptions forwards hasLiveSession as isSessionActive', () => { + const options = doublespeedOwnerOptions({ hasLiveSession: () => false }); + expect(deploymentOptions(options).isSessionActive?.(device)).toBe(false); +}); + +test('lifecycle facts refuse open/close for a device the provider does not recognize', () => { + const facts = doublespeedLifecycleFacts({ ...device, id: 'limrun:ios:lease-a' }, true); + expect(facts.resolveOpenTarget).toMatchObject({ + available: false, + hint: 'Doublespeed open requires a Doublespeed-owned iOS simulator.', + }); + expect(facts.closeApplication).toMatchObject({ + available: false, + hint: 'Doublespeed close requires a Doublespeed-owned iOS simulator.', + }); +}); + +test('lifecycle facts require a live session to open/close a recognized device', () => { + const facts = doublespeedLifecycleFacts(device, false); + expect(facts.resolveOpenTarget).toEqual(liveSessionUnavailable); + expect(facts.closeApplication).toEqual(liveSessionUnavailable); + expect(facts.configureProviderPortReverse).toEqual(liveSessionUnavailable); +}); diff --git a/packages/provider-doublespeed/src/facts-runtime.ts b/packages/provider-doublespeed/src/facts-runtime.ts new file mode 100644 index 0000000000..f721917a6f --- /dev/null +++ b/packages/provider-doublespeed/src/facts-runtime.ts @@ -0,0 +1,254 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; +import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { RuntimeFacts } from '@agent-device/contracts/platform-runtime'; +import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; +import { selectorObservationRuntimeOperationFacts } from '@agent-device/contracts/selector-observation-runtime'; +import { snapshotRuntimeOperationFacts } from '@agent-device/contracts/snapshot-runtime'; +import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; +import { audioProbeRuntimeOperationFacts } from '@agent-device/contracts/audio-probe-runtime'; +import { perfRuntimeOperationFacts } from '@agent-device/contracts/perf-runtime'; +import type { DoublespeedPlatformRuntimeOwnerOptions } from './app-log-runtime.ts'; +import { isSupportedDoublespeedDevice } from './device.ts'; +import { + doublespeedAppDeploymentFacts, + type DoublespeedAppDeploymentRuntimeOptions, +} from './deployment-runtime.ts'; +import { + doublespeedAlertOperationFacts, + doublespeedAppEventOperationFacts, + doublespeedSystemButtonOperationFacts, + doublespeedClipboardOperationFacts, + doublespeedInteractionOperationFacts, + doublespeedKeyboardOperationFacts, + doublespeedNavigationOperationFacts, + doublespeedSettingsOperationFacts, +} from './interaction-operations.ts'; +import { DOUBLESPEED_PORT_REVERSE_UNSUPPORTED } from './lifecycle.ts'; + +const available = Object.freeze({ available: true } as const); +const viewportUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed does not expose viewport resizing.', +} as const); +/** + * A point read needs a local tool (the XCUITest runner). The session transport carries none, so + * the owner reports no live read and `get` answers from the captured tree. + */ +const elementTextUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed-owned devices read element text from the captured tree only.', +} as const); +const observationUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed-owned devices poll the captured tree instead of a native selector read.', +} as const); +const recordingUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed does not expose an exact-owner screen-recording runtime.', +} as const); +const headlessUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Headless boot is unavailable for provider-owned devices.', +} as const); +/** Also read by the owner's `inspectFacts` for a device with no matching live session at all. */ +export const liveSessionUnavailable = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'Doublespeed requires a matching live provider session for this device.', +} as const); +const prepareUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Apple runner preparation is unavailable for Doublespeed-owned devices.', +} as const); +const openTargetUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed open requires a Doublespeed-owned iOS simulator.', +} as const); +const closeTargetUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed close requires a Doublespeed-owned iOS simulator.', +} as const); +const runtimeHintsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Runtime hints are not applied to provider-owned devices.', +} as const); +const portReverseUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: DOUBLESPEED_PORT_REVERSE_UNSUPPORTED, +} as const); +const audioProbeUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed does not expose the audio probe.', +} as const); +const shutdownTargetUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed owns the target lifecycle for provider-owned devices.', +} as const); + +/** Also read by the owner's `bind`: it needs the same deployment options its facts do. */ +export function deploymentOptions( + options: DoublespeedPlatformRuntimeOwnerOptions, +): DoublespeedAppDeploymentRuntimeOptions { + return { ...options, isSessionActive: options.hasLiveSession }; +} + +/** Also read by the owner's `inspectFacts` for the not-live-session fallback. */ +export function doublespeedLifecycleFacts(device: DeviceInfo, live: boolean) { + const supported = isSupportedDoublespeedDevice(device); + const openTarget = supported + ? live + ? available + : liveSessionUnavailable + : openTargetUnavailable; + const closeTarget = supported + ? live + ? available + : liveSessionUnavailable + : closeTargetUnavailable; + return applicationLifecycleOperationFacts({ + resolveOpenTarget: openTarget, + prepareApplicationOpen: openTarget, + openApplication: openTarget, + applyRuntimeHints: runtimeHintsUnavailable, + clearRuntimeHints: runtimeHintsUnavailable, + closeApplication: closeTarget, + finalizeApplicationClose: closeTarget, + prepareAppleRunner: prepareUnavailable, + configureProviderPortReverse: live ? portReverseUnavailable : liveSessionUnavailable, + }); +} + +export function doublespeedRuntimeFacts( + options: DoublespeedPlatformRuntimeOwnerOptions, + device: DeviceInfo, +): RuntimeFacts { + const deployment = doublespeedAppDeploymentFacts(deploymentOptions(options), device); + return Object.freeze({ + device: { + family: device.platform, + ...(device.appleOs === undefined ? {} : { appleOs: device.appleOs }), + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + ...(device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), + providerMode: 'provider-runtime', + }, + operations: { + appLogInspect: available, + appLogDoctor: available, + appLogStart: available, + appLogReattach: available, + appLogCleanup: available, + ...deployment, + appState: available, + networkDump: available, + screenRecordingStart: recordingUnavailable, + screenRecordingReattach: recordingUnavailable, + screenRecordingCleanup: recordingUnavailable, + ...snapshotRuntimeOperationFacts({ + capture: available, + customActions: available, + withoutActiveApp: available, + }), + ...screenshotRuntimeOperationFacts({ capture: available }), + ...selectorObservationRuntimeOperationFacts({ + findText: observationUnavailable, + }), + ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), + setFoldPose: viewportUnavailable, + ...doublespeedInteractionOperationFacts(device), + ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), + ...doublespeedNavigationOperationFacts(device), + ...doublespeedKeyboardOperationFacts(device), + ...doublespeedClipboardOperationFacts(device), + ...doublespeedSystemButtonOperationFacts(device), + ...doublespeedAppEventOperationFacts(device), + ...doublespeedSettingsOperationFacts(device), + ...doublespeedAlertOperationFacts(device), + ...audioProbeRuntimeOperationFacts({ + capture: audioProbeUnavailable, + query: audioProbeUnavailable, + }), + ...perfRuntimeOperationFacts({ + frames: elementTextUnavailable, + memorySample: elementTextUnavailable, + memorySnapshot: elementTextUnavailable, + nativeCapture: elementTextUnavailable, + profileReport: elementTextUnavailable, + }), + ensureReady: available, + bootTarget: available, + bootTargetHeadless: headlessUnavailable, + listApps: available, + shutdownTarget: shutdownTargetUnavailable, + ...doublespeedLifecycleFacts(device, true), + }, + }); +} + +export function doublespeedRecoveryFacts( + options: DoublespeedPlatformRuntimeOwnerOptions, + device: DeviceInfo, +): RuntimeFacts { + const normalFacts = doublespeedRuntimeFacts(options, device); + return Object.freeze({ + device: normalFacts.device, + operations: { + ...normalFacts.operations, + appLogInspect: liveSessionUnavailable, + appLogDoctor: liveSessionUnavailable, + appLogStart: liveSessionUnavailable, + appLogReattach: available, + appLogCleanup: available, + appState: liveSessionUnavailable, + networkDump: liveSessionUnavailable, + screenRecordingStart: liveSessionUnavailable, + screenRecordingReattach: liveSessionUnavailable, + screenRecordingCleanup: liveSessionUnavailable, + ...snapshotRuntimeOperationFacts({ + capture: liveSessionUnavailable, + customActions: liveSessionUnavailable, + withoutActiveApp: liveSessionUnavailable, + }), + ...screenshotRuntimeOperationFacts({ capture: liveSessionUnavailable }), + ...selectorObservationRuntimeOperationFacts({ + findText: liveSessionUnavailable, + }), + ...viewportRuntimeOperationFacts({ setViewport: liveSessionUnavailable }), + setFoldPose: liveSessionUnavailable, + ...doublespeedInteractionOperationFacts(device, liveSessionUnavailable), + ...doublespeedNavigationOperationFacts(device, liveSessionUnavailable), + ...doublespeedKeyboardOperationFacts(device, liveSessionUnavailable), + ...doublespeedClipboardOperationFacts(device, liveSessionUnavailable), + ...doublespeedSystemButtonOperationFacts(device, liveSessionUnavailable), + ...doublespeedAppEventOperationFacts(device, liveSessionUnavailable), + ...doublespeedSettingsOperationFacts(device, liveSessionUnavailable), + ...doublespeedAlertOperationFacts(device, liveSessionUnavailable), + ensureReady: liveSessionUnavailable, + bootTarget: liveSessionUnavailable, + bootTargetHeadless: liveSessionUnavailable, + listApps: liveSessionUnavailable, + deployApp: liveSessionUnavailable, + materializeAppSource: liveSessionUnavailable, + deployMaterializedApp: liveSessionUnavailable, + sendPushNotification: liveSessionUnavailable, + shutdownTarget: liveSessionUnavailable, + ...doublespeedLifecycleFacts(device, false), + }, + }); +} diff --git a/packages/provider-doublespeed/src/index.ts b/packages/provider-doublespeed/src/index.ts new file mode 100644 index 0000000000..f00db85a53 --- /dev/null +++ b/packages/provider-doublespeed/src/index.ts @@ -0,0 +1 @@ +export { default } from './plugin.ts'; diff --git a/packages/provider-doublespeed/src/interaction-operations.test.ts b/packages/provider-doublespeed/src/interaction-operations.test.ts new file mode 100644 index 0000000000..2fd94ae911 --- /dev/null +++ b/packages/provider-doublespeed/src/interaction-operations.test.ts @@ -0,0 +1,113 @@ +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { expect, test } from 'vitest'; +import { + doublespeedAlertOperationFacts, + doublespeedAppEventOperationFacts, + doublespeedSystemButtonOperationFacts, + doublespeedClipboardOperationFacts, + doublespeedInteractionOperationFacts, + doublespeedKeyboardOperationFacts, + doublespeedNavigationOperationFacts, + doublespeedSettingsOperationFacts, +} from './interaction-operations.ts'; +import { doublespeedIosDevice as device } from './runtime.fixtures.ts'; + +const liveSessionUnavailable = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'The Doublespeed provider session is no longer active for this device.', +} as const); + +test('the session admits tap, long press, selector taps and scroll but no gesture plans', () => { + const facts = doublespeedInteractionOperationFacts(device); + expect(facts.tapPoint).toEqual({ available: true }); + expect(facts.longPressPoint).toEqual({ available: true }); + expect(facts.tapElementSelector).toEqual({ available: true }); + expect(facts.fillPoint).toEqual({ available: true }); + expect(facts.scrollDirection).toEqual({ available: true }); + expect(facts.typeText).toEqual({ available: true }); + expect(facts.focusPoint).toEqual({ available: true }); + expect(facts.tapRef).toMatchObject({ available: false, reason: 'unsupported-provider-mode' }); + expect(facts.performGesturePlan).toMatchObject({ + available: false, + hint: 'Doublespeed iOS sessions do not expose portable gesture execution yet.', + }); +}); + +test('navigation admits home and orientation but refuses back and tv-remote', () => { + const facts = doublespeedNavigationOperationFacts(device); + expect(doublespeedSystemButtonOperationFacts(device).home).toEqual({ available: true }); + expect(facts.setOrientation).toEqual({ available: true }); + expect(facts.back).toEqual({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed iOS sessions do not expose back navigation yet.', + }); + expect(facts.tvRemote).toMatchObject({ available: false, reason: 'unsupported-provider-mode' }); +}); + +test('a dead session closes every cell with the same reason', () => { + const navigation = doublespeedNavigationOperationFacts(device, liveSessionUnavailable); + expect(doublespeedSystemButtonOperationFacts(device, liveSessionUnavailable).home).toEqual( + liveSessionUnavailable, + ); + expect(navigation.setOrientation).toEqual(liveSessionUnavailable); + expect(navigation.back).toEqual(liveSessionUnavailable); + const interaction = doublespeedInteractionOperationFacts(device, liveSessionUnavailable); + expect(interaction.tapPoint).toEqual(liveSessionUnavailable); + expect(interaction.performGesturePlan).toEqual(liveSessionUnavailable); + expect(doublespeedAppEventOperationFacts(device, liveSessionUnavailable).triggerAppEvent).toEqual( + liveSessionUnavailable, + ); +}); + +test('system leaves the session does not serve are refused with their own wording', () => { + expect(doublespeedAppEventOperationFacts(device).triggerAppEvent).toEqual({ available: true }); + for (const leg of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { + expect(doublespeedAlertOperationFacts(device)[leg]).toMatchObject({ + available: false, + hint: 'Doublespeed iOS sessions do not expose alert inspection yet.', + }); + } + expect(doublespeedClipboardOperationFacts(device).readClipboard).toMatchObject({ + available: false, + }); + expect(doublespeedSettingsOperationFacts(device).setSetting).toMatchObject({ available: false }); + expect(doublespeedSystemButtonOperationFacts(device).appSwitcher).toMatchObject({ + available: false, + }); + expect(doublespeedKeyboardOperationFacts(device).keyboardEnter).toMatchObject({ + available: false, + }); +}); + +test('binding exposes only the admitted navigation operations and drives the interactor', async () => { + const calls: string[] = []; + const interactor = { + home: async () => { + calls.push('home'); + }, + setOrientation: async (rotation: string) => { + calls.push(`setOrientation:${rotation}`); + return undefined; + }, + } as unknown as Interactor; + const operations = bindAdmittedProviderInteractorOperations({ + device, + signal: new AbortController().signal, + resolveInteractor: () => interactor, + facts: { + ...doublespeedNavigationOperationFacts(device), + ...doublespeedSystemButtonOperationFacts(device), + }, + }); + + expect(operations.home).toBeTypeOf('function'); + expect(operations.setOrientation).toBeTypeOf('function'); + expect(operations.back).toBeUndefined(); + expect(operations.tvRemote).toBeUndefined(); + await operations.home?.({}); + await operations.setOrientation?.({ rotation: 'landscape-left' }); + expect(calls).toEqual(['home', 'setOrientation:landscape-left']); +}); diff --git a/packages/provider-doublespeed/src/interaction-operations.ts b/packages/provider-doublespeed/src/interaction-operations.ts new file mode 100644 index 0000000000..8a51742028 --- /dev/null +++ b/packages/provider-doublespeed/src/interaction-operations.ts @@ -0,0 +1,254 @@ +import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; +import { + bindProviderFocusInteractor, + focusRuntimeOperationFacts, +} from '@agent-device/contracts/focus-runtime'; +import { + bindProviderGestureInteractor, + gestureRuntimeOperationFacts, + type GestureRuntimeOperationFacts, +} from '@agent-device/contracts/gesture-runtime'; +import { + bindProviderScrollInteractor, + scrollRuntimeOperationFacts, +} from '@agent-device/contracts/scroll-runtime'; +import { systemButtonRuntimeOperationFacts } from '@agent-device/contracts/system-button-runtime'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; +import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; +import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; +import { bindProviderScreenshotInteractor } from '@agent-device/contracts/screenshot-runtime'; +import { bindProviderSnapshotInteractor } from '@agent-device/contracts/snapshot-runtime'; +import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { + bindProviderTypeTextInteractor, + typeTextRuntimeOperationFacts, +} from '@agent-device/contracts/type-text-runtime'; +import { + bindProviderTouchInteractor, + touchRuntimeOperationFacts, +} from '@agent-device/contracts/touch-runtime'; +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import type { RuntimeOperationUnavailability } from '@agent-device/contracts/platform-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { + DOUBLESPEED_IOS_ALERT_UNSUPPORTED, + DOUBLESPEED_IOS_BACK_UNSUPPORTED, + DOUBLESPEED_IOS_GESTURE_UNSUPPORTED, +} from './ios.ts'; + +const available = Object.freeze({ available: true } as const); +const unsupportedTouch = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', +} as const); +const hoverUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'hover raises pointer hover state and is available on web targets only. On touch platforms use longpress for hold gestures.', +} as const); +/** + * The session drives text and touch but exposes no portable gesture execution — its interactor's + * own `performGesture` refuses with this wording. Stating it as a fact refuses at admission + * instead of mid-execution (ADR 0019 §6), keeping the agent-facing hint identical. + */ +const gestureUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: DOUBLESPEED_IOS_GESTURE_UNSUPPORTED, +} as const); +const backUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: DOUBLESPEED_IOS_BACK_UNSUPPORTED, +} as const); +const tvRemoteUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed iOS sessions do not expose tv remote control.', +} as const); +const keyboardUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed iOS sessions do not expose keyboard actions.', +} as const); +const clipboardUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed iOS sessions do not expose clipboard access yet.', +} as const); +const settingsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed iOS sessions do not expose settings changes yet.', +} as const); +const appSwitcherUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Doublespeed iOS sessions do not expose app switcher yet.', +} as const); +const alertUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: DOUBLESPEED_IOS_ALERT_UNSUPPORTED, +} as const); + +function doublespeedGestureFacts( + cell: RuntimeOperationUnavailability | typeof available, +): GestureRuntimeOperationFacts { + const gesture = cell === available ? gestureUnavailable : cell; + return gestureRuntimeOperationFacts({ + unsupported: gestureUnavailable, + plan: gesture, + directionalFling: gesture, + multiTouch: gesture, + targetAuthoredDrag: gesture, + viewport: gesture, + }); +} + +/** + * The interactor-backed interaction cells a live session serves: everything here rides one + * provider interactor, and a live session always has one, so the cells are available together. + */ +export function doublespeedInteractionOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = liveSessionUnavailable ?? available; + return Object.freeze({ + ...focusRuntimeOperationFacts({ focus: cell }), + ...typeTextRuntimeOperationFacts({ type: cell }), + ...touchRuntimeOperationFacts({ + unsupported: unsupportedTouch, + tap: cell, + tapRef: unsupportedTouch, + longPress: cell, + hover: liveSessionUnavailable ?? hoverUnavailable, + hoverRef: unsupportedTouch, + fill: cell, + fillRef: unsupportedTouch, + tapElementSelector: cell, + }), + ...doublespeedGestureFacts(cell), + ...scrollRuntimeOperationFacts({ scroll: cell }), + }); +} + +/** Binds the interactor-backed operations (snapshot, screenshot, focus, type, touch, scroll). */ +export function bindDoublespeedInteractionOperations( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; + }>, +) { + const { device, signal } = params; + const resolveInteractor = (runner: RunnerContext) => params.getInteractor(device, runner); + return Object.freeze({ + ...bindProviderSnapshotInteractor({ device, signal, resolveInteractor }), + ...bindProviderFocusInteractor({ device, signal, resolveInteractor }), + ...bindProviderTypeTextInteractor({ device, signal, resolveInteractor }), + ...bindProviderTouchInteractor({ + device, + signal, + resolveInteractor, + facts: doublespeedInteractionOperationFacts(device), + pause: async (milliseconds) => await sleep(milliseconds, undefined, { signal }), + }), + ...bindProviderScreenshotInteractor({ device, signal, resolveInteractor }), + ...bindProviderGestureInteractor({ + device, + signal, + facts: doublespeedGestureFacts(available), + resolveInteractor, + }), + ...bindProviderScrollInteractor({ device, signal, resolveInteractor }), + }); +} + +/** `home` and `orientation` ride the session; `back` and `tvRemote` have no iOS-simulator leg. */ +export function doublespeedNavigationOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + return Object.freeze({ + ...backRuntimeOperationFacts({ back: liveSessionUnavailable ?? backUnavailable }), + + ...orientationRuntimeOperationFacts({ orientation: liveSessionUnavailable ?? available }), + ...tvRemoteRuntimeOperationFacts({ tvRemote: liveSessionUnavailable ?? tvRemoteUnavailable }), + }); +} + +export function doublespeedKeyboardOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = liveSessionUnavailable ?? keyboardUnavailable; + return Object.freeze({ + ...keyboardRuntimeOperationFacts({ + unsupported: cell, + status: cell, + dismiss: cell, + enter: cell, + }), + }); +} + +export function doublespeedClipboardOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = liveSessionUnavailable ?? clipboardUnavailable; + return Object.freeze({ + ...clipboardRuntimeOperationFacts({ unsupported: cell, read: cell, write: cell }), + }); +} + +export function doublespeedAlertOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = liveSessionUnavailable ?? alertUnavailable; + return Object.freeze({ + ...alertRuntimeOperationFacts({ read: cell, wait: cell, accept: cell, dismiss: cell }), + }); +} + +export function doublespeedSystemButtonOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + return Object.freeze({ + ...systemButtonRuntimeOperationFacts({ + unsupported: unsupportedTouch, + home: liveSessionUnavailable ?? available, + appSwitcher: liveSessionUnavailable ?? appSwitcherUnavailable, + }), + }); +} + +/** A deep link is exactly what the interactor's `open` routes, so app-event delivery is served. */ +export function doublespeedAppEventOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + return Object.freeze({ + ...appEventRuntimeOperationFacts({ triggerAppEvent: liveSessionUnavailable ?? available }), + }); +} + +export function doublespeedSettingsOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + return Object.freeze({ + ...settingsRuntimeOperationFacts({ + setSetting: liveSessionUnavailable ?? settingsUnavailable, + readSetting: liveSessionUnavailable ?? settingsUnavailable, + }), + }); +} diff --git a/packages/provider-doublespeed/src/ios.test.ts b/packages/provider-doublespeed/src/ios.test.ts new file mode 100644 index 0000000000..21de1f76ed --- /dev/null +++ b/packages/provider-doublespeed/src/ios.test.ts @@ -0,0 +1,189 @@ +import fs from 'node:fs'; +import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import type { DoublespeedApiClient } from './api-client.ts'; +import { + createDoublespeedIosInteractor, + installDoublespeedIosApp, + installDoublespeedIosRemoteApp, + isUserInstalledIosApp, + type DoublespeedIosSession, +} from './ios.ts'; +import { + doublespeedIosDevice, + doublespeedLease, + doublespeedTestDependencies, +} from './runtime.fixtures.ts'; + +const IOS_APPS = [ + { bundleId: 'com.apple.Preferences', name: 'Settings', installType: 'System' }, + { bundleId: 'com.facebook.WebDriverAgentRunner.xctrunner', installType: 'User' }, + { bundleId: 'com.example.ios', name: 'Example', installType: 'User' }, +]; + +function iosSession(client: Record): DoublespeedIosSession { + return { + lease: doublespeedLease(), + simulatorId: 'sim-a', + device: doublespeedIosDevice, + client, + screen: { width: 393, height: 852, scale: 3 }, + dependencies: doublespeedTestDependencies, + } as unknown as DoublespeedIosSession; +} + +test('snapshot stamps the xctest channel with its own producer', async () => { + const session = iosSession({ + elementTree: async () => [ + { + type: 'Application', + label: 'Example', + frame: { x: 0, y: 0, width: 393, height: 852 }, + children: [{ type: 'Button', label: 'Continue', enabled: true, visible: true }], + }, + ], + }); + + const result = await createDoublespeedIosInteractor(session).snapshot(); + + if ('stage' in result) throw new Error('Doublespeed returns a decoded tree'); + expect(result.backend).toBe('xctest'); + expect(result.producer).toBe('doublespeed-ios-tree'); + expect(result.nodes?.map((node) => [node.label, node.depth, node.parentIndex])).toEqual([ + ['Example', 0, undefined], + ['Continue', 1, 0], + ]); + expect(result.nodes?.[0]?.rect).toEqual({ x: 0, y: 0, width: 393, height: 852 }); +}); + +test('routes open, deep links, home and orientation through the session', async () => { + const launchApp = vi.fn(async () => undefined); + const openUrl = vi.fn(async () => undefined); + const pressKey = vi.fn(async () => undefined); + const setOrientation = vi.fn(async () => undefined); + const tapElement = vi.fn(async () => undefined); + const interactor = createDoublespeedIosInteractor( + iosSession({ launchApp, openUrl, pressKey, setOrientation, tapElement }), + ); + + await interactor.open('com.example.ios'); + await interactor.open('example://deep/link'); + await interactor.open('com.example.ios', { url: 'https://example.test/path' }); + await interactor.home?.(); + await interactor.setOrientation('landscape-left'); + await interactor.tapElementSelector?.({ key: 'text', value: 'Continue' }); + + expect(launchApp.mock.calls).toEqual([['com.example.ios'], ['com.example.ios']]); + expect(openUrl.mock.calls).toEqual([['example://deep/link'], ['https://example.test/path']]); + expect(pressKey).toHaveBeenCalledWith('home'); + expect(setOrientation).toHaveBeenCalledWith('landscape'); + expect(tapElement).toHaveBeenCalledWith({ label: 'Continue' }); + await expect(interactor.setOrientation('portrait-upside-down')).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + }); + await expect(interactor.back()).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' }); +}); + +test('remote install waits for eventually consistent app inventory', async () => { + const staleApps = IOS_APPS.slice(0, 2); + const listApps = vi + .fn(async () => IOS_APPS) + .mockResolvedValueOnce(staleApps) + .mockResolvedValueOnce(staleApps); + const installApp = vi.fn(async () => ({ bundleId: 'com.example.ios' })); + const session = iosSession({ listApps, installApp }); + + await expect( + installDoublespeedIosRemoteApp(session, 'https://blob.example/example.zip', { + sha256: 'abc', + relaunch: true, + appIdentifierHint: 'com.example.ios', + }), + ).resolves.toEqual({ appId: 'com.example.ios' }); + expect(listApps).toHaveBeenCalledTimes(3); + expect(installApp.mock.calls[0]).toEqual([ + { url: 'https://blob.example/example.zip', sha256: 'abc', launchMode: 'RelaunchIfRunning' }, + undefined, + ]); +}); + +test('remote install infers the single new user app when the session reports no bundle id', async () => { + const listApps = vi.fn(async () => IOS_APPS).mockResolvedValueOnce(IOS_APPS.slice(0, 2)); + const session = iosSession({ listApps, installApp: async () => ({}) }); + await expect( + installDoublespeedIosRemoteApp(session, 'https://blob.example/x.zip'), + ).resolves.toEqual({ + appId: 'com.example.ios', + }); +}); + +test('packages a .app directory, publishes it once, and installs through the signed download', async () => { + const tempDir = mkdtempForTestSync('doublespeed-ios-app-'); + const appPath = path.join(tempDir, 'Example.app'); + fs.mkdirSync(appPath); + const archiveDirectory = vi.fn(async ({ archivePath }: { archivePath: string }) => { + fs.writeFileSync(archivePath, 'zip-bytes'); + }); + const registerAsset = vi.fn(async (_input: { sha256: string; size: number; name: string }) => ({ + sha256: 'sha', + exists: false, + upload_url: 'https://blob.example/upload', + download_url: null, + })); + const uploadAsset = vi.fn(async () => undefined); + const completeAsset = vi.fn(async () => ({ + sha256: 'sha', + exists: true, + upload_url: null, + download_url: 'https://blob.example/get', + })); + const api = { registerAsset, uploadAsset, completeAsset } as unknown as DoublespeedApiClient; + const installApp = vi.fn(async (_input: unknown) => ({ bundleId: 'com.example.ios' })); + const session = { + ...iosSession({ installApp, listApps: async () => IOS_APPS }), + dependencies: { + host: { archiveDirectory }, + ios: { + resolveAppAlias: async (app: string) => app, + readBundleAppName: async () => 'Example', + }, + }, + }; + + const result = await installDoublespeedIosApp(api, session, appPath, { + appIdentifierHint: 'com.example.ios', + }); + + expect(result).toEqual({ + bundleId: 'com.example.ios', + launchTarget: 'com.example.ios', + appName: 'Example', + }); + expect(archiveDirectory).toHaveBeenCalledWith( + expect.objectContaining({ sourceDirectory: tempDir, entryName: 'Example.app' }), + ); + const registration = registerAsset.mock.calls[0]![0]; + expect(registration).toMatchObject({ size: 9, name: 'Example.app.zip' }); + const sha = registration.sha256; + expect(sha).toMatch(/^[a-f0-9]{64}$/); + expect(uploadAsset).toHaveBeenCalledWith( + 'https://blob.example/upload', + expect.stringMatching(/Example\.app\.zip$/), + undefined, + ); + expect(completeAsset).toHaveBeenCalledWith(sha, 9, undefined); + expect(installApp.mock.calls[0]?.[0]).toEqual({ + url: 'https://blob.example/get', + sha256: sha, + launchMode: 'ForegroundIfRunning', + }); + expect(fs.existsSync(path.dirname(archiveDirectory.mock.calls[0]![0].archivePath))).toBe(false); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test('user-installed inventory hides Apple and the WebDriverAgent runner', () => { + expect(IOS_APPS.filter(isUserInstalledIosApp).map((app) => app.bundleId)).toEqual([ + 'com.example.ios', + ]); +}); diff --git a/packages/provider-doublespeed/src/ios.ts b/packages/provider-doublespeed/src/ios.ts new file mode 100644 index 0000000000..343a4f793e --- /dev/null +++ b/packages/provider-doublespeed/src/ios.ts @@ -0,0 +1,434 @@ +import { isDeepLinkTarget } from '@agent-device/contracts/command'; +import type { + DeviceLease, + DeviceRotation, + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, +} from '@agent-device/contracts/device'; +import type { + Interactor, + SnapshotOptions, + SnapshotResult, +} from '@agent-device/contracts/interactor-types'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import type { DoublespeedApiClient } from './api-client.ts'; +import type { DoublespeedRuntimeDependencies } from './runtime-dependencies.ts'; +import { + createDoublespeedSessionClient, + type DoublespeedInstalledApp, + type DoublespeedSessionClient, + type DoublespeedSessionScreen, +} from './session-client.ts'; +import { flattenDoublespeedTree, toDoublespeedSelector, writeBase64File } from './snapshot.ts'; +import { normalizeOptionalString } from './strings.ts'; + +export type DoublespeedIosSession = { + lease: DeviceLease; + simulatorId: string; + device: DeviceInfo; + client: DoublespeedSessionClient; + screen: DoublespeedSessionScreen; + readonly dependencies: Pick; +}; + +export type DoublespeedIosRemoteInstallOptions = { + sha256?: string; + relaunch?: boolean; + appIdentifierHint?: string; +}; + +export type DoublespeedIosRemoteInstallResult = { + appId?: string; +}; + +export function createDoublespeedIosSession( + options: { + lease: DeviceLease; + simulatorId: string; + device: DeviceInfo; + apiUrl: string; + screen: DoublespeedSessionScreen; + fetch?: typeof fetch; + }, + dependencies: Pick, +): DoublespeedIosSession { + return { + lease: options.lease, + simulatorId: options.simulatorId, + device: options.device, + client: createDoublespeedSessionClient(options.apiUrl, { fetch: options.fetch }), + screen: options.screen, + dependencies, + }; +} + +export async function installDoublespeedIosApp( + api: DoublespeedApiClient, + session: DoublespeedIosSession, + installablePath: string, + options?: ProviderDeviceInstallOptions, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const prepared = await prepareIosAsset(installablePath, session.dependencies); + try { + signal?.throwIfAborted(); + const digest = await fileDigest(prepared.uploadPath); + const downloadUrl = await publishAsset(api, prepared, digest, signal); + const result = await installDoublespeedIosRemoteApp( + session, + downloadUrl, + { + sha256: digest.sha256, + relaunch: options?.relaunch, + appIdentifierHint: options?.appIdentifierHint, + }, + signal, + ); + const bundleId = result.appId; + return { + ...(bundleId ? { bundleId, launchTarget: bundleId } : {}), + ...(prepared.appName ? { appName: prepared.appName } : {}), + }; + } finally { + await prepared.cleanup(); + } +} + +async function publishAsset( + api: DoublespeedApiClient, + prepared: { uploadPath: string; assetName: string }, + digest: { sha256: string; size: number }, + signal?: AbortSignal, +): Promise { + let asset = await api.registerAsset({ ...digest, name: prepared.assetName }, signal); + if (!asset.exists) { + if (!asset.upload_url) { + throw new AppError( + 'COMMAND_FAILED', + 'Doublespeed asset registration returned no upload URL.', + ); + } + await api.uploadAsset(asset.upload_url, prepared.uploadPath, signal); + asset = await api.completeAsset(digest.sha256, digest.size, signal); + } + if (!asset.download_url) { + throw new AppError( + 'COMMAND_FAILED', + 'Doublespeed asset registration returned no download URL.', + ); + } + return asset.download_url; +} + +export async function installDoublespeedIosRemoteApp( + session: DoublespeedIosSession, + url: string, + options?: DoublespeedIosRemoteInstallOptions, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const beforeInstallApps = await session.client.listApps(signal).catch((error: unknown) => { + if (signal?.aborted) throw error; + return undefined; + }); + const result = await session.client.installApp( + { + url, + sha256: options?.sha256, + launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'ForegroundIfRunning', + }, + signal, + ); + const resultBundleId = normalizeOptionalString(result.bundleId); + const requestedBundleId = normalizeOptionalString(options?.appIdentifierHint); + let afterInstallApps: DoublespeedInstalledApp[] = []; + for (const delayMs of IOS_APP_INVENTORY_RETRY_DELAYS_MS) { + if (delayMs > 0) await sleep(delayMs, undefined, { signal }); + afterInstallApps = await session.client.listApps(signal); + const verifiedBundleId = resolveInstalledIosAppId({ + resultBundleId, + requestedBundleId, + beforeInstallApps, + afterInstallApps, + }); + if (verifiedBundleId) return { appId: verifiedBundleId }; + } + throw new AppError('COMMAND_FAILED', 'Doublespeed iOS app installation could not be verified.', { + resultBundleId, + requestedBundleId, + installedUserApps: afterInstallApps + .filter(isUserInstalledIosApp) + .map((app) => app.bundleId) + .sort(), + }); +} + +export function createDoublespeedIosInteractor(session: DoublespeedIosSession): Interactor { + return new DoublespeedIosInteractor(session); +} + +class DoublespeedIosInteractor implements Interactor { + private readonly session: DoublespeedIosSession; + + constructor(session: DoublespeedIosSession) { + this.session = session; + } + + async open(app: string, options?: { url?: string }): Promise { + if (options?.url) { + await this.session.client.launchApp(await this.session.dependencies.ios.resolveAppAlias(app)); + await this.session.client.openUrl(options.url); + return; + } + if (isDeepLinkTarget(app)) { + await this.session.client.openUrl(app); + return; + } + await this.session.client.launchApp(await this.session.dependencies.ios.resolveAppAlias(app)); + } + + async openDevice(): Promise {} + + async close(app: string): Promise { + if (app) { + await this.session.client + .terminateApp(await this.session.dependencies.ios.resolveAppAlias(app)) + .catch(() => {}); + } + } + + async tap(x: number, y: number): Promise { + await this.session.client.tap(x, y); + } + + async tapElementSelector(selector: { + key: 'id' | 'label' | 'text' | 'value'; + value: string; + }): Promise | void> { + await this.session.client.tapElement(toDoublespeedSelector(selector)); + } + + async doubleTap(x: number, y: number): Promise { + await this.tap(x, y); + await this.tap(x, y); + } + + async longPress(x: number, y: number, durationMs?: number): Promise { + await this.session.client.longPress(x, y, durationMs); + } + + async focus(x: number, y: number): Promise { + await this.tap(x, y); + } + + async type(text: string, delayMs?: number): Promise { + if (delayMs && delayMs > 0) { + for (const char of Array.from(text)) { + await this.session.client.typeText(char); + await sleep(delayMs); + } + return; + } + await this.session.client.typeText(text); + } + + async fill(x: number, y: number, text: string): Promise { + await this.tap(x, y); + await this.session.client.typeText(text); + } + + async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { + await this.session.client.scroll(direction, options?.pixels ?? 300); + } + + async screenshot(outPath: string): Promise { + const screenshot = await this.session.client.screenshot(); + await writeBase64File(outPath, screenshot.base64); + } + + async snapshot(_options?: SnapshotOptions): Promise { + const tree = await this.session.client.elementTree(); + return { + nodes: flattenDoublespeedTree(tree), + backend: 'xctest', + producer: 'doublespeed-ios-tree', + }; + } + + async back(): Promise { + throw unsupported('back', DOUBLESPEED_IOS_BACK_UNSUPPORTED); + } + + async home(): Promise { + await this.session.client.pressKey('home'); + } + + async setOrientation(orientation: DeviceRotation): Promise { + if (orientation === 'portrait-upside-down') { + throw unsupported( + 'orientation', + 'Doublespeed iOS sessions support portrait and landscape orientation, not portrait upside-down.', + ); + } + await this.session.client.setOrientation(orientation === 'portrait' ? 'portrait' : 'landscape'); + } + + async performGesture(): Promise { + throw unsupported('gesture', DOUBLESPEED_IOS_GESTURE_UNSUPPORTED); + } + + async appSwitcher(): Promise { + throw unsupported('app-switcher', 'Doublespeed iOS sessions do not expose app switcher yet.'); + } + + async tvRemote(): Promise { + throw unsupported('tv-remote', 'Doublespeed iOS sessions do not expose tv remote control.'); + } + + async readAlert(): Promise { + throw unsupported('alert', DOUBLESPEED_IOS_ALERT_UNSUPPORTED); + } + + async awaitAlert(): Promise { + throw unsupported('alert', DOUBLESPEED_IOS_ALERT_UNSUPPORTED); + } + + async acceptAlert(): Promise { + throw unsupported('alert', DOUBLESPEED_IOS_ALERT_UNSUPPORTED); + } + + async dismissAlert(): Promise { + throw unsupported('alert', DOUBLESPEED_IOS_ALERT_UNSUPPORTED); + } + + async readClipboard(): Promise { + throw unsupported('clipboard', 'Doublespeed iOS sessions do not expose clipboard read yet.'); + } + + async writeClipboard(): Promise { + throw unsupported('clipboard', 'Doublespeed iOS sessions do not expose clipboard write yet.'); + } + + async setSetting(): Promise { + throw unsupported('settings', 'Doublespeed iOS sessions do not expose settings changes yet.'); + } +} + +async function prepareIosAsset( + artifactPath: string, + dependencies: Pick, +): Promise<{ + uploadPath: string; + assetName: string; + appName?: string; + cleanup: () => Promise; +}> { + const stat = await fs.promises.stat(artifactPath); + if (!stat.isDirectory()) { + return { + uploadPath: artifactPath, + assetName: path.basename(artifactPath), + appName: inferAppNameFromPath(artifactPath), + cleanup: async () => {}, + }; + } + + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'agent-device-doublespeed-ios-app-'), + ); + const zipPath = path.join(tempDir, `${path.basename(artifactPath)}.zip`); + try { + await dependencies.host.archiveDirectory({ + sourceDirectory: path.dirname(artifactPath), + entryName: path.basename(artifactPath), + archivePath: zipPath, + }); + } catch (error) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + throw error; + } + return { + uploadPath: zipPath, + assetName: path.basename(zipPath), + appName: + (await dependencies.ios.readBundleAppName(artifactPath)) ?? + inferAppNameFromPath(artifactPath), + cleanup: async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +async function fileDigest(filePath: string): Promise<{ sha256: string; size: number }> { + const hash = createHash('sha256'); + let size = 0; + for await (const chunk of fs.createReadStream(filePath)) { + hash.update(chunk as Buffer); + size += (chunk as Buffer).length; + } + return { sha256: hash.digest('hex'), size }; +} + +function inferAppNameFromPath(appPath: string): string | undefined { + const base = path.basename(appPath).replace(/\.(?:app|ipa|zip)$/i, ''); + return base || undefined; +} + +const IOS_APP_INVENTORY_RETRY_DELAYS_MS = [0, 250] as const; + +function resolveInstalledIosAppId(params: { + resultBundleId?: string; + requestedBundleId?: string; + beforeInstallApps: DoublespeedInstalledApp[] | undefined; + afterInstallApps: DoublespeedInstalledApp[]; +}): string | undefined { + const installedBundleIds = new Set(params.afterInstallApps.map((app) => app.bundleId)); + return ( + (params.resultBundleId && installedBundleIds.has(params.resultBundleId) + ? params.resultBundleId + : undefined) ?? + (params.requestedBundleId && installedBundleIds.has(params.requestedBundleId) + ? params.requestedBundleId + : undefined) ?? + inferNewUserInstalledApp(params.beforeInstallApps, params.afterInstallApps) + ); +} + +function inferNewUserInstalledApp( + beforeInstallApps: DoublespeedInstalledApp[] | undefined, + afterInstallApps: DoublespeedInstalledApp[], +): string | undefined { + if (!beforeInstallApps) return undefined; + const beforeBundleIds = new Set(beforeInstallApps.map((app) => app.bundleId)); + const candidates = afterInstallApps.filter( + (app) => isUserInstalledIosApp(app) && !beforeBundleIds.has(app.bundleId), + ); + return candidates.length === 1 ? candidates[0]?.bundleId : undefined; +} + +export function isUserInstalledIosApp(app: DoublespeedInstalledApp): boolean { + return ( + !app.bundleId.startsWith('com.apple.') && + !app.bundleId.startsWith('com.facebook.WebDriverAgentRunner') && + !app.installType.toLowerCase().includes('system') + ); +} + +export const DOUBLESPEED_IOS_BACK_UNSUPPORTED = + 'Doublespeed iOS sessions do not expose back navigation yet.'; +export const DOUBLESPEED_IOS_GESTURE_UNSUPPORTED = + 'Doublespeed iOS sessions do not expose portable gesture execution yet.'; +/** One sentence for all four alert legs: the session API exposes no alert inspection. */ +export const DOUBLESPEED_IOS_ALERT_UNSUPPORTED = + 'Doublespeed iOS sessions do not expose alert inspection yet.'; + +function unsupported(command: string, message: string): never { + throw new AppError('UNSUPPORTED_OPERATION', message, { command }); +} diff --git a/packages/provider-doublespeed/src/lifecycle.test.ts b/packages/provider-doublespeed/src/lifecycle.test.ts new file mode 100644 index 0000000000..291ca1273d --- /dev/null +++ b/packages/provider-doublespeed/src/lifecycle.test.ts @@ -0,0 +1,217 @@ +import type { DeviceBinding, RuntimeFacts } from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { expect, test, vi } from 'vitest'; +import { createDoublespeedPlatformRuntimeOwner } from './app-log-runtime.ts'; +import { + doublespeedIosDevice as device, + doublespeedOwnerOptions, + doublespeedScope as scope, + unusedDoublespeedHost as unusedHost, +} from './runtime.fixtures.ts'; + +type LifecycleCell = Readonly<{ + openTarget: boolean; + prepareAppleRunner: boolean; + closeTarget: boolean; + runtimeHints: boolean; + portReverse: boolean; +}>; + +const SUPPORTED_CELL: LifecycleCell = { + openTarget: true, + prepareAppleRunner: false, + closeTarget: true, + runtimeHints: false, + portReverse: false, +}; +const UNSUPPORTED_CELL: LifecycleCell = { + openTarget: false, + prepareAppleRunner: false, + closeTarget: false, + runtimeHints: false, + portReverse: false, +}; +const DEVICE_KINDS = ['simulator', 'emulator', 'device'] as const; +const APPLE_LEAVES = [ + { appleOs: 'ios', target: 'mobile' }, + { appleOs: 'ipados', target: 'mobile' }, + { appleOs: 'tvos', target: 'tv' }, + { appleOs: 'macos', target: 'desktop' }, + { appleOs: 'visionos', target: 'mobile' }, + { appleOs: 'watchos', target: 'mobile' }, +] as const; +const OTHER_FAMILIES = [ + { platform: 'android', target: 'mobile' }, + { platform: 'harmonyos', target: 'mobile' }, + { platform: 'vega', target: 'tv' }, + { platform: 'linux', target: 'desktop' }, + { platform: 'web', target: 'desktop' }, +] as const; + +// The provider has exactly one lifecycle dispatch cell: iOS-simulator/mobile. Every other +// canonical leaf/kind shape is fact-only and fails closed before a binding is constructed. +const LIFECYCLE_DENOMINATOR = [ + ...APPLE_LEAVES.flatMap(({ appleOs, target }) => + DEVICE_KINDS.map((kind) => ({ + name: `${appleOs} ${kind} ${target}`, + device: { + platform: 'apple' as const, + appleOs, + id: `doublespeed:ios:${appleOs}-${kind}`, + name: `Doublespeed ${appleOs} ${kind}`, + kind, + target, + booted: true, + }, + cell: + appleOs === 'ios' && kind === 'simulator' && target === 'mobile' + ? SUPPORTED_CELL + : UNSUPPORTED_CELL, + })), + ), + ...OTHER_FAMILIES.flatMap(({ platform, target }) => + DEVICE_KINDS.map((kind) => ({ + name: `${platform} ${kind} ${target}`, + device: { + platform, + id: `doublespeed:ios:${platform}-${kind}`, + name: `Doublespeed ${platform} ${kind}`, + kind, + target, + booted: true, + }, + cell: UNSUPPORTED_CELL, + })), + ), + { + name: 'iOS simulator TV target', + device: { ...device, id: 'doublespeed:ios:tv', target: 'tv' as const }, + cell: UNSUPPORTED_CELL, + }, +] satisfies ReadonlyArray>; + +test.each(LIFECYCLE_DENOMINATOR)( + 'classifies the $name provider descriptor/dispatch cell', + async ({ device: runtimeDevice, cell }) => { + const owner = createDoublespeedPlatformRuntimeOwner( + doublespeedOwnerOptions({ getInteractor: () => ({}) as Interactor }), + ); + const facts = await owner.inspectFacts(runtimeDevice); + expectLifecycleFactAvailability(facts, cell); + if (!cell.openTarget) { + await expect( + owner.bind({ device: runtimeDevice, intent: { kind: 'ordinary' }, scope }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_PLATFORM' }); + return; + } + const binding = await owner.bind({ + device: runtimeDevice, + intent: { kind: 'ordinary' }, + scope, + }); + expect(binding.facts.device.providerMode).toBe('provider-runtime'); + expect(binding.facts.operations.networkDump).toEqual({ available: true }); + expect(binding.facts.operations.ensureReady).toEqual({ available: true }); + expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); + expectLifecycleFacts(binding, cell); + }, +); + +test('a stale session publishes unavailable lifecycle facts and admits recovery only', async () => { + const owner = createDoublespeedPlatformRuntimeOwner( + doublespeedOwnerOptions({ hasLiveSession: () => false }), + ); + const facts = await owner.inspectFacts(device); + for (const operation of [ + 'resolveOpenTarget', + 'openApplication', + 'closeApplication', + 'configureProviderPortReverse', + ] as const) { + expect(facts.operations[operation]).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + }); + } + await expect(owner.bind({ device, intent: { kind: 'ordinary' }, scope })).rejects.toThrow( + /no longer live/, + ); + const recovery = await owner.bind({ + device, + intent: { kind: 'exact-owner', owner: owner.owner, fence: { token: 'fence', generation: 1 } }, + scope, + }); + expect(Object.keys(recovery.operations).sort()).toEqual(['appLogCleanup', 'appLogReattach']); +}); + +test('a live lifecycle binding relaunches with its provider interactor only', async () => { + const localInteractor = vi.fn(async () => { + throw new Error('local interactor must not be reached for a provider-owned lifecycle'); + }); + const providerClose = vi.fn(async () => undefined); + const providerOpen = vi.fn(async () => undefined); + const baseHost = unusedHost(); + const owner = createDoublespeedPlatformRuntimeOwner( + doublespeedOwnerOptions({ + host: { ...baseHost, localInteractors: { resolve: localInteractor } }, + getInteractor: () => ({ close: providerClose, open: providerOpen }) as unknown as Interactor, + }), + ); + const binding = await owner.bind({ + device, + intent: { kind: 'exact-owner', owner: owner.owner, fence: { token: 'fence', generation: 1 } }, + scope, + }); + await binding.operations.openApplication?.({ + target: 'com.example.app', + positionals: ['com.example.app'], + appBundleId: 'com.example.app', + surface: 'app', + hasExistingSession: true, + relaunch: true, + prewarmRunnerBeforeOpen: false, + enableTestIme: false, + stateDir: '/state', + runtimeHints: {}, + execution: {}, + }); + expect(providerClose).toHaveBeenCalledWith('com.example.app'); + expect(providerOpen).toHaveBeenCalledWith( + 'com.example.app', + expect.objectContaining({ appBundleId: 'com.example.app' }), + ); + expect(localInteractor).not.toHaveBeenCalled(); + expect(binding.operations.configureProviderPortReverse).toBeUndefined(); +}); + +const LIFECYCLE_OPERATIONS = [ + ['openTarget', ['resolveOpenTarget', 'prepareApplicationOpen', 'openApplication']], + ['prepareAppleRunner', ['prepareAppleRunner']], + ['closeTarget', ['closeApplication', 'finalizeApplicationClose']], + ['runtimeHints', ['applyRuntimeHints', 'clearRuntimeHints']], + ['portReverse', ['configureProviderPortReverse']], +] as const; + +function expectLifecycleFacts( + binding: DeviceBinding, + cell: LifecycleCell, +): void { + expectLifecycleFactAvailability(binding.facts, cell); + for (const [facet, names] of LIFECYCLE_OPERATIONS) { + for (const name of names) { + if (cell[facet]) expect(binding.operations[name]).toBeTypeOf('function'); + else expect(binding.operations[name]).toBeUndefined(); + } + } +} + +function expectLifecycleFactAvailability( + facts: RuntimeFacts, + cell: LifecycleCell, +): void { + for (const [facet, names] of LIFECYCLE_OPERATIONS) { + for (const name of names) expect(facts.operations[name].available).toBe(cell[facet]); + } +} diff --git a/packages/provider-doublespeed/src/lifecycle.ts b/packages/provider-doublespeed/src/lifecycle.ts new file mode 100644 index 0000000000..1dc4ad7110 --- /dev/null +++ b/packages/provider-doublespeed/src/lifecycle.ts @@ -0,0 +1,38 @@ +import type { ApplicationLifecycleRuntimeOperations } from '@agent-device/contracts/application-lifecycle-runtime'; +import { + bindDirectApplicationLifecycle, + bindProviderApplicationLifecycleInteractor, +} from '@agent-device/contracts/application-lifecycle-interaction'; +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +type DoublespeedLifecycleParams = Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; +}>; + +export const DOUBLESPEED_PORT_REVERSE_UNSUPPORTED = + 'Doublespeed iOS sessions cannot reach local host ports; use a bridge public URL.'; + +/** Doublespeed owns its live-session lifecycle and relaunch; it exposes no port reverse. */ +export function bindDoublespeedApplicationLifecycle( + params: DoublespeedLifecycleParams, +): ApplicationLifecycleRuntimeOperations { + return bindDirectApplicationLifecycle({ + owner: 'Doublespeed', + openTargetIdentity: 'bundle-id', + closeBeforeRelaunch: true, + configureProviderPortReverse: async () => { + throw new AppError('UNSUPPORTED_OPERATION', DOUBLESPEED_PORT_REVERSE_UNSUPPORTED, { + command: 'port reverse', + }); + }, + binding: bindProviderApplicationLifecycleInteractor({ + device: params.device, + signal: params.signal, + resolveInteractor: (runner) => params.getInteractor(params.device, runner), + }), + }); +} diff --git a/packages/provider-doublespeed/src/plugin.ts b/packages/provider-doublespeed/src/plugin.ts new file mode 100644 index 0000000000..e19c501bef --- /dev/null +++ b/packages/provider-doublespeed/src/plugin.ts @@ -0,0 +1,58 @@ +import type { ProviderPluginHost } from 'agent-device/plugins'; +import { verifyDoublespeedConnection } from './connection-verification.ts'; +import type { CliFlags } from '@agent-device/contracts/command'; + +export default async function doublespeedPlugin(host: ProviderPluginHost) { + const { createDoublespeedRuntime } = await import('./runtime.ts'); + const apiKey = host.env.DOUBLESPEED_API_KEY?.trim() ?? ''; + const registration = createDoublespeedRuntime( + { + apiKey, + apiUrl: host.env.DOUBLESPEED_API_URL?.trim() || undefined, + device: host.env.DOUBLESPEED_DEVICE?.trim() || undefined, + }, + { clientVersion: host.clientVersion, host: host.apple, ios: host.apple }, + { includePlatformModule: true }, + ); + return { + ...registration, + connection: { + resolve: ({ flags }: { flags: CliFlags }) => { + if (!apiKey) + throw host.createError( + 'INVALID_ARGS', + 'connect doublespeed requires DOUBLESPEED_API_KEY.', + ); + if (flags.platform !== undefined && flags.platform !== 'ios') + throw host.createError( + 'INVALID_ARGS', + 'connect doublespeed supports --platform ios only.', + ); + if (flags.device !== undefined) + throw host.createError( + 'INVALID_ARGS', + 'connect doublespeed does not accept --device; set DOUBLESPEED_DEVICE to pick the simulator model.', + ); + if (flags.leaseBackend !== undefined && flags.leaseBackend !== 'ios-instance') + throw host.createError( + 'INVALID_ARGS', + 'connect doublespeed requires --lease-backend ios-instance.', + ); + return { + profile: { + leaseProvider: 'doublespeed', + leaseBackend: 'ios-instance', + platform: 'ios', + daemonTransport: 'auto', + } as const, + }; + }, + verify: async () => + await verifyDoublespeedConnection({ + apiKey, + apiUrl: host.env.DOUBLESPEED_API_URL?.trim() || undefined, + clientVersion: host.clientVersion, + }), + }, + }; +} diff --git a/packages/provider-doublespeed/src/runtime-dependencies.ts b/packages/provider-doublespeed/src/runtime-dependencies.ts new file mode 100644 index 0000000000..07295608b1 --- /dev/null +++ b/packages/provider-doublespeed/src/runtime-dependencies.ts @@ -0,0 +1,18 @@ +export type DoublespeedHostAdapter = { + archiveDirectory(options: { + sourceDirectory: string; + entryName: string; + archivePath: string; + }): Promise; +}; + +export type DoublespeedIosRuntimeAdapter = { + resolveAppAlias(app: string): Promise; + readBundleAppName(appPath: string): Promise; +}; + +export type DoublespeedRuntimeDependencies = { + clientVersion: string; + host: DoublespeedHostAdapter; + ios: DoublespeedIosRuntimeAdapter; +}; diff --git a/packages/provider-doublespeed/src/runtime-instance.test.ts b/packages/provider-doublespeed/src/runtime-instance.test.ts new file mode 100644 index 0000000000..7dbc3e1fb8 --- /dev/null +++ b/packages/provider-doublespeed/src/runtime-instance.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest'; +import { resolveDoublespeedRuntimeInstance } from './runtime-instance.ts'; + +test('derives a stable opaque identity without exposing the API key', () => { + const first = resolveDoublespeedRuntimeInstance({ apiKey: 'secret-key' }); + const same = resolveDoublespeedRuntimeInstance({ + apiKey: 'secret-key', + apiUrl: 'https://API.mac.doublespeed.ai/', + }); + const changed = resolveDoublespeedRuntimeInstance({ apiKey: 'another-key' }); + const otherHost = resolveDoublespeedRuntimeInstance({ + apiKey: 'secret-key', + apiUrl: 'https://staging.example', + }); + expect(first).toBe(same); + expect(changed).not.toBe(first); + expect(otherHost).not.toBe(first); + expect(first).not.toContain('secret-key'); +}); + +test('uses and validates an explicit composition identity', () => { + expect( + resolveDoublespeedRuntimeInstance({ apiKey: 'secret', runtimeInstance: ' account-a ' }), + ).toBe('account-a'); + expect(() => + resolveDoublespeedRuntimeInstance({ apiKey: 'secret', runtimeInstance: ' ' }), + ).toThrow('non-empty'); +}); diff --git a/packages/provider-doublespeed/src/runtime-instance.ts b/packages/provider-doublespeed/src/runtime-instance.ts new file mode 100644 index 0000000000..902e55c964 --- /dev/null +++ b/packages/provider-doublespeed/src/runtime-instance.ts @@ -0,0 +1,35 @@ +import { scryptSync } from 'node:crypto'; +import { DOUBLESPEED_DEFAULT_API_URL } from './api-client.ts'; + +const RUNTIME_INSTANCE_KEY_LENGTH = 32; +const RUNTIME_INSTANCE_SCRYPT_COST = 16_384; +const RUNTIME_INSTANCE_SCRYPT_MAX_MEMORY = 64 * 1024 * 1024; +const RUNTIME_INSTANCE_SALT = 'agent-device:doublespeed-runtime-owner:v1'; + +export function resolveDoublespeedRuntimeInstance(options: { + apiKey: string; + apiUrl?: string; + runtimeInstance?: string; +}): string { + if (options.runtimeInstance !== undefined) { + const explicit = options.runtimeInstance.trim(); + if (!explicit) throw new TypeError('Doublespeed runtimeInstance must be a non-empty string'); + return explicit; + } + const principal = JSON.stringify({ + provider: 'doublespeed', + apiUrl: normalizeApiUrl(options.apiUrl), + apiKey: options.apiKey, + }); + const fingerprint = scryptSync(principal, RUNTIME_INSTANCE_SALT, RUNTIME_INSTANCE_KEY_LENGTH, { + N: RUNTIME_INSTANCE_SCRYPT_COST, + r: 8, + p: 1, + maxmem: RUNTIME_INSTANCE_SCRYPT_MAX_MEMORY, + }); + return `principal-${fingerprint.toString('hex')}`; +} + +function normalizeApiUrl(apiUrl: string | undefined): string { + return (apiUrl?.trim() || DOUBLESPEED_DEFAULT_API_URL).replace(/\/+$/, '').toLowerCase(); +} diff --git a/packages/provider-doublespeed/src/runtime.fixtures.ts b/packages/provider-doublespeed/src/runtime.fixtures.ts new file mode 100644 index 0000000000..1a79d1ed39 --- /dev/null +++ b/packages/provider-doublespeed/src/runtime.fixtures.ts @@ -0,0 +1,144 @@ +import type { DeviceLease } from '@agent-device/contracts/device'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { DoublespeedPlatformRuntimeOwnerOptions } from './app-log-runtime.ts'; +import type { DoublespeedRuntimeDependencies } from './runtime-dependencies.ts'; + +export const doublespeedIosDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'doublespeed:ios:lease-a', + name: 'Doublespeed iPhone 16', + kind: 'simulator', + target: 'mobile', + booted: true, +}; + +export const doublespeedScope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + +export function doublespeedLease(leaseId = 'lease-a'): DeviceLease { + return { + leaseId, + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'doublespeed', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; +} + +export const doublespeedTestDependencies: DoublespeedRuntimeDependencies = { + clientVersion: 'test-version', + host: { archiveDirectory: async () => undefined }, + ios: { + resolveAppAlias: async (app) => app, + readBundleAppName: async () => undefined, + }, +}; + +/** + * One inert owner wiring. Each scenario overrides only the ports it asserts on, so a new required + * option lands in a single place instead of every construction site. + */ +export function doublespeedOwnerOptions( + overrides: Partial = {}, +): DoublespeedPlatformRuntimeOwnerOptions { + return { + host: unusedDoublespeedHost(), + runtimeInstance: 'default', + ownsDevice: () => true, + hasLiveSession: () => true, + getInteractor: () => undefined, + openCurrent: async () => undefined, + reconnect: async () => ({ status: 'missing' }), + listApps: async () => [], + getAppState: async () => ({ package: 'com.example.app' }), + ...overrides, + }; +} + +export function unusedDoublespeedHost(): PlatformRuntimeHost { + const failOperation = async (): Promise => { + throw new Error('unused'); + }; + const implementedFacets = { + artifacts: { + resolveSession: (sessionId: string) => ({ + outputPath: `/sessions/${sessionId}/app.log`, + pidPath: `/sessions/${sessionId}/app-log.pid`, + }), + }, + commands: { + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + appLogs: { + readRecent: async () => ({ + path: '/sessions/session/app.log', + exists: false, + text: '', + skippedLines: 0, + }), + readProcessMarker: async () => ({ status: 'missing' as const }), + }, + appleDeployment: unusedHostFacet(failOperation), + androidDeployment: unusedHostFacet(failOperation), + }; + // Test-only trust boundary: an unexpected host facet returns an operation that fails instead + // of creating a second, incomplete PlatformRuntimeHost fixture. + return new Proxy(implementedFacets, { + get: (target, property) => + Reflect.has(target, property) + ? Reflect.get(target, property) + : unusedHostFacet(failOperation), + }) as unknown as PlatformRuntimeHost; +} + +function unusedHostFacet(operation: () => Promise): Facet { + return new Proxy({} as Facet, { get: () => operation }); +} + +export type FetchCall = { url: string; init: RequestInit }; + +/** A scripted `fetch`: each handler answers one request in order and records what it saw. */ +export function scriptedFetch( + handlers: Array<(call: FetchCall) => { status?: number; body?: unknown }>, +): { fetch: typeof fetch; calls: FetchCall[] } { + const calls: FetchCall[] = []; + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const call = { url: String(input), init: init ?? {} }; + calls.push(call); + const handler = handlers.shift(); + if (!handler) throw new Error(`unexpected request ${call.init.method ?? 'GET'} ${call.url}`); + const { status = 200, body = {} } = handler(call); + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + return { fetch: fetchImpl, calls }; +} + +export function readySimulator(overrides: Record = {}) { + return { + id: 'sim-a', + status: 'running', + ready: true, + device: 'iPhone 16', + labels: { provider: 'doublespeed', leaseId: 'lease-a' }, + api_url: 'https://worker.example/i/token-a', + token: 'token-a', + viewer_url: 'https://worker.example/s/token-a', + screen: { width: 393, height: 852, scale: 3 }, + expires_at: '2030-01-01T00:00:00.000Z', + error: null, + ...overrides, + }; +} diff --git a/packages/provider-doublespeed/src/runtime.test.ts b/packages/provider-doublespeed/src/runtime.test.ts new file mode 100644 index 0000000000..552250936c --- /dev/null +++ b/packages/provider-doublespeed/src/runtime.test.ts @@ -0,0 +1,124 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { createDoublespeedRuntime } from './runtime.ts'; +import { + doublespeedLease, + doublespeedTestDependencies, + readySimulator, + scriptedFetch, +} from './runtime.fixtures.ts'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('allocates a labelled simulator per lease, serves it as inventory, and releases it', async () => { + const { fetch, calls } = scriptedFetch([ + () => ({ + status: 202, + body: readySimulator({ ready: false, status: 'queued', api_url: null }), + }), + () => ({ body: readySimulator() }), + () => ({ body: readySimulator({ status: 'cancelled', ready: false }) }), + ]); + vi.stubGlobal('fetch', fetch); + const runtime = createDoublespeedRuntime( + { apiKey: 'dsx_test_key', device: 'iPhone 16 Pro' }, + doublespeedTestDependencies, + ); + const lease = doublespeedLease(); + + const allocated = await runtime.leaseLifecycle.allocate?.(lease); + expect(allocated).toMatchObject({ doublespeedSimulatorId: 'sim-a' }); + expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ + device: 'iPhone 16 Pro', + labels: { + tenantId: 'team-a', + runId: 'run-a', + leaseId: 'lease-a', + provider: 'doublespeed', + source: 'agent-device-cli', + }, + wait: true, + }); + const inventory = await runtime.deviceInventoryProvider({ + leaseProvider: 'doublespeed', + leaseId: 'lease-a', + platform: 'ios', + }); + expect(inventory).toHaveLength(1); + const device = inventory![0]!; + expect(device).toMatchObject({ + id: 'doublespeed:ios:lease-a', + kind: 'simulator', + appleOs: 'ios', + }); + expect(runtime.ownsDevice(device)).toBe(true); + expect(runtime.getInteractor(device)).toBeDefined(); + expect(runtime.getDeviceSession(device)).toBeDefined(); + expect(await runtime.leaseLifecycle.allocate?.(lease)).toMatchObject({ + doublespeedSimulatorId: 'sim-a', + }); + expect(calls).toHaveLength(2); + + await expect(runtime.leaseLifecycle.release?.(lease)).resolves.toEqual({ + doublespeedSimulatorId: 'sim-a', + }); + expect(`${calls[2]?.init.method} ${calls[2]?.url}`).toBe( + 'DELETE https://api.mac.doublespeed.ai/v1/xcode/simulators/sim-a', + ); + expect(runtime.getInteractor(device)).toBeUndefined(); +}); + +test('ignores leases it does not own and recovers orphaned simulators by label', async () => { + const { fetch, calls } = scriptedFetch([ + () => ({ body: { simulators: [readySimulator({ id: 'sim-orphan' })] } }), + () => ({ body: readySimulator({ id: 'sim-orphan', status: 'cancelled', ready: false }) }), + ]); + vi.stubGlobal('fetch', fetch); + const runtime = createDoublespeedRuntime({ apiKey: 'dsx_test_key' }, doublespeedTestDependencies); + + expect( + await runtime.leaseLifecycle.allocate?.({ ...doublespeedLease(), leaseProvider: 'limrun' }), + ).toBeUndefined(); + expect( + await runtime.leaseLifecycle.allocate?.({ ...doublespeedLease(), backend: 'android-instance' }), + ).toBeUndefined(); + await expect( + runtime.recoverExpiredLease({ ...doublespeedLease(), backend: 'android-instance' }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' }); + + await runtime.recoverExpiredLease(doublespeedLease('lease-orphan')); + expect(calls[0]?.url).toBe( + 'https://api.mac.doublespeed.ai/v1/xcode/simulators?label_selector=provider%3Ddoublespeed%2CleaseId%3Dlease-orphan', + ); + expect(`${calls[1]?.init.method} ${calls[1]?.url}`).toBe( + 'DELETE https://api.mac.doublespeed.ai/v1/xcode/simulators/sim-orphan', + ); +}); + +test('releases a simulator whose session never exposed an API', async () => { + const { fetch, calls } = scriptedFetch([ + () => ({ body: readySimulator({ api_url: null, token: null }) }), + () => ({ body: readySimulator({ status: 'cancelled', ready: false }) }), + ]); + vi.stubGlobal('fetch', fetch); + const runtime = createDoublespeedRuntime({ apiKey: 'dsx_test_key' }, doublespeedTestDependencies); + await expect(runtime.leaseLifecycle.allocate?.(doublespeedLease())).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + }); + expect(calls[1]?.init.method).toBe('DELETE'); +}); + +test('registers a provider-runtime owner keyed by an opaque principal', () => { + const registration = createDoublespeedRuntime( + { apiKey: 'dsx_test_key' }, + doublespeedTestDependencies, + { includePlatformModule: true }, + ); + expect(registration.runtime.provider).toBe('doublespeed'); + expect(registration.platformModule.owner).toMatchObject({ + kind: 'provider-runtime', + provider: 'doublespeed', + }); + expect(registration.platformModule.owner.instance).not.toContain('dsx_test_key'); +}); diff --git a/packages/provider-doublespeed/src/runtime.ts b/packages/provider-doublespeed/src/runtime.ts new file mode 100644 index 0000000000..fb45f034aa --- /dev/null +++ b/packages/provider-doublespeed/src/runtime.ts @@ -0,0 +1,334 @@ +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import type { + DeviceInventoryProvider, + DeviceLease, + LeaseLifecycleProvider, + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, + ProviderDeviceRuntime, + ProviderExpiredLeaseRecovery, +} from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + PlatformRuntimeHost, + PlatformRuntimeOwner, + PlatformRuntimeProviderModule, +} from '@agent-device/contracts/platform-runtime-operations'; +import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import { DOUBLESPEED_CLIENT_HEADER, DoublespeedApiClient } from './api-client.ts'; +import type { DoublespeedAppLogDescriptor } from './app-log-descriptor.ts'; +import type { DoublespeedAppLogReader } from './app-log-poller.ts'; +import { + buildDoublespeedDevice, + DOUBLESPEED_PROVIDER, + isDoublespeedLeaseBackend, + parseDoublespeedDeviceId, +} from './device.ts'; +import { createDoublespeedDeviceSession, type DoublespeedDeviceSession } from './device-session.ts'; +import { + createDoublespeedIosInteractor, + createDoublespeedIosSession, + installDoublespeedIosApp, + type DoublespeedIosSession, +} from './ios.ts'; +import type { DoublespeedRuntimeDependencies } from './runtime-dependencies.ts'; +import { resolveDoublespeedRuntimeInstance } from './runtime-instance.ts'; + +export type DoublespeedRuntimeOptions = { + apiKey: string; + apiUrl?: string; + /** Simulator model name, e.g. `iPhone 16 Pro`; the service default applies when omitted. */ + device?: string; + runtimeInstance?: string; +}; + +export type DoublespeedRuntime = ProviderDeviceRuntime & { + recoverExpiredLease: ProviderExpiredLeaseRecovery; + getDeviceSession(device: DeviceInfo): DoublespeedDeviceSession | undefined; +}; + +export type DoublespeedRuntimeRegistration = Readonly<{ + runtime: DoublespeedRuntime; + platformModule: PlatformRuntimeProviderModule; +}>; + +export function createDoublespeedRuntime( + options: DoublespeedRuntimeOptions, + dependencies: DoublespeedRuntimeDependencies, + mode: Readonly<{ includePlatformModule: true }>, +): DoublespeedRuntimeRegistration; +export function createDoublespeedRuntime( + options: DoublespeedRuntimeOptions, + dependencies: DoublespeedRuntimeDependencies, +): DoublespeedRuntime; +export function createDoublespeedRuntime( + options: DoublespeedRuntimeOptions, + dependencies: DoublespeedRuntimeDependencies, + mode?: Readonly<{ includePlatformModule: true }>, +): DoublespeedRuntime | DoublespeedRuntimeRegistration { + const runtime = new DoublespeedRuntimeImplementation(options, dependencies); + if (!mode?.includePlatformModule) return runtime; + const owner = providerRuntimeOwner( + DOUBLESPEED_PROVIDER, + resolveDoublespeedRuntimeInstance(options), + ); + if (owner.kind !== 'provider-runtime') throw new TypeError('Invalid Doublespeed runtime owner'); + return Object.freeze({ + runtime, + platformModule: Object.freeze({ + owner, + loadRuntime: async (host: PlatformRuntimeHost) => + await loadDoublespeedPlatformRuntime(runtime, owner.instance, host), + }), + }); +} + +class DoublespeedRuntimeImplementation implements ProviderDeviceRuntime { + private readonly api: DoublespeedApiClient; + private readonly sessions = new Map(); + private readonly options: DoublespeedRuntimeOptions; + private readonly dependencies: DoublespeedRuntimeDependencies; + readonly provider = DOUBLESPEED_PROVIDER; + + readonly leaseLifecycle: LeaseLifecycleProvider = { + allocate: async (lease) => await this.allocate(lease), + release: async (lease) => await this.release(lease), + }; + + readonly recoverExpiredLease: ProviderExpiredLeaseRecovery = async (lease) => { + if (lease.leaseProvider !== this.provider || !isDoublespeedLeaseBackend(lease.backend)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Doublespeed cannot recover this expired lease.', + { + leaseId: lease.leaseId, + leaseProvider: lease.leaseProvider, + leaseBackend: lease.backend, + }, + ); + } + await this.release(lease); + }; + + readonly deviceInventoryProvider: DeviceInventoryProvider = async (request) => { + if (request.leaseProvider !== this.provider || !request.leaseId) return null; + const session = this.sessions.get(request.leaseId); + if (!session) return null; + if (request.platform && request.platform !== 'ios') return []; + return [session.device]; + }; + + constructor(options: DoublespeedRuntimeOptions, dependencies: DoublespeedRuntimeDependencies) { + this.options = options; + this.dependencies = dependencies; + this.api = new DoublespeedApiClient({ + apiKey: options.apiKey, + apiUrl: options.apiUrl, + clientVersion: dependencies.clientVersion, + }); + } + + ownsDevice(device: DeviceInfo): boolean { + return parseDoublespeedDeviceId(device.id) !== undefined; + } + + hasLiveSession(device: DeviceInfo): boolean { + return this.getSessionForDevice(device) !== undefined; + } + + getInteractor(device: DeviceInfo, _runner?: RunnerContext): Interactor | undefined { + const session = this.getSessionForDevice(device); + return session ? createDoublespeedIosInteractor(session) : undefined; + } + + getDeviceSession(device: DeviceInfo): DoublespeedDeviceSession | undefined { + const session = this.getSessionForDevice(device); + return session ? createDoublespeedDeviceSession(session) : undefined; + } + + async installApp( + device: DeviceInfo, + app: string, + appPath: string, + options?: ProviderDeviceInstallOptions, + signal?: AbortSignal, + ): Promise { + return await this.installInstallablePath( + device, + appPath, + { ...options, appIdentifierHint: options?.appIdentifierHint ?? app }, + signal, + ); + } + + async installInstallablePath( + device: DeviceInfo, + installablePath: string, + options?: ProviderDeviceInstallOptions, + signal?: AbortSignal, + ): Promise { + const session = this.getSessionForDevice(device); + if (!session) return undefined; + return await installDoublespeedIosApp(this.api, session, installablePath, options, signal); + } + + async shutdown(): Promise { + const sessions = [...this.sessions.values()]; + await Promise.allSettled(sessions.map(async (session) => await this.terminateSession(session))); + this.sessions.clear(); + } + + private async allocate(lease: DeviceLease): Promise | undefined> { + if (lease.leaseProvider !== this.provider || !isDoublespeedLeaseBackend(lease.backend)) { + return undefined; + } + const existing = this.sessions.get(lease.leaseId); + if (existing) return { doublespeedSimulatorId: existing.simulatorId, device: existing.device }; + + const simulator = await this.api.createSimulator({ + device: this.options.device, + labels: this.buildLabels(lease), + }); + try { + if (!simulator.api_url || !simulator.screen) { + throw new AppError('COMMAND_FAILED', 'Doublespeed simulator did not expose a session API'); + } + const session = createDoublespeedIosSession( + { + lease, + simulatorId: simulator.id, + device: buildDoublespeedDevice(lease, simulator), + apiUrl: simulator.api_url, + screen: simulator.screen, + }, + this.dependencies, + ); + this.sessions.set(lease.leaseId, session); + return { doublespeedSimulatorId: session.simulatorId, device: session.device }; + } catch (error) { + await this.api.deleteSimulator(simulator.id).catch(() => {}); + throw error; + } + } + + private buildLabels(lease: DeviceLease): Record { + return { + tenantId: lease.tenantId, + runId: lease.runId, + leaseId: lease.leaseId, + provider: lease.leaseProvider ?? DOUBLESPEED_PROVIDER, + source: DOUBLESPEED_CLIENT_HEADER, + }; + } + + private async release(lease: DeviceLease): Promise | undefined> { + const session = this.sessions.get(lease.leaseId); + if (!session) return await this.releaseRecoveredSession(lease); + await this.terminateSession(session); + this.sessions.delete(lease.leaseId); + return { doublespeedSimulatorId: session.simulatorId }; + } + + private async releaseRecoveredSession( + lease: DeviceLease, + ): Promise | undefined> { + if (!isDoublespeedLeaseBackend(lease.backend)) return undefined; + const simulators = await this.api.listSimulators({ + provider: DOUBLESPEED_PROVIDER, + leaseId: lease.leaseId, + }); + for (const simulator of simulators) await this.api.deleteSimulator(simulator.id); + if (simulators.length === 0) return undefined; + return { + doublespeedSimulatorId: simulators[0]?.id, + doublespeedSimulatorCount: simulators.length, + }; + } + + private async terminateSession(session: DoublespeedIosSession): Promise { + await this.api.deleteSimulator(session.simulatorId); + } + + private getSessionForDevice(device: DeviceInfo): DoublespeedIosSession | undefined { + const parsed = parseDoublespeedDeviceId(device.id); + return parsed ? this.sessions.get(parsed.leaseId) : undefined; + } + + currentAppLogReader(device: DeviceInfo): DoublespeedAppLogReader | undefined { + const session = this.getSessionForDevice(device); + if (!session) return undefined; + const deviceSession = createDoublespeedDeviceSession(session); + return { + leaseId: session.lease.leaseId, + simulatorId: session.simulatorId, + readLogs: async (appBundleId, lineLimit) => + await deviceSession.readLogs(appBundleId, lineLimit), + [Symbol.asyncDispose]: async () => undefined, + }; + } + + async reconnectAppLogReader(descriptor: DoublespeedAppLogDescriptor, signal?: AbortSignal) { + const { reconnectDoublespeedAppLogReader } = await import('./app-log-reconnect.ts'); + return await reconnectDoublespeedAppLogReader({ api: this.api, descriptor, signal }); + } +} + +async function loadDoublespeedPlatformRuntime( + runtime: DoublespeedRuntimeImplementation, + runtimeInstance: string, + host: PlatformRuntimeHost, +): Promise { + const { createDoublespeedPlatformRuntimeOwner } = await import('./app-log-runtime.ts'); + return createDoublespeedPlatformRuntimeOwner({ + host, + runtimeInstance, + ownsDevice: (device) => runtime.ownsDevice(device), + hasLiveSession: (device) => runtime.hasLiveSession(device), + getInteractor: (device, runner) => runtime.getInteractor(device, runner), + openCurrent: async (device) => runtime.currentAppLogReader(device), + reconnect: async (descriptor, signal) => + await runtime.reconnectAppLogReader(descriptor, signal), + listApps: async (device, filter, signal) => { + signal.throwIfAborted(); + const session = runtime.getDeviceSession(device); + if (!session) { + throw new AppError('DEVICE_NOT_FOUND', 'Doublespeed app inventory session is unavailable', { + deviceId: device.id, + }); + } + return (await session.listApps(filter, signal)).map((app) => ({ + id: app.id, + name: app.name ?? app.id, + })); + }, + getAppState: async (device, signal) => { + signal.throwIfAborted(); + const session = runtime.getDeviceSession(device); + if (!session) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Doublespeed appstate requires an active provider session', + ); + } + const state = await session.getForegroundApp(signal); + signal.throwIfAborted(); + return { package: state.appId }; + }, + deployApp: async (device, input, signal) => + await runtime.installApp( + device, + input.app, + input.appPath, + { relaunch: input.replaceExisting, appIdentifierHint: input.app }, + signal, + ), + deployMaterializedApp: async (device, input, signal) => + await runtime.installInstallablePath( + device, + input.artifact.installablePath, + { appIdentifierHint: input.artifact.bundleId }, + signal, + ), + }); +} diff --git a/packages/provider-doublespeed/src/session-client.test.ts b/packages/provider-doublespeed/src/session-client.test.ts new file mode 100644 index 0000000000..9932b1c80a --- /dev/null +++ b/packages/provider-doublespeed/src/session-client.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from 'vitest'; +import { createDoublespeedSessionClient } from './session-client.ts'; +import { scriptedFetch } from './runtime.fixtures.ts'; + +const API_URL = 'https://worker.example/i/token-a'; + +test('maps the session inventory and state to camel-cased shapes', async () => { + const { fetch, calls } = scriptedFetch([ + () => ({ + body: { + apps: [ + { bundle_id: 'com.apple.Preferences', name: 'Settings', install_type: 'System' }, + { bundle_id: 'com.example.ios', name: null, install_type: 'User' }, + ], + }, + }), + () => ({ body: { bundle_id: 'com.example.ios', pid: 12 } }), + () => ({ body: { bundle_id: null, pid: null } }), + () => ({ body: { bundle_id: 'com.example.ios', text: 'line one\nline two\n' } }), + ]); + const client = createDoublespeedSessionClient(API_URL, { fetch }); + + expect(await client.listApps()).toEqual([ + { bundleId: 'com.apple.Preferences', name: 'Settings', installType: 'System' }, + { bundleId: 'com.example.ios', installType: 'User' }, + ]); + expect(await client.foregroundApp()).toEqual({ bundleId: 'com.example.ios' }); + expect(await client.foregroundApp()).toEqual({}); + expect(await client.appLogTail('com.example.ios', 200)).toBe('line one\nline two\n'); + expect(calls[3]?.url).toBe(`${API_URL}/logs?bundle_id=com.example.ios&lines=200`); +}); + +test('sends install, input and orientation requests in the session wire shape', async () => { + const { fetch, calls } = scriptedFetch([ + () => ({ body: { bundle_id: 'com.example.ios', launched: true } }), + () => ({ body: { ok: true } }), + () => ({ body: { ok: true } }), + () => ({ body: { ok: true } }), + () => ({ body: { ok: true } }), + ]); + const client = createDoublespeedSessionClient(API_URL, { fetch }); + + expect( + await client.installApp({ + url: 'https://blob/get', + sha256: 'abc', + launchMode: 'RelaunchIfRunning', + }), + ).toEqual({ bundleId: 'com.example.ios' }); + await client.tapElement({ label: 'Continue' }); + await client.longPress(10, 20, 600); + await client.scroll('down', 300); + await client.setOrientation('landscape'); + + expect( + calls.map((call) => [call.url.slice(API_URL.length), JSON.parse(String(call.init.body))]), + ).toEqual([ + ['/apps/install', { url: 'https://blob/get', sha256: 'abc', launch_mode: 'RelaunchIfRunning' }], + ['/tap-element', { selector: { label: 'Continue' } }], + ['/long-press', { x: 10, y: 20, ms: 600 }], + ['/scroll', { direction: 'down', pixels: 300 }], + ['/orientation', { orientation: 'landscape' }], + ]); +}); + +test('keeps the typed provider reason for a missing element', async () => { + const { fetch } = scriptedFetch([ + () => ({ status: 404, body: { error: { code: 'ELEMENT_NOT_FOUND', message: 'no element' } } }), + () => ({ status: 500, body: { error: { code: 'INTERNAL', message: 'boom' } } }), + ]); + const client = createDoublespeedSessionClient(API_URL, { fetch }); + await expect(client.tapElement({ label: 'Nope' })).rejects.toMatchObject({ + code: 'ELEMENT_NOT_FOUND', + details: { status: 404, providerCode: 'ELEMENT_NOT_FOUND' }, + }); + await expect(client.tap(1, 1)).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { status: 500, providerCode: 'INTERNAL' }, + }); +}); diff --git a/packages/provider-doublespeed/src/session-client.ts b/packages/provider-doublespeed/src/session-client.ts new file mode 100644 index 0000000000..06124fee45 --- /dev/null +++ b/packages/provider-doublespeed/src/session-client.ts @@ -0,0 +1,200 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { boundedSignal } from './api-client.ts'; + +const REQUEST_TIMEOUT_MS = 60_000; +const INSTALL_TIMEOUT_MS = 10 * 60_000; + +export type DoublespeedSessionScreen = { width: number; height: number; scale: number }; + +export type DoublespeedSessionInfo = { + device: string; + udid: string; + screen: DoublespeedSessionScreen; + bundleId?: string; +}; + +export type DoublespeedInstalledApp = { + bundleId: string; + name?: string; + installType: string; +}; + +export type DoublespeedTreeNode = { + type?: string; + label?: string; + identifier?: string; + value?: string; + frame?: { x?: number; y?: number; width?: number; height?: number }; + enabled?: boolean; + visible?: boolean; + children?: DoublespeedTreeNode[]; +}; + +export type DoublespeedElementSelector = { + accessibilityId?: string; + label?: string; + value?: string; +}; + +export type DoublespeedLaunchMode = 'ForegroundIfRunning' | 'RelaunchIfRunning'; +export type DoublespeedSessionKey = 'home' | 'enter' | 'backspace' | 'escape'; +export type DoublespeedOrientation = 'portrait' | 'landscape'; +export type DoublespeedScrollDirection = 'up' | 'down' | 'left' | 'right'; + +type SessionErrorBody = { error?: { code?: string; message?: string } }; + +/** + * One live simulator session's JSON API. The session URL carries the capability token, so no + * account credential ever travels to the worker that hosts the simulator. + */ +export type DoublespeedSessionClient = Readonly<{ + apiUrl: string; + info(signal?: AbortSignal): Promise; + listApps(signal?: AbortSignal): Promise; + installApp( + input: { url: string; sha256?: string; launchMode?: DoublespeedLaunchMode }, + signal?: AbortSignal, + ): Promise<{ bundleId?: string }>; + launchApp(bundleId: string, signal?: AbortSignal): Promise; + terminateApp(bundleId: string, signal?: AbortSignal): Promise; + openUrl(url: string, signal?: AbortSignal): Promise; + tap(x: number, y: number, signal?: AbortSignal): Promise; + longPress(x: number, y: number, ms?: number, signal?: AbortSignal): Promise; + tapElement(selector: DoublespeedElementSelector, signal?: AbortSignal): Promise; + typeText(text: string, signal?: AbortSignal): Promise; + scroll( + direction: DoublespeedScrollDirection, + pixels: number, + signal?: AbortSignal, + ): Promise; + pressKey(key: DoublespeedSessionKey, signal?: AbortSignal): Promise; + setOrientation(orientation: DoublespeedOrientation, signal?: AbortSignal): Promise; + screenshot(signal?: AbortSignal): Promise<{ base64: string }>; + elementTree(signal?: AbortSignal): Promise; + appLogTail(bundleId: string, lines: number, signal?: AbortSignal): Promise; + foregroundApp(signal?: AbortSignal): Promise<{ bundleId?: string }>; +}>; + +export function createDoublespeedSessionClient( + apiUrl: string, + options?: { fetch?: typeof fetch }, +): DoublespeedSessionClient { + const fetchImpl = options?.fetch ?? fetch; + const request = async ( + method: 'GET' | 'POST', + path: string, + body?: unknown, + signal?: AbortSignal, + timeoutMs = REQUEST_TIMEOUT_MS, + ): Promise => { + const response = await fetchImpl(`${apiUrl}${path}`, { + method, + headers: { 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: boundedSignal(timeoutMs, signal), + }); + const payload = (await response.json().catch(() => undefined)) as SessionErrorBody | undefined; + if (!response.ok) throw sessionError(response.status, payload?.error); + return payload as Result; + }; + const post = async (path: string, body: unknown, signal?: AbortSignal): Promise => { + await request('POST', path, body, signal); + }; + + return Object.freeze({ + apiUrl, + info: async (signal) => { + const body = await request<{ + device: string; + udid: string; + screen: DoublespeedSessionScreen; + bundle_id: string | null; + }>('GET', '/', undefined, signal); + return { + device: body.device, + udid: body.udid, + screen: body.screen, + ...(body.bundle_id ? { bundleId: body.bundle_id } : {}), + }; + }, + listApps: async (signal) => { + const body = await request<{ + apps: Array<{ bundle_id: string; name: string | null; install_type: string }>; + }>('GET', '/apps', undefined, signal); + return body.apps.map((app) => ({ + bundleId: app.bundle_id, + ...(app.name ? { name: app.name } : {}), + installType: app.install_type, + })); + }, + installApp: async (input, signal) => { + const body = await request<{ bundle_id?: string }>( + 'POST', + '/apps/install', + { + url: input.url, + ...(input.sha256 ? { sha256: input.sha256 } : {}), + ...(input.launchMode ? { launch_mode: input.launchMode } : {}), + }, + signal, + INSTALL_TIMEOUT_MS, + ); + return body.bundle_id ? { bundleId: body.bundle_id } : {}; + }, + launchApp: async (bundleId, signal) => + await post(`/apps/${encodeURIComponent(bundleId)}/launch`, {}, signal), + terminateApp: async (bundleId, signal) => + await post(`/apps/${encodeURIComponent(bundleId)}/terminate`, {}, signal), + openUrl: async (url, signal) => await post('/open-url', { url }, signal), + tap: async (x, y, signal) => await post('/tap', { x, y }, signal), + longPress: async (x, y, ms, signal) => + await post('/long-press', { x, y, ...(ms ? { ms } : {}) }, signal), + tapElement: async (selector, signal) => await post('/tap-element', { selector }, signal), + typeText: async (text, signal) => await post('/type', { text }, signal), + scroll: async (direction, pixels, signal) => + await post('/scroll', { direction, pixels }, signal), + pressKey: async (key, signal) => await post('/key', { key }, signal), + setOrientation: async (orientation, signal) => + await post('/orientation', { orientation }, signal), + screenshot: async (signal) => { + const body = await request<{ base64: string }>('GET', '/screenshot', undefined, signal); + return { base64: body.base64 }; + }, + elementTree: async (signal) => { + const body = await request<{ nodes: DoublespeedTreeNode[] }>( + 'GET', + '/tree', + undefined, + signal, + ); + return body.nodes; + }, + appLogTail: async (bundleId, lines, signal) => { + const query = `?bundle_id=${encodeURIComponent(bundleId)}&lines=${Math.max(1, Math.floor(lines))}`; + const body = await request<{ text: string }>('GET', `/logs${query}`, undefined, signal); + return body.text; + }, + foregroundApp: async (signal) => { + const body = await request<{ bundle_id: string | null }>( + 'GET', + '/app-state', + undefined, + signal, + ); + return body.bundle_id ? { bundleId: body.bundle_id } : {}; + }, + }); +} + +function sessionError(status: number, error: SessionErrorBody['error']): AppError { + const code = + status === 404 && error?.code === 'ELEMENT_NOT_FOUND' ? 'ELEMENT_NOT_FOUND' : 'COMMAND_FAILED'; + return new AppError( + code, + `Doublespeed session request failed: ${error?.message ?? `HTTP ${status}`}`, + { + status, + ...(error?.code ? { providerCode: error.code } : {}), + }, + ); +} diff --git a/packages/provider-doublespeed/src/snapshot.ts b/packages/provider-doublespeed/src/snapshot.ts new file mode 100644 index 0000000000..ba4d9d52a8 --- /dev/null +++ b/packages/provider-doublespeed/src/snapshot.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import type { DoublespeedElementSelector, DoublespeedTreeNode } from './session-client.ts'; + +type SnapshotSelector = { key: 'id' | 'label' | 'text' | 'value'; value: string }; + +export function flattenDoublespeedTree(roots: readonly DoublespeedTreeNode[]): RawSnapshotNode[] { + const nodes: RawSnapshotNode[] = []; + const visit = (node: DoublespeedTreeNode, depth: number, parentIndex?: number) => { + const index = nodes.length; + nodes.push(mapNode(node, { index, depth, parentIndex })); + for (const child of node.children ?? []) visit(child, depth + 1, index); + }; + for (const root of roots) visit(root, 0); + return nodes; +} + +function mapNode( + node: DoublespeedTreeNode, + options: { index: number; depth: number; parentIndex?: number }, +): RawSnapshotNode { + return { + index: options.index, + type: node.type, + role: node.type, + label: node.label, + value: node.value, + identifier: node.identifier, + rect: readRect(node), + enabled: node.enabled, + visibleToUser: node.visible, + depth: options.depth, + parentIndex: options.parentIndex, + }; +} + +function readRect(node: DoublespeedTreeNode): RawSnapshotNode['rect'] { + const frame = node.frame; + if ( + !frame || + typeof frame.x !== 'number' || + typeof frame.y !== 'number' || + typeof frame.width !== 'number' || + typeof frame.height !== 'number' + ) { + return undefined; + } + return { x: frame.x, y: frame.y, width: frame.width, height: frame.height }; +} + +/** The session tree exposes visible text through `label`, so text selectors target that field. */ +export function toDoublespeedSelector(selector: SnapshotSelector): DoublespeedElementSelector { + if (selector.key === 'id') return { accessibilityId: selector.value }; + if (selector.key === 'value') return { value: selector.value }; + return { label: selector.value }; +} + +export async function writeBase64File(filePath: string, base64: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, Buffer.from(base64, 'base64')); +} diff --git a/packages/provider-doublespeed/src/strings.ts b/packages/provider-doublespeed/src/strings.ts new file mode 100644 index 0000000000..8b6689d728 --- /dev/null +++ b/packages/provider-doublespeed/src/strings.ts @@ -0,0 +1,4 @@ +export function normalizeOptionalString(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} diff --git a/packages/provider-doublespeed/src/tmp-dir.fixtures.ts b/packages/provider-doublespeed/src/tmp-dir.fixtures.ts new file mode 100644 index 0000000000..dbc5c2724c --- /dev/null +++ b/packages/provider-doublespeed/src/tmp-dir.fixtures.ts @@ -0,0 +1,21 @@ +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Creates a fresh scratch directory for one test. Cleanup is automatic: the + * unit suite redirects TMPDIR to a per-run directory (scripts/vitest-tmpdir-global-setup.ts) + * that gets removed in one recursive rm after every worker finishes, so + * individual tests never need their own afterEach/afterAll for this. + */ +// fallow-ignore-next-line code-duplication +export async function mkdtempForTest(prefix: string): Promise { + return fsPromises.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +/** Sync counterpart of {@link mkdtempForTest}, for setup code that can't await. */ +// fallow-ignore-next-line code-duplication +export function mkdtempForTestSync(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} diff --git a/packages/provider-doublespeed/test/package-smoke.mjs b/packages/provider-doublespeed/test/package-smoke.mjs new file mode 100644 index 0000000000..fbe95b8f2d --- /dev/null +++ b/packages/provider-doublespeed/test/package-smoke.mjs @@ -0,0 +1,7 @@ +export const args = ['--platform', 'ios']; +export function environment(endpoint) { + return { DOUBLESPEED_API_KEY: 'key', DOUBLESPEED_API_URL: endpoint }; +} +export function respond(_url, rejectCredentials) { + return rejectCredentials ? { status: 401, body: { error: 'unauthorized' } } : { body: [] }; +} diff --git a/packages/provider-doublespeed/tsconfig.json b/packages/provider-doublespeed/tsconfig.json new file mode 100644 index 0000000000..c419bf796b --- /dev/null +++ b/packages/provider-doublespeed/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src", + "paths": { + "agent-device/plugins": ["./node_modules/agent-device/src/sdk/plugins.ts"] + } + }, + "include": ["src"] +} diff --git a/packages/provider-doublespeed/tsdown.config.ts b/packages/provider-doublespeed/tsdown.config.ts new file mode 100644 index 0000000000..5aeb12099a --- /dev/null +++ b/packages/provider-doublespeed/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { plugin: 'src/plugin.ts' }, + outDir: 'dist', + format: 'esm', + platform: 'node', + target: 'es2022', + minify: true, + dts: false, + hash: false, + deps: { alwaysBundle: [/^@agent-device\//] }, + inputOptions: { + onLog(level, log, handler) { + if (log.code === 'UNRESOLVED_IMPORT') throw new Error(log.message); + handler(level, log); + }, + }, +}); diff --git a/packages/provider-webdriver/package.json b/packages/provider-webdriver/package.json index d4d56b8d0e..1d445ccdf6 100644 --- a/packages/provider-webdriver/package.json +++ b/packages/provider-webdriver/package.json @@ -19,6 +19,10 @@ "./providers": { "types": "./src/providers.ts", "default": "./src/providers.ts" + }, + "./plugin": { + "types": "./src/plugin.ts", + "default": "./src/plugin.ts" } } } diff --git a/packages/provider-webdriver/src/capabilities.ts b/packages/provider-webdriver/src/capabilities.ts index 04293fd628..f0e5d456d1 100644 --- a/packages/provider-webdriver/src/capabilities.ts +++ b/packages/provider-webdriver/src/capabilities.ts @@ -171,3 +171,15 @@ function applyCapabilityOverrides( } return next; } + +export function buildCloudWebDriverBaseCapabilities( + platform: CloudWebDriverPlatform, + deviceName: string, + configured: Record = {}, +): Record { + return { + platformName: platform === 'ios' ? 'iOS' : 'Android', + 'appium:deviceName': deviceName, + ...configured, + }; +} diff --git a/packages/provider-webdriver/src/plugin.ts b/packages/provider-webdriver/src/plugin.ts new file mode 100644 index 0000000000..1a877023ab --- /dev/null +++ b/packages/provider-webdriver/src/plugin.ts @@ -0,0 +1,9 @@ +import type { CloudWebDriverRuntimeOptions, CloudWebDriverRuntime } from './runtime.ts'; + +export async function createCloudWebDriverRuntime( + options: CloudWebDriverRuntimeOptions, +): Promise { + const runtime = await import('./runtime.ts'); + return runtime.createCloudWebDriverRuntime(options); +} +export type { CloudWebDriverRuntimeOptions } from './runtime.ts'; diff --git a/packages/provider-webdriver/src/runtime-session.ts b/packages/provider-webdriver/src/runtime-session.ts index 7274789f6c..4425f761b6 100644 --- a/packages/provider-webdriver/src/runtime-session.ts +++ b/packages/provider-webdriver/src/runtime-session.ts @@ -8,6 +8,7 @@ import { AppError, errorMessage } from '@agent-device/kernel/errors'; import { unavailableCloudArtifactsResult } from './artifact-results.ts'; import { createCloudWebDriverCapabilities, + buildCloudWebDriverBaseCapabilities, type CloudWebDriverProviderCapabilities, } from './capabilities.ts'; import { WebDriverClient, type WebDriverSession } from './webdriver-client.ts'; @@ -17,7 +18,6 @@ import { snapshotBackendForPlatform } from './runtime-helpers.ts'; import { releaseOnFailure } from './webdriver-utils.ts'; import type { CloudWebDriverBaseSession, - CloudWebDriverPlatform, CloudWebDriverPreparedSession, CloudWebDriverRuntimeOptions, } from './runtime.ts'; @@ -293,18 +293,6 @@ export class WebDriverSessionManager { } } -export function buildCloudWebDriverBaseCapabilities( - platform: CloudWebDriverPlatform, - deviceName: string, - configured: Record = {}, -): Record { - return { - platformName: platform === 'ios' ? 'iOS' : 'Android', - 'appium:deviceName': deviceName, - ...configured, - }; -} - /** * The transport gave up on `POST /session`; the provider may still finish it, * and nothing here can learn that session's id — so the error names the lease diff --git a/packages/provider-webdriver/src/runtime.ts b/packages/provider-webdriver/src/runtime.ts index 4b94ac2d72..4f400bf28c 100644 --- a/packages/provider-webdriver/src/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -111,7 +111,7 @@ export function createCloudWebDriverRuntime( return new CloudWebDriverRuntimeImplementation(options); } -export { buildCloudWebDriverBaseCapabilities } from './runtime-session.ts'; +export { buildCloudWebDriverBaseCapabilities } from './capabilities.ts'; /** Public façade: provider wiring stays small while session/deployment mechanics stay focused. */ class CloudWebDriverRuntimeImplementation implements CloudWebDriverRuntime { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0f06abb97..7af867ee8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@agent-device/device-selection': specifier: workspace:* version: link:packages/device-selection + '@agent-device/doublespeed': + specifier: workspace:* + version: link:packages/provider-doublespeed '@agent-device/host-kit': specifier: workspace:* version: link:packages/host-kit @@ -454,6 +457,21 @@ importers: specifier: workspace:* version: link:../selectors + packages/provider-doublespeed: + devDependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + agent-device: + specifier: workspace:* + version: link:../.. + packages/provider-limrun: dependencies: '@agent-device/capture-kit': diff --git a/scripts/check-provider-plugin.mjs b/scripts/check-provider-plugin.mjs new file mode 100644 index 0000000000..bc713e7332 --- /dev/null +++ b/scripts/check-provider-plugin.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; + +const exec = promisify(execFile); +const root = path.resolve(import.meta.dirname, '..'); +const plugin = path.resolve(root, process.argv[2]); +const fixture = await import(pathToFileURL(path.join(plugin, 'test/package-smoke.mjs')).href); +const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-plugin-package-')); +const consumer = path.join(scratch, 'consumer'); +const home = path.join(scratch, 'home'); +const installation = crypto.randomUUID(); +const project = path.join(home, 'plugins', installation); +let rejectCredentials = true; +const requests = []; +const server = http.createServer((request, response) => { + requests.push(request.url); + const result = fixture.respond(request.url, rejectCredentials); + response.writeHead(result.status ?? 200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(result.body)); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const endpoint = `http://127.0.0.1:${server.address().port}`; +async function run(command, args, cwd) { + return await exec(command, args, { cwd, maxBuffer: 16 * 1024 * 1024, timeout: 120_000 }); +} +async function pack(directory) { + if (directory === plugin) { + const tarball = path.join(scratch, 'plugin.tgz'); + await run('pnpm', ['pack', '--out', tarball], directory); + return tarball; + } + const { stdout } = await run( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', scratch], + directory, + ); + return path.join(scratch, JSON.parse(stdout)[0].filename); +} +try { + await run('pnpm', ['build'], plugin); + const coreTarball = await pack(root); + const pluginTarball = await pack(plugin); + for (const directory of [consumer, project]) { + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, 'package.json'), '{"private":true,"type":"module"}'); + } + await run( + 'npm', + ['install', '--ignore-scripts', '--no-audit', '--no-fund', coreTarball], + consumer, + ); + await run( + 'npm', + ['install', '--ignore-scripts', '--no-audit', '--no-fund', pluginTarball], + project, + ); + const manifest = JSON.parse(await fs.readFile(path.join(plugin, 'package.json'), 'utf8')); + const installed = JSON.parse( + await fs.readFile(path.join(project, 'node_modules', manifest.name, 'package.json'), 'utf8'), + ); + assert.deepEqual(installed.exports, { '.': { import: './dist/plugin.mjs' } }); + assert.equal(installed.dependencies, undefined); + assert.equal(installed.peerDependencies, undefined); + await assert.rejects(fs.stat(path.join(project, 'node_modules', 'agent-device')), { + code: 'ENOENT', + }); + await fs.writeFile( + path.join(home, 'config.json'), + JSON.stringify({ plugins: { [manifest.name]: { installation } } }), + ); + const core = path.join(consumer, 'node_modules', 'agent-device'); + const coreManifest = JSON.parse(await fs.readFile(path.join(core, 'package.json'), 'utf8')); + const bin = path.resolve(core, coreManifest.bin['agent-device']); + const env = { + ...process.env, + ...fixture.environment(endpoint), + AGENT_DEVICE_HOME: home, + AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1', + }; + const preload = path.join(scratch, 'fetch-fixture.mjs'); + await fs.writeFile( + preload, + `const originalFetch = globalThis.fetch; +const redirects = ${JSON.stringify(fixture.fetchRedirects ?? [])}; +globalThis.fetch = (input, init) => { + let url = String(input); + for (const prefix of redirects) if (url.startsWith(prefix)) url = ${JSON.stringify(endpoint)} + url.slice(prefix.length); + if (new URL(url).hostname !== '127.0.0.1') throw new Error('Unexpected external request: ' + url); + return originalFetch(url, init); +};`, + ); + const command = async (args) => { + try { + return await exec(process.execPath, ['--import', preload, bin, ...args, '--json'], { + cwd: consumer, + env, + timeout: 30_000, + }); + } catch (error) { + if (typeof error.stdout !== 'string') throw error; + return error; + } + }; + const listed = await command(['plugins', 'list']); + assert.equal(listed.code, undefined, listed.stderr); + const rejected = await command([ + 'connect', + manifest.agentDevicePlugin.provider, + ...fixture.args, + '--state-dir', + path.join(scratch, 'state'), + ]); + assert.equal( + JSON.parse(rejected.stdout).error.code, + 'UNAUTHORIZED', + rejected.stdout + rejected.stderr, + ); + rejectCredentials = false; + const connected = await command([ + 'connect', + manifest.agentDevicePlugin.provider, + ...fixture.args, + '--state-dir', + path.join(scratch, 'state'), + ]); + assert.equal(connected.code, undefined, connected.stdout + connected.stderr); + assert.ok(requests.length > 0); + console.log( + `Packed ${manifest.name}: isolated install, host-recognized errors, and CLI connect passed.`, + ); +} finally { + await new Promise((resolve) => server.close(resolve)); + await fs.rm(scratch, { recursive: true, force: true }); +} diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 1064511136..ad54112871 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -108,7 +108,9 @@ export const UNRANKED_ZONES: ReadonlySet = new Set([ ...PLATFORMS.map((family) => `platform-${family}`), 'provider-webdriver', 'provider-limrun', + 'provider-doublespeed', 'proxy', + 'xml', ]); diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 8fd2181518..e046337d7a 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -151,6 +151,38 @@ test('readWorkspacePackages reads tracked manifests only', () => { ); }); +test('published ESM plugins declare bundled workspace build dependencies without runtime dependencies', () => { + const [plugin] = workspacePackagesFromManifests( + new Map([ + [ + 'packages/provider-example/package.json', + JSON.stringify({ + name: '@agent-device/example', + exports: { '.': { import: './dist/plugin.mjs' } }, + devDependencies: { '@agent-device/kernel': 'workspace:*', tsdown: '^0.21.0' }, + }), + ], + ]), + ); + assert.ok(plugin); + assert.equal( + plugin.exportTargets.get('@agent-device/example'), + 'packages/provider-example/dist/plugin.mjs', + ); + assert.deepEqual([...plugin.workspaceDependencies], ['@agent-device/kernel']); + assert.equal(plugin.externalDependencies.size, 0); + const sites = specifierSites( + 'packages/provider-example/src/plugin.ts', + "import { AppError } from '@agent-device/kernel/errors';", + ); + assert.deepEqual(checkPackageInternalSites(plugin, sites, [plugin, kernel]), []); + const undeclared = { ...plugin, workspaceDependencies: new Set() }; + const violations = checkPackageInternalSites(undeclared, sites, [undeclared, kernel]); + assert.equal(violations.length, 1); + assert.equal(violations[0]?.rule, 'R11 package-boundaries'); + assert.match(violations[0]?.message ?? '', /without declaring/); +}); + test('every workspace package façade names its exports explicitly (no bare `export *`)', () => { // #1574 built a hand-maintained pin table (`facade-symbols.ts`, 816 symbols across every // workspace-package façade) plus a ~200-line star-chain resolver (`readFacadeExports`) whose @@ -788,6 +820,7 @@ test('the real tree parses, declares, and passes R11', () => { assert.ok(providerWebDriverPackage, 'provider-webdriver package must exist'); assert.deepEqual([...providerWebDriverPackage.exportTargets.keys()].sort(), [ '@agent-device/provider-webdriver', + '@agent-device/provider-webdriver/plugin', '@agent-device/provider-webdriver/providers', ]); assert.deepEqual([...providerWebDriverPackage.workspaceDependencies].sort(), [ @@ -811,6 +844,25 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/kernel', '@agent-device/platform-android', ]); + const providerDoublespeedPackage = packages.find( + (pkg) => pkg.name === '@agent-device/doublespeed', + ); + assert.ok(providerDoublespeedPackage, 'provider-doublespeed package must exist'); + assert.deepEqual( + [...providerDoublespeedPackage.exportTargets.keys()], + [ + '@agent-device/doublespeed', + '@agent-device/doublespeed/connection-verification', + '@agent-device/doublespeed/package.json', + ], + ); + assert.deepEqual([...providerDoublespeedPackage.workspaceDependencies].sort(), [ + '@agent-device/capture-kit', + '@agent-device/contracts', + '@agent-device/kernel', + 'agent-device', + ]); + assert.equal(providerDoublespeedPackage.externalDependencies.size, 0); const rootExternalDependencies = rootExternalDependencyRanges(repoRoot); for (const pkg of packages) { for (const [name, range] of pkg.externalDependencies) { @@ -861,6 +913,11 @@ test('the real tree parses, declares, and passes R11', () => { rootWorkspaceDependencyNames(repoRoot).has('@agent-device/provider-limrun'), 'root must declare the provider-limrun workspace dependency', ); + assert.equal( + rootWorkspaceDependencyNames(repoRoot).has('@agent-device/doublespeed'), + true, + 'workspace tests resolve the plugin through its declared development dependency', + ); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/xml'), 'root must declare the xml workspace dependency', @@ -928,6 +985,7 @@ test('Node resolution enforces the exports map at runtime', () => { providerLimrunResolved.endsWith('packages/provider-limrun/src/index.ts'), providerLimrunResolved, ); + assert.ok(import.meta.resolve('@agent-device/doublespeed').endsWith('/src/index.ts')); const xmlResolved = import.meta.resolve('@agent-device/xml'); assert.ok(xmlResolved.endsWith('packages/xml/src/index.ts'), xmlResolved); const adScriptResolved = import.meta.resolve('@agent-device/ad-script'); diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index 6352717a12..64b2757183 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -102,14 +102,14 @@ export function workspacePackagesFromManifests( const manifest = JSON.parse(manifests.get(manifestFile)!) as { name?: string; private?: boolean; - exports?: Record; + exports?: Record; dependencies?: Record; devDependencies?: Record; }; if (!manifest.name) continue; const exportTargets = new Map(); for (const [subpath, target] of Object.entries(manifest.exports ?? {})) { - const targetFile = typeof target === 'string' ? target : target.default; + const targetFile = typeof target === 'string' ? target : (target.default ?? target.import); if (!targetFile) continue; exportTargets.set( path.posix.join(manifest.name, subpath), @@ -119,6 +119,7 @@ export function workspacePackagesFromManifests( const workspaceDependencyField = manifest.private === true ? 'dependencies' : 'devDependencies'; const workspaceDependencies = new Set( Object.entries(manifest[workspaceDependencyField] ?? {}) + .filter(([, range]) => range.startsWith('workspace:')) .map(([name]) => name), ); diff --git a/src/__tests__/cloud-connect-doublespeed.test.ts b/src/__tests__/cloud-connect-doublespeed.test.ts new file mode 100644 index 0000000000..9f216d923d --- /dev/null +++ b/src/__tests__/cloud-connect-doublespeed.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { connectCommand } from '../cli/commands/connection.ts'; +import type { AgentDeviceClient } from '../agent-device-client.ts'; +import { + readActiveConnectionState, + type RemoteConnectionState, +} from '../remote/remote-connection-state.ts'; +import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; +import { verifyDoublespeedConnection } from '@agent-device/doublespeed/connection-verification'; +import doublespeedPlugin from '@agent-device/doublespeed'; +import { createPluginHost } from '../plugins/host.ts'; +import { selectPlugin, pluginHome } from '../plugins/plugin.fixtures.ts'; +import { installedPlugins } from '../plugins/store.ts'; +import manifest from '@agent-device/doublespeed/package.json' with { type: 'json' }; +import type { PluginConnection } from '../plugins/connection.ts'; + +vi.mock('@agent-device/doublespeed/connection-verification', async (importOriginal) => ({ + ...(await importOriginal()), + verifyDoublespeedConnection: vi.fn(), +})); + +vi.mock('../plugins/load.ts', () => ({ + withPluginConnection: async ( + _provider: string, + env: NodeJS.ProcessEnv, + runConnection: (connection: PluginConnection) => Promise, + ) => { + const registration = await doublespeedPlugin(createPluginHost(env, undefined)); + try { + return await runConnection(registration.connection); + } finally { + await registration.runtime.shutdown(); + } + }, +})); +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); +const mockedVerifyDoublespeedConnection = vi.mocked(verifyDoublespeedConnection); +beforeEach(() => { + const { home, env } = pluginHome(); + selectPlugin(home, manifest.name, 'doublespeed', 'export default () => {};'); + const [plugin] = installedPlugins(env); + const manifestPath = path.join(plugin!.directory, 'package.json'); + const declared = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + declared.agentDevicePlugin.connection = manifest.agentDevicePlugin.connection; + fs.writeFileSync(manifestPath, JSON.stringify(declared)); + vi.stubEnv('AGENT_DEVICE_HOME', home); + mockedVerifyDoublespeedConnection.mockResolvedValue({ + provider: 'doublespeed', + service: 'Doublespeed', + verificationMessage: 'Credentials and iOS simulator access verified.', + device: { + status: 'deferred', + name: 'Provider-selected iOS simulator', + platform: 'ios', + }, + app: { + status: 'missing', + message: 'A new Doublespeed simulator does not have your app yet.', + }, + }); +}); + +test('connect doublespeed generates an iOS-only local daemon remote profile', async () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-doublespeed-'); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('DOUBLESPEED_API_KEY', 'dsx_test_key'); + + try { + await captureConnectStdout(async () => { + await connectCommand({ + positionals: ['doublespeed'], + flags: { + json: true, + help: false, + version: false, + stateDir, + tenant: 'team-a', + runId: 'run-a', + session: 'doublespeed-ios', + }, + client: {} as AgentDeviceClient, + }); + }); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.session, 'doublespeed-ios'); + assert.equal(state.leaseBackend, 'ios-instance'); + assert.equal(state.leaseProvider, 'doublespeed'); + assert.equal(state.platform, 'ios'); + assert.equal(state.daemon?.baseUrl, undefined); + assert.match( + state.remoteConfigPath, + /remote-connections\/generated\/doublespeed-[a-f0-9]{16}\.json$/, + ); + assert.deepEqual(readGeneratedConfigKeys(state.remoteConfigPath), [ + 'clientId', + 'daemonTransport', + 'leaseBackend', + 'leaseProvider', + 'platform', + 'runId', + 'session', + 'sessionIsolation', + 'stateDir', + 'target', + 'tenant', + ]); + assert.equal(mockedVerifyDoublespeedConnection.mock.calls.length, 1); + assert.equal(mockedVerifyDoublespeedConnection.mock.calls[0]?.[0]?.apiKey, 'dsx_test_key'); + + await assert.rejects( + connectCommand({ + positionals: ['doublespeed'], + flags: { + json: true, + help: false, + version: false, + stateDir, + platform: 'android', + session: 'doublespeed-android', + force: true, + }, + client: {} as AgentDeviceClient, + }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +async function captureConnectStdout(run: () => Promise): Promise { + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await run(); + } finally { + write.mockRestore(); + } +} +function readRequiredActiveState(stateDir: string): RemoteConnectionState { + const state = readActiveConnectionState({ stateDir }); + assert.ok(state); + return state; +} +function readGeneratedConfigKeys(configPath: string): string[] { + return Object.keys(JSON.parse(fs.readFileSync(configPath, 'utf8'))); +} diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index b33c1130b3..56993534c4 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -33,6 +33,7 @@ vi.mock('../provider-webdriver.ts', () => ({ })); afterEach(() => { + vi.unstubAllEnvs(); vi.clearAllMocks(); vi.unstubAllGlobals(); }); diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index 9971c34546..e9a52c7879 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -85,6 +85,25 @@ test('default provider runtimes load Limrun when a Limrun API key is configured' await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); }); +test('credentials alone do not enable the optional Doublespeed plugin', async () => { + const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ + DOUBLESPEED_API_KEY: 'dsx_test_key', + }); + + const doublespeed = runtimes.find((runtime) => runtime.provider === 'doublespeed'); + assert.equal(doublespeed, undefined); + assert.equal( + runtimes.some((runtime) => runtime.provider === 'limrun'), + false, + ); + assertPlatformModuleCoverage(runtimes, platformModules); + assert.equal( + platformModules.some(({ runtime }) => runtime === doublespeed), + false, + ); + await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); +}); + test('default provider runtimes load Limrun for an existing instance without an API key', async () => { const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', @@ -94,6 +113,7 @@ test('default provider runtimes load Limrun for an existing instance without an const limrun = runtimes.find((runtime) => runtime.provider === 'limrun'); assert.ok(limrun); assertPlatformModuleCoverage(runtimes, platformModules, [limrun]); + await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); }); diff --git a/src/cli.ts b/src/cli.ts index 0bdbb3de5c..9499167c3b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,10 +25,8 @@ import { type AgentDeviceClientConfig, type AgentDeviceDaemonTransport, } from './agent-device-client.ts'; -import { materializeRemoteConnectionForCommand } from './cli/commands/connection-runtime.ts'; import { tryRunClientBackedCommand } from './cli/commands/router.ts'; import { runAgentCdpCommand } from './cli/commands/agent-cdp.ts'; -import { runReactDevtoolsCommand } from './cli/commands/react-devtools.ts'; import { readCliBatchStepsJson } from './commands/batch/batch-steps.ts'; import { createRequestId, @@ -353,6 +351,7 @@ async function resolveRunContextOrExit( } async function runReactDevtoolsCli(ctx: CliRunContext, deps: CliDeps): Promise { + const { runReactDevtoolsCommand } = await import('./cli/commands/react-devtools.ts'); const { daemonAuthToken, ...directRequestFlags } = ctx.effectiveFlags; return await runReactDevtoolsCommand(ctx.positionals, { flags: { @@ -408,6 +407,8 @@ async function resolveRemoteContext(ctx: CliRunContext, deps: CliDeps): Promise< const materializationClient = createAgentDeviceClient(buildClientConfig(ctx), { transport: createClientDaemonTransport(deps.sendToDaemon), }); + const { materializeRemoteConnectionForCommand } = + await import('./cli/commands/connection-runtime.ts'); const materialized = await materializeRemoteConnectionForCommand({ command: ctx.command, flags: ctx.effectiveFlags, diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 28ca37743e..79f9ca159e 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -14,7 +14,15 @@ import { resolveLimrunConnectProfile } from './limrun-profile.ts'; import { readLimrunCredentials } from '../../provider-limrun-credentials.ts'; import { resolveProxyConnectProfile } from './proxy-profile.ts'; import { profileToCliFlags } from '../remote-config-flags.ts'; -import { isConnectProviderName, type ConnectProvider } from './provider-policy.ts'; +import { + isConnectProviderName, + type ConnectProvider, + type BuiltinConnectProvider, +} from './provider-policy.ts'; +import { withPluginConnection } from '../../plugins/load.ts'; +import { readMetroProfileFields } from './profile-fields.ts'; +import { buildConnectClientId } from './client-id.ts'; +import { persistAndResolveGeneratedProfile } from './generated-config.ts'; type ConnectProfile = { flags: CliFlags; remoteConfigPath: string }; type ResolvedConnectProfile = ConnectProfile & { provider?: ConnectProvider }; @@ -75,7 +83,7 @@ const CONNECT_PROVIDER_ADAPTERS = { resolve: resolveLimrunConnectProfile, verify: verifyLimrun, }, -} satisfies Record; +} satisfies Record; export async function resolveConnectProviderProfile(options: { provider?: ConnectProvider; @@ -100,17 +108,52 @@ export async function resolveConnectProviderProfile(options: { remoteConfig: resolved.resolvedPath, }, remoteConfigPath: resolved.resolvedPath, - ...(isConnectProviderName(leaseProvider) ? { provider: leaseProvider } : {}), + ...(isConnectProviderName(leaseProvider, env) ? { provider: leaseProvider } : {}), }; } const provider = options.provider ?? (shouldUseProxyConnectShortcut(options.flags) ? 'proxy' : 'cloud'); - const profile = await CONNECT_PROVIDER_ADAPTERS[provider].resolve({ + const context = { flags: options.flags, stateDir: options.stateDir, cwd, env, - }); + }; + const adapter = (CONNECT_PROVIDER_ADAPTERS as Partial>)[ + provider + ]; + const profile = adapter + ? await adapter.resolve(context) + : await withPluginConnection(provider, env, async (connection) => { + const resolved = await connection.resolve(context); + if (resolved.profile.leaseProvider !== provider) + throw new AppError( + 'INVALID_ARGS', + 'Plugin connection profile must select its declared provider', + ); + const clientId = buildConnectClientId( + provider, + context.stateDir, + context.flags.session, + resolved.profile.device, + ); + return persistAndResolveGeneratedProfile({ + ...context, + ...resolved, + provider, + profile: { + tenant: context.flags.tenant ?? provider, + sessionIsolation: context.flags.sessionIsolation ?? 'tenant', + runId: context.flags.runId ?? `${provider}-${clientId}`, + clientId, + target: context.flags.target ?? 'mobile', + session: context.flags.session, + stateDir: context.stateDir, + ...readMetroProfileFields(context.flags), + ...resolved.profile, + }, + }); + }); return { ...profile, provider }; } @@ -125,10 +168,20 @@ export async function verifyResolvedConnectProvider( 'Remote connection profile loaded. Access is checked by the first remote command.', }; } - return await CONNECT_PROVIDER_ADAPTERS[resolved.provider].verify({ + const context = { flags: resolved.flags, env: process.env, - }); + }; + const adapter = (CONNECT_PROVIDER_ADAPTERS as Partial>)[ + resolved.provider + ]; + return adapter + ? await adapter.verify(context) + : await withPluginConnection( + resolved.provider, + context.env, + async (connection) => await connection.verify(context), + ); } async function verifyBrowserStack( diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index ebb86bc6d4..db4f872186 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -1,24 +1,26 @@ +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; import { CLOUD_WEBDRIVER_PROVIDERS, isCloudWebDriverProviderName, type CloudWebDriverKnownProviderName, } from '@agent-device/provider-webdriver/providers'; +import { pluginConnectionCapabilities, pluginConnectionNames } from '../../plugins/connection.ts'; export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; -export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; +import { RESERVED_PLUGIN_PROVIDERS as BUILTIN_CONNECT_PROVIDERS } from '../../plugins/manifest.ts'; +export type BuiltinConnectProvider = (typeof BUILTIN_CONNECT_PROVIDERS)[number]; +export type ConnectProvider = BuiltinConnectProvider | (string & {}); -export type ConnectionProviderCapabilities = { - leaseKind: 'proxy' | 'direct-device-provider' | 'remote-provider'; - requiresAppAttachment: boolean; - requiresRemoteDaemon: boolean; - supportsArtifacts: boolean; - supportsDeferredAppSelection: boolean; - supportsDirectPortReverse: boolean; - usesCloudWebDriverLease: boolean; -}; - -export function isConnectProviderName(value: string | undefined): value is ConnectProvider { - return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); +export function isConnectProviderName( + value: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): value is ConnectProvider { + return ( + value === 'cloud' || + value === 'proxy' || + isDirectDeviceConnectProvider(value) || + pluginConnectionCapabilities(value, env) !== undefined + ); } function isDirectDeviceConnectProvider( @@ -28,13 +30,7 @@ function isDirectDeviceConnectProvider( } export function connectProviderNamesForError(): string { - return [ - 'cloud', - 'proxy', - CLOUD_WEBDRIVER_PROVIDERS.browserStack, - CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - 'limrun', - ].join(', '); + return [...BUILTIN_CONNECT_PROVIDERS, ...pluginConnectionNames()].join(', '); } export function connectionProviderCapabilities( @@ -42,6 +38,10 @@ export function connectionProviderCapabilities( ): ConnectionProviderCapabilities { const directDeviceProvider = isDirectDeviceConnectProvider(provider); const cloudWebDriver = isCloudWebDriverProviderName(provider); + if (!directDeviceProvider && provider !== 'cloud' && provider !== 'proxy') { + const plugin = pluginConnectionCapabilities(provider); + if (plugin) return plugin; + } return { leaseKind: provider === 'proxy' diff --git a/src/commands/capture/runtime/snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts index 0e1bd4d683..e1ab34e52f 100644 --- a/src/commands/capture/runtime/snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -76,6 +76,7 @@ test('runtime snapshot upgrades an absent truncation flag only for producers tha 'simulator-ax-bridge': false, 'appium-source': undefined, 'limrun-ios-tree': undefined, + 'doublespeed-ios-tree': undefined, } as const satisfies Record; for (const producer of Object.keys(expected) as IosSnapshotProducer[]) { diff --git a/src/commands/schema/cli-help-topics.test.ts b/src/commands/schema/cli-help-topics.test.ts index 2364272b00..a4bbc1b149 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -401,6 +401,8 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /BrowserStack: agent-device connect browserstack/); assert.match(help, /AWS Device Farm: agent-device connect aws-device-farm/); assert.match(help, /Limrun: agent-device connect limrun/); + assert.match(help, /Doublespeed: agent-device connect doublespeed/); + assert.match(help, /It does not create a simulator/); assert.match(help, /It does not create an App Automate session/); assert.match(help, /It does not create a remote access session/); assert.match(help, /It does not create an instance/); @@ -414,6 +416,9 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /connect browserstack --platform android/); assert.match(help, /connect aws-device-farm --platform android/); assert.match(help, /connect limrun --platform android/); + assert.match(help, /plugins add @agent-device\/doublespeed/); + assert.match(help, /connect doublespeed --platform ios/); + assert.match(help, /Doublespeed uses DOUBLESPEED_API_KEY/); assert.match(help, /AWS_REGION=us-west-2 AWS_ACCESS_KEY_ID/); assert.match(help, /AWS Device Farm uses the AWS CLI credential chain/); assert.match(help, /Prefer short-lived AWS role credentials in CI/); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 0dc5d845df..80f58156a6 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -582,16 +582,18 @@ Providers: BrowserStack: agent-device connect browserstack verifies credentials, the exact device, and a bs:// app reference, then stores a local provider profile. It does not create an App Automate session. AWS Device Farm: agent-device connect aws-device-farm verifies credentials and the exact project, device, and optional app upload, then stores a local provider profile. It does not create a remote access session. Limrun: agent-device connect limrun verifies access to the selected iOS or Android instance service, then stores a local provider profile. It does not create an instance. + Doublespeed: agent-device connect doublespeed verifies access to the Doublespeed iOS simulator service, then stores a local provider profile. It does not create a simulator. After direct-provider connect: Read the printed Device, App, Next, and workflow-note lines. They are also available as verification/device/app/liveSession/nextSteps/notes in --json output. BrowserStack and AWS Device Farm create the hosted session on open. open needs the installed package or bundle identifier, not the app artifact name or ARN. Before provider allocation, apps lists compatible uploaded app assets without creating an instance when the selected provider exposes a catalog. open creates the instance with that asset, resolves its installed app id, and launches it. install remains available when the app comes from a fresh local path or URL. + A new Doublespeed simulator has no user app. Run install first, then open the installed id. AWS Device Farm cannot install after allocation. If connect reports no attached app, run its printed reconnect command, which includes --session --force, before open. Do not run devices as a pre-open catalog probe for direct providers; it can allocate the deferred provider session. Limrun is the exception for apps: before allocation it lists uploaded assets for the selected platform. Device cloud interfaces: - CLI is the canonical bootstrap path: connect limrun/browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. + CLI is the canonical bootstrap path: connect limrun/doublespeed/browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. JavaScript can skip persisted connect state by passing leaseProvider plus provider fields to createAgentDeviceClient or per-command options. MCP exposes operational tools such as open, snapshot, click, close, and artifacts. It does not expose connect/disconnect; run CLI connect first in the same state dir before relying on MCP tools. @@ -653,6 +655,19 @@ Limrun direct-device flow: agent-device close agent-device disconnect +Doublespeed direct-simulator flow: + agent-device plugins add @agent-device/doublespeed + DOUBLESPEED_API_KEY=... + agent-device connect doublespeed --platform ios + + After adding or updating the plugin, close active sessions and run agent-device daemon stop with the same --state-dir before continuing. + Doublespeed creates remote iOS simulators only. Do not pass local device selectors such as --udid, --serial, or --device; set DOUBLESPEED_DEVICE to choose the simulator model. + agent-device install com.example.app ./Example.app + agent-device open com.example.app + agent-device snapshot -i + agent-device close + agent-device disconnect + Local profile flow: agent-device connect --remote-config ./remote-config.json agent-device open com.example.app @@ -669,14 +684,15 @@ Rules: Use connect without --remote-config when the cloud control plane owns the connection profile. Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. - Use Limrun, BrowserStack, and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. - Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY, or the LIM_*_INSTANCE_* variables for an existing instance. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. + Use Limrun, BrowserStack, and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. Doublespeed uses a local provider profile the same way. + Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY, or the LIM_*_INSTANCE_* variables for an existing instance. Doublespeed uses DOUBLESPEED_API_KEY. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. A local daemon keeps the Limrun and BrowserStack credentials it started with. When the shell holds different ones, the first command that allocates a lease refuses with reason provider-credentials-changed; run agent-device daemon stop with the same --state-dir, then rerun the command. A shell that sets none of them uses the daemon's. A daemon with an HTTP auth hook serves remote callers and does not compare. Direct-provider connect performs read-only provider calls and saves active connection state only after verification succeeds. It never creates a device, instance, App Automate session, or AWS remote access session. connect without --session always creates a fresh remote session and prints that session in its next-step commands. Concurrent callers must pass the returned --session on every command; the ambient active connection is only a single-workflow convenience. To replace an existing connection, pass its returned session explicitly with --session --force. --force without --session creates another fresh session and does not release or overwrite an unrelated active connection. Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not Limrun API keys or instance tokens, BrowserStack access keys, or AWS credentials. - Limrun Android supports direct ADB port reverse for local Metro. Limrun iOS requires a public Metro/React DevTools URL because it cannot reach local host ports directly. + Limrun Android supports direct ADB port reverse for local Metro. Limrun iOS and Doublespeed require a public Metro/React DevTools URL because they cannot reach local host ports directly. + After closing a device cloud session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. connect proxy stores the connection profile and client identity. Proxy device leases are acquired on open and expire after five minutes without commands; devices may inspect proxy inventory without allocating. Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index 25e68ffbd1..797d0c5924 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -75,7 +75,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'Configure remote access without allocating a device. Direct providers validate credentials/resources before saving state and print the exact device/app preparation needed before open. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', }, usageOverride: - 'connect [cloud|proxy|limrun|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', + 'connect [provider] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', usageFlags: [], listUsageOverride: 'connect', positionalArgs: ['provider?'], diff --git a/src/plugins/connection.ts b/src/plugins/connection.ts new file mode 100644 index 0000000000..295f8f8cbe --- /dev/null +++ b/src/plugins/connection.ts @@ -0,0 +1,34 @@ +import type { CliFlags } from '@agent-device/contracts/command'; +import type { + ConnectionProviderCapabilities, + ProviderConnectionVerification, +} from '@agent-device/contracts/remote'; +import type { EnvMap } from '@agent-device/kernel/source-value'; +import type { RemoteConfigProfile } from '../remote/remote-config-schema.ts'; +import { installedPlugins } from './store.ts'; + +export type PluginConnection = Readonly<{ + resolve(context: { flags: CliFlags; stateDir: string; cwd: string; env: EnvMap }): + | Promise<{ + profile: RemoteConfigProfile; + extraFlags?: Partial; + }> + | { profile: RemoteConfigProfile; extraFlags?: Partial }; + verify(context: { flags: CliFlags; env: EnvMap }): Promise; +}>; + +export function pluginConnectionCapabilities( + provider: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): ConnectionProviderCapabilities | undefined { + return provider === undefined + ? undefined + : installedPlugins(env).find((plugin) => plugin.agentDevicePlugin.provider === provider) + ?.agentDevicePlugin.connection; +} + +export function pluginConnectionNames(env: NodeJS.ProcessEnv = process.env): string[] { + return installedPlugins(env) + .filter((plugin) => plugin.agentDevicePlugin.connection) + .map((plugin) => plugin.agentDevicePlugin.provider); +} diff --git a/src/plugins/host.ts b/src/plugins/host.ts new file mode 100644 index 0000000000..98961a65c2 --- /dev/null +++ b/src/plugins/host.ts @@ -0,0 +1,36 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { execFailureDetails, runCmd } from '@agent-device/host-kit/command'; +import { readVersion } from '@agent-device/host-kit/version'; +import type { ProviderPluginHost } from '../sdk/plugins.ts'; + +export function createPluginHost( + env: NodeJS.ProcessEnv, + options: Record | undefined, +): ProviderPluginHost { + return Object.freeze({ + env: Object.freeze({ ...env }), + options: Object.freeze({ ...options }), + clientVersion: readVersion(), + createError: (code, message, details) => new AppError(code, message, details), + apple: Object.freeze({ + archiveDirectory: async ({ sourceDirectory, entryName, archivePath }) => { + const args = ['-qr', archivePath, entryName]; + const result = await runCmd('zip', args, { cwd: sourceDirectory, timeoutMs: 120_000 }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to package iOS app for provider install', { + command: ['zip', ...args].join(' '), + ...execFailureDetails(result), + }); + } + }, + resolveAppAlias: async (app) => { + const { resolveIosAppAlias } = await import('@agent-device/platform-apple/app-resolution'); + return await resolveIosAppAlias(app); + }, + readBundleAppName: async (appPath) => { + const { readIosBundleInfo } = await import('@agent-device/platform-apple/install-artifact'); + return (await readIosBundleInfo(appPath)).appName; + }, + }), + } satisfies ProviderPluginHost); +} diff --git a/src/plugins/load.test.ts b/src/plugins/load.test.ts index 64afbc9f44..4026c62558 100644 --- a/src/plugins/load.test.ts +++ b/src/plugins/load.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { loadProviderPlugins } from './load.ts'; +import { loadProviderPlugins, withPluginConnection } from './load.ts'; import { pluginHome, selectPlugin, registrationSource } from './plugin.fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { ProviderPluginHost } from '../sdk/plugins.ts'; @@ -62,3 +62,52 @@ test('cleanup throwing synchronously preserves the factory failure', async () => await assert.rejects(loadProviderPlugins(env, []), /factory failed/); assert.ok(fs.existsSync(marker)); }); + +test('connection callbacks run from the installed plugin and always release runtimes', async () => { + const { home, env } = pluginHome(); + const marker = path.join(home, 'shutdown'); + const source = registrationSource('example', marker).replace( + 'platformModule:', + "connection: { resolve: () => ({ profile: { leaseProvider: 'example', platform: 'android' } }), verify: async () => { throw host.createError('COMMAND_FAILED', 'verification failed'); } }, platformModule:", + ); + selectPlugin(home, 'example', 'example', source); + selectPlugin(home, 'unrelated', 'unrelated', 'throw new Error("unrelated plugin evaluated");'); + const profile = await withPluginConnection( + 'example', + env, + async (connection) => + await connection.resolve({ + flags: { json: false, help: false, version: false }, + stateDir: home, + cwd: home, + env, + }), + ); + assert.equal(profile.profile.platform, 'android'); + assert.ok(fs.existsSync(marker)); + fs.unlinkSync(marker); + await assert.rejects( + withPluginConnection( + 'example', + env, + async (connection) => + await connection.verify({ flags: { json: false, help: false, version: false }, env }), + ), + { code: 'COMMAND_FAILED' }, + ); + assert.ok(fs.existsSync(marker)); +}); + +test('WebDriver plugins use the shared engine and refuse mismatched providers', async () => { + const { home, env } = pluginHome(); + const source = + "export default () => ({ webDriver: { provider: 'example', endpoint: 'http://127.0.0.1/', platform: 'android', deviceName: 'example' } });"; + selectPlugin(home, 'example', 'example', source); + const [registration] = await loadProviderPlugins(env, []); + assert.equal(registration!.runtime.provider, 'example'); + assert.equal(registration!.platformModule.owner.provider, 'example'); + await registration!.runtime.shutdown(); + const other = pluginHome(); + selectPlugin(other.home, 'example', 'wrong', source); + await assert.rejects(loadProviderPlugins(other.env, []), { code: 'INVALID_ARGS' }); +}); diff --git a/src/plugins/load.ts b/src/plugins/load.ts index 024b7a9573..b9e93d471b 100644 --- a/src/plugins/load.ts +++ b/src/plugins/load.ts @@ -4,60 +4,36 @@ import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { PlatformRuntimeProviderModule } from '@agent-device/contracts/platform-runtime-operations'; import type { ProviderPluginHost } from '../sdk/plugins.ts'; import { installedPlugins } from './store.ts'; -import { resolvePluginEntry, assertUniquePluginProviders } from './manifest.ts'; +import { + resolvePluginEntry, + assertUniquePluginProviders, + RESERVED_PLUGIN_PROVIDERS, +} from './manifest.ts'; +import { createPluginHost } from './host.ts'; +import type { PluginConnection } from './connection.ts'; +import type { WebDriverPluginOptions } from '../sdk/plugin-webdriver.ts'; type ProviderPluginRegistration = Readonly<{ runtime: ProviderDeviceRuntime; platformModule: PlatformRuntimeProviderModule; + connection?: PluginConnection; }>; export async function loadProviderPlugins( env: NodeJS.ProcessEnv, reservedProviders: readonly string[], + onlyProvider?: string, ): Promise { const plugins = installedPlugins(env); assertUniquePluginProviders(plugins, reservedProviders); const registrations: ProviderPluginRegistration[] = []; try { - for (const plugin of plugins) { - const module = await import( - pathToFileURL(resolvePluginEntry(plugin.directory, plugin.agentDevicePlugin.entry)).href - ); - if (typeof module.default !== 'function') - throw new AppError('INVALID_ARGS', `Plugin must export a default factory: ${plugin.name}`); - const registration = await ( - module.default as ( - host: ProviderPluginHost, - ) => ProviderPluginRegistration | Promise - )( - Object.freeze({ - env: Object.freeze({ ...env }), - options: Object.freeze({ ...plugin.selection.options }), - createError: (code, message, details) => new AppError(code, message, details), - }), - ); - if (!registration?.runtime || typeof registration.runtime.shutdown !== 'function') { - throw new AppError('INVALID_ARGS', `Plugin must return a provider runtime: ${plugin.name}`); - } + for (const plugin of plugins.filter( + (plugin) => onlyProvider === undefined || plugin.agentDevicePlugin.provider === onlyProvider, + )) { + const registration = await instantiateProviderPlugin(plugin, env); registrations.push(registration); - if ( - registration.runtime.provider !== plugin.agentDevicePlugin.provider || - typeof registration.runtime.ownsDevice !== 'function' || - typeof registration.runtime.getInteractor !== 'function' || - typeof registration.runtime.deviceInventoryProvider !== 'function' || - !registration.runtime.leaseLifecycle || - typeof registration.runtime.leaseLifecycle !== 'object' || - registration.platformModule?.owner?.kind !== 'provider-runtime' || - registration.platformModule.owner.provider !== registration.runtime.provider || - typeof registration.platformModule.owner.instance !== 'string' || - registration.platformModule.owner.instance.trim().length === 0 || - typeof registration.platformModule.loadRuntime !== 'function' - ) { - throw new AppError( - 'INVALID_ARGS', - `Plugin runtime owner does not match its declaration: ${plugin.name}`, - ); - } + validateProviderPlugin(registration, plugin); } return registrations; } catch (error) { @@ -65,3 +41,107 @@ export async function loadProviderPlugins( throw error; } } + +export async function withPluginConnection( + provider: string, + env: NodeJS.ProcessEnv, + runConnection: (connection: PluginConnection) => Promise, +): Promise { + const registrations = await loadProviderPlugins(env, RESERVED_PLUGIN_PROVIDERS, provider); + try { + const connection = registrations.find( + (entry) => entry.runtime.provider === provider, + )?.connection; + if (!connection) + throw new AppError('INVALID_ARGS', `Plugin does not register connect: ${provider}`); + return await runConnection(connection); + } finally { + await Promise.allSettled(registrations.map(async ({ runtime }) => await runtime.shutdown())); + } +} + +async function instantiateProviderPlugin( + plugin: ReturnType[number], + env: NodeJS.ProcessEnv, +): Promise { + const module = await import( + pathToFileURL(resolvePluginEntry(plugin.directory, plugin.agentDevicePlugin.entry)).href + ); + if (typeof module.default !== 'function') + throw new AppError('INVALID_ARGS', `Plugin must export a default factory: ${plugin.name}`); + const host = createPluginHost(env, plugin.selection.options); + const result = await ( + module.default as ( + host: ProviderPluginHost, + ) => + | ProviderPluginRegistration + | { webDriver: WebDriverPluginOptions; connection?: PluginConnection } + | Promise< + | ProviderPluginRegistration + | { webDriver: WebDriverPluginOptions; connection?: PluginConnection } + > + )(host); + let registration: ProviderPluginRegistration; + if (result && 'webDriver' in result) { + if (result.webDriver?.provider !== plugin.agentDevicePlugin.provider) { + throw new AppError( + 'INVALID_ARGS', + `WebDriver plugin provider does not match its declaration: ${plugin.name}`, + ); + } + const { createCloudWebDriverRuntime } = await import('@agent-device/provider-webdriver/plugin'); + const runtime = await createCloudWebDriverRuntime({ + ...result.webDriver, + clientVersion: host.clientVersion, + }); + registration = { + runtime, + platformModule: runtime.platformRuntimeModule, + connection: result.connection, + }; + } else registration = result; + if (!registration?.runtime || typeof registration.runtime.shutdown !== 'function') { + throw new AppError('INVALID_ARGS', `Plugin must return a provider runtime: ${plugin.name}`); + } + return registration; +} + +function validateProviderPlugin( + registration: ProviderPluginRegistration, + plugin: ReturnType[number], +): void { + if ( + registration.runtime.provider !== plugin.agentDevicePlugin.provider || + !hasProviderFacets(registration.runtime) || + !matchesRuntimeOwner(registration) || + (plugin.agentDevicePlugin.connection && + (typeof registration.connection?.resolve !== 'function' || + typeof registration.connection?.verify !== 'function')) + ) { + throw new AppError( + 'INVALID_ARGS', + `Plugin runtime owner does not match its declaration: ${plugin.name}`, + ); + } +} + +function matchesRuntimeOwner(registration: ProviderPluginRegistration): boolean { + const owner = registration.platformModule?.owner; + return ( + owner?.kind === 'provider-runtime' && + owner.provider === registration.runtime.provider && + typeof owner.instance === 'string' && + owner.instance.trim().length > 0 && + typeof registration.platformModule.loadRuntime === 'function' + ); +} + +function hasProviderFacets(runtime: ProviderDeviceRuntime): boolean { + return ( + runtime.leaseLifecycle !== null && + typeof runtime.leaseLifecycle === 'object' && + (['ownsDevice', 'getInteractor', 'deviceInventoryProvider'] as const).every( + (method) => typeof runtime[method] === 'function', + ) + ); +} diff --git a/src/plugins/manifest.test.ts b/src/plugins/manifest.test.ts index a7763c828a..a414e7bd94 100644 --- a/src/plugins/manifest.test.ts +++ b/src/plugins/manifest.test.ts @@ -33,3 +33,35 @@ test('manifest refuses traversal and symlink entries outside the installed packa fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify(manifest)); assert.throws(() => readPluginManifest(directory), { code: 'INVALID_ARGS' }); }); + +test('connection metadata admits local providers and rejects malformed or remote policies', () => { + const { home } = pluginHome(); + const directory = writePlugin(home); + const file = path.join(directory, 'package.json'); + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); + const connection = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: true, + requiresRemoteDaemon: false, + supportsArtifacts: true, + supportsDeferredAppSelection: false, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: true, + }; + const read = (policy: unknown) => { + manifest.agentDevicePlugin.connection = policy; + fs.writeFileSync(file, JSON.stringify(manifest)); + return readPluginManifest(directory); + }; + assert.deepEqual(read(connection).agentDevicePlugin.connection, connection); + for (const invalid of [ + null, + {}, + { ...connection, leaseKind: 'remote-daemon' }, + { ...connection, requiresRemoteDaemon: true }, + { ...connection, supportsArtifacts: 'true' }, + { ...connection, supportsDeferredAppSelection: undefined }, + ]) { + assert.throws(() => read(invalid), { code: 'INVALID_ARGS' }); + } +}); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 44b6d2a1c9..93fd645c12 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -1,19 +1,35 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; + +import { CLOUD_WEBDRIVER_PROVIDERS } from '@agent-device/provider-webdriver/providers'; + +export const RESERVED_PLUGIN_PROVIDERS = [ + 'cloud', + 'proxy', + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + 'limrun', +] as const; const PROVIDER_PLUGIN_API_VERSION = 1; type PluginManifest = { name: string; version: string; - agentDevicePlugin: { apiVersion: number; provider: string; entry: string }; + agentDevicePlugin: { + apiVersion: number; + provider: string; + entry: string; + connection?: ConnectionProviderCapabilities; + }; }; export function assertUniquePluginProviders( plugins: readonly PluginManifest[], reserved: readonly string[], ): void { - const providers = new Set(reserved); + const providers = new Set([...RESERVED_PLUGIN_PROVIDERS, ...reserved]); for (const plugin of plugins) { const provider = plugin.agentDevicePlugin.provider; if (providers.has(provider)) @@ -53,6 +69,28 @@ export function readPluginManifest(directory: string): PluginManifest { }); } resolvePluginEntry(directory, declaration.entry); + if (declaration.connection !== undefined) { + const policy = declaration.connection; + if ( + !policy || + typeof policy !== 'object' || + policy.leaseKind !== 'direct-device-provider' || + [ + 'requiresAppAttachment', + 'requiresRemoteDaemon', + 'supportsArtifacts', + 'supportsDeferredAppSelection', + 'supportsDirectPortReverse', + 'usesCloudWebDriverLease', + ].some((key) => typeof policy[key as keyof ConnectionProviderCapabilities] !== 'boolean') || + policy.requiresRemoteDaemon + ) { + throw new AppError( + 'INVALID_ARGS', + 'Plugin connection must declare local provider capabilities', + ); + } + } return manifest as PluginManifest; } diff --git a/src/sdk/plugin-webdriver.ts b/src/sdk/plugin-webdriver.ts new file mode 100644 index 0000000000..1f43c9f016 --- /dev/null +++ b/src/sdk/plugin-webdriver.ts @@ -0,0 +1,3 @@ +import type { CloudWebDriverRuntimeOptions } from '@agent-device/provider-webdriver/plugin'; + +export type WebDriverPluginOptions = Omit; diff --git a/src/sdk/plugins.ts b/src/sdk/plugins.ts index 188481315e..753ce229b0 100644 --- a/src/sdk/plugins.ts +++ b/src/sdk/plugins.ts @@ -3,5 +3,15 @@ import type { AppError, AppErrorCode, AppErrorDetails } from '@agent-device/kern export type ProviderPluginHost = Readonly<{ env: Readonly>; options: Readonly>; + clientVersion: string; + apple: Readonly<{ + archiveDirectory(options: { + sourceDirectory: string; + entryName: string; + archivePath: string; + }): Promise; + resolveAppAlias(app: string): Promise; + readBundleAppName(appPath: string): Promise; + }>; createError(code: AppErrorCode, message: string, details?: AppErrorDetails): AppError; }>; diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index 0253ca03e6..c76c99197f 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -228,6 +228,10 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi const pluginTypes = fs.readFileSync(path.join(installedPackageRoot, 'dist/src/plugins.d.ts')); assert.ok(pluginTypes.length < 1024); + const webDriverPluginTypes = fs.readFileSync( + path.join(installedPackageRoot, 'dist/src/plugins/webdriver.d.ts'), + ); + assert.ok(webDriverPluginTypes.length < 5120); assert.match(pluginTypes.toString(), /export \{ ProviderPluginHost \}/); metroPort = await listenOnLoopback(metroServer); t.after(async () => { @@ -327,6 +331,7 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi }, './metro': (mod) => mod.buildBundleUrl('https://public.example.test', 'ios'), './plugins': (mod) => Object.keys(mod).length === 0, + './plugins/webdriver': (mod) => Object.keys(mod).length === 0, './remote-config': (mod) => typeof mod, './selectors': (mod) => mod.isSelectorToken('||') && @@ -404,6 +409,7 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi './io': 'function', './limrun': 'limrun', './plugins': true, + './plugins/webdriver': true, // Type-only subpath: resolving the module from the packed exports map is // the entire runtime check. './remote-config': 'object', diff --git a/tsconfig.json b/tsconfig.json index b77afe95cb..2dadc88290 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,10 @@ "erasableSyntaxOnly": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true, - "types": ["node"] + "types": ["node"], + "paths": { + "agent-device/plugins": ["./src/sdk/plugins.ts"] + } }, "include": [ "src", diff --git a/tsdown.config.ts b/tsdown.config.ts index 7131e46e79..163bb4c75d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -102,6 +102,7 @@ export default defineConfig({ 'android-adb': 'src/sdk/android-adb.ts', limrun: 'src/sdk/limrun.ts', plugins: 'src/sdk/plugins.ts', + 'plugins/webdriver': 'src/sdk/plugin-webdriver.ts', contracts: 'src/sdk/contracts.ts', selectors: 'src/sdk/selectors.ts', finders: 'src/sdk/finders.ts', diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index a951d4384a..71fa5ae64b 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -122,6 +122,11 @@ "type": "custom-link", "label": "Limrun", "link": "/docs/limrun" + }, + { + "type": "custom-link", + "label": "Doublespeed", + "link": "/docs/doublespeed" } ] }, diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 6261a4949b..01f8833b60 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -98,6 +98,8 @@ Supported public entry points for Node consumers: - `runtime.getDeviceSession(device)` - types: `LimrunRuntimeOptions`, `LimrunDeviceSession`, `LimrunAndroidDeviceSession`, `LimrunIosDeviceSession`, `LimrunIosCommandExecution` +- `agent-device/plugins/webdriver` + - experimental `WebDriverPluginOptions` for providers using the shared engine. - `agent-device/plugins` - experimental factory context: `ProviderPluginHost`; see [provider plugins](./plugins.md). - `agent-device/ai-sdk` diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index d81d679bc0..f80b35f531 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -10,8 +10,9 @@ Use a device cloud or farm when an agent needs to automate a hosted mobile devic - [BrowserStack](/docs/browserstack): Android and iOS App Automate sessions over WebDriver. - [AWS Device Farm](/docs/aws-device-farm): Android and iOS remote-access sessions through AWS. - [Limrun](/docs/limrun): direct iOS simulator and Android emulator instances. +- [Doublespeed](/docs/doublespeed): direct iOS simulator sessions on a Mac mini fleet. -All three integrations run through the local `agent-device` daemon. `connect` checks the credentials and configuration, then saves non-secret connection state. It does not allocate a device. BrowserStack and AWS Device Farm allocate a hosted session on `open`. Limrun allocates an instance on the first device command, such as `install` or `open`. +All four integrations run through the local `agent-device` daemon. `connect` checks the credentials and configuration, then saves non-secret connection state. It does not allocate a device. BrowserStack and AWS Device Farm allocate a hosted session on `open`. Limrun and Doublespeed allocate an instance on the first device command, such as `install` or `open`. For each provider, the standard lifecycle is: diff --git a/website/docs/docs/doublespeed.md b/website/docs/docs/doublespeed.md new file mode 100644 index 0000000000..f954b47303 --- /dev/null +++ b/website/docs/docs/doublespeed.md @@ -0,0 +1,59 @@ +--- +title: Doublespeed +description: Drive Doublespeed iOS simulators with agent-device. +--- + +# Doublespeed + +Use [Doublespeed](https://mac.doublespeed.ai) for direct remote iOS simulators hosted on a Mac mini fleet. Doublespeed does not use local or physical-device selectors such as `--udid`, `--serial`, or `--device`. + +## Install the plugin + +```bash +agent-device plugins add @agent-device/doublespeed +``` + +The provider is an optional npm package installed under `AGENT_DEVICE_HOME`. +After adding or updating it, close sessions and run `agent-device daemon stop` +with the state directory you use; the next device command loads the selected plugin. + +## Credentials and connection + +Set a Doublespeed API key in a non-interactive environment. `DOUBLESPEED_DEVICE` optionally selects the simulator model (default `iPhone 16`); `DOUBLESPEED_API_URL` optionally overrides the service endpoint. + +```bash +export DOUBLESPEED_API_KEY=... +agent-device connect doublespeed --platform ios +``` + +`connect` verifies the service without creating a simulator. Doublespeed supports iOS only; `--platform ios` is the default and the only accepted value. + +## CLI workflow + +A new Doublespeed simulator does not contain your app. Run `install ` before `open`. The install command allocates the simulator when needed, so you do not need to run `devices` first. + +```bash +export DOUBLESPEED_API_KEY=... + +agent-device connect doublespeed --platform ios +agent-device install com.example.app ./build/Example.app +agent-device open com.example.app --relaunch +agent-device snapshot -i +agent-device click 'label="Continue"' +agent-device close +agent-device disconnect +``` + +`install` accepts a simulator `.app` directory, a zipped `.app`, or a URL to either. The bundle is uploaded once per organization: repeated installs of the same build reuse the stored asset. + +Doublespeed sessions support app lifecycle commands, snapshots, screenshots, taps, long presses, text input, scrolling, home, orientation, app state, app logs, and app installation. They cannot reverse a remote device port to a local host port. For Metro or React DevTools, use a publicly reachable HTTPS endpoint or bridge URL instead of a local-only address. + +For MCP-only use, run `connect` in the same effective state directory before starting `agent-device mcp`. MCP exposes operational tools but not provider `connect` commands. + +## Billing and lifetime + +A simulator session is billed per second while it is allocated. `close` and `disconnect` release it; an idle session with no commands for 15 minutes ends on its own, and every session ends after its maximum duration. Orphaned sessions are found through their agent-device labels and released on the next lease recovery. + +## Artifacts and troubleshooting + +Doublespeed does not currently expose provider artifacts through `agent-device artifacts`. If connect fails, check `DOUBLESPEED_API_KEY`. A `402` response means the organization is out of credits; add credits in the [Doublespeed dashboard](https://mac.doublespeed.ai/dashboard/billing). diff --git a/website/docs/docs/plugins.md b/website/docs/docs/plugins.md index a668163fa2..f9e2b218a0 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -27,8 +27,35 @@ The interface is experimental. Publish this declaration in your package manifest {"agentDevicePlugin": {"apiVersion": 1, "provider": "example", "entry": "./dist/plugin.mjs"}} ``` -Use `agent-device` as a development dependency. The default factory accepts `ProviderPluginHost` from `agent-device/plugins`: `env`, package-specific `options`, and `createError` for host-recognized errors. Return `{ runtime, platformModule }` implementing the [provider runtime](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/provider-device-runtime.ts) and [platform module](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/platform-runtime-operations.ts) contracts. Both provider IDs must match the manifest; the module owner must declare `kind: 'provider-runtime'` and a nonempty `instance`. Core checks required runtime methods and owner metadata at startup; operation contracts are checked when used. Provider packages own implementation types; the SDK exposes only factory context. Set options through `plugins[""].options` in user config; leave `installation` unchanged. +Use `agent-device` as a development dependency. The default factory accepts `ProviderPluginHost` from `agent-device/plugins`: `env`, package-specific `options`, `clientVersion`, Apple app packaging and resolution helpers under `apple`, and `createError` for host-recognized errors. Return `{ runtime, platformModule }` implementing the [provider runtime](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/provider-device-runtime.ts) and [platform module](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/platform-runtime-operations.ts) contracts. Both provider IDs must match the manifest; the module owner must declare `kind: 'provider-runtime'` and a nonempty `instance`. Core checks required runtime methods and owner metadata at startup; operation contracts are checked when used. Provider packages own implementation types; the SDK exposes only factory context. Set options through `plugins[""].options` in user config; leave `installation` unchanged. Keep initialization prompt and free of network I/O or device allocation; a stalled factory blocks startup. Load platform mechanics through `platformModule.loadRuntime` and perform remote work in request-bound operations. A failing factory cleans up its own resources; core shuts down previously returned runtimes if another plugin fails. -Incompatible contract changes require a new API version. Plugins cannot replace bundled providers or register arbitrary commands. Installing a package does not add `connect `; provider-specific connect adapters need separate support. Limrun, BrowserStack, and AWS Device Farm remain bundled. +Incompatible contract changes require a new API version. Plugins cannot replace bundled providers or register arbitrary commands. Plugins can add `connect ` through the connection callbacks described below. Limrun, BrowserStack, and AWS Device Farm remain bundled. + +For an Appium or WebDriver service, return `{ webDriver: options }` instead of building an engine. `WebDriverPluginOptions` is available through the type-only `agent-device/plugins/webdriver` import. Core supplies the client version and creates the shared runtime. Provider callbacks prepare sessions, upload apps, and retrieve artifacts. + +To support `agent-device connect example`, declare `agentDevicePlugin.connection` in the package manifest: + +```json +{ + "agentDevicePlugin": { + "apiVersion": 1, + "provider": "example", + "entry": "./dist/plugin.mjs", + "connection": { + "leaseKind": "direct-device-provider", + "requiresAppAttachment": false, + "requiresRemoteDaemon": false, + "supportsArtifacts": false, + "supportsDeferredAppSelection": true, + "supportsDirectPortReverse": false, + "usesCloudWebDriverLease": false + } + } +} +``` + +Return `connection` alongside the runtime or WebDriver options. Its `resolve({ flags, env, cwd, stateDir })` callback validates provider flags and returns `{ profile, extraFlags? }`; `profile.leaseProvider` must match the manifest. Core supplies connection identity, session defaults, Metro settings, and persists the profile. Its async `verify({ flags, env })` callback returns the provider verification result. These callbacks run without allocating a device and their temporary runtimes are shut down afterwards. + +Bundle the plugin implementation and ship ready-to-run ESM: installation disables lifecycle scripts. Bundle shared implementation helpers with the plugin; `AppError` carries a shared brand so core preserves its code and details across package copies. Import types with `import type` to keep them out of the runtime dependency graph.