From 8dad6500a31178e4f11ec32fe981ce069aa75041 Mon Sep 17 00:00:00 2001 From: Hassan Syed Date: Thu, 27 Aug 2026 18:50:58 -0700 Subject: [PATCH 01/11] feat: add direct Doublespeed provider runtime Adds `agent-device connect doublespeed`, a direct iOS-simulator provider backed by the Doublespeed Mac fleet. The provider mirrors the Limrun package: lease lifecycle with label-selector recovery, a session-API interactor, content-addressed app deployment, and an ADR-0019 platform runtime owner with durable app-log recovery. No new external dependency; the kernel snapshot provenance table gains the `doublespeed-ios-tree` producer. Co-Authored-By: Claude Fable 5 --- README.md | 4 +- package.json | 3 +- .../kernel/src/snapshot-provenance.test.ts | 4 + packages/kernel/src/snapshot.ts | 7 +- packages/provider-doublespeed/package.json | 18 + .../src/api-client.test.ts | 147 ++++++ .../provider-doublespeed/src/api-client.ts | 245 ++++++++++ .../src/app-log-descriptor.ts | 80 ++++ .../src/app-log-poller.test.ts | 208 +++++++++ .../src/app-log-poller.ts | 206 +++++++++ .../src/app-log-reconnect.test.ts | 67 +++ .../src/app-log-reconnect.ts | 44 ++ .../src/app-log-runtime.test.ts | 163 +++++++ .../src/app-log-runtime.ts | 339 ++++++++++++++ .../src/connection-verification.test.ts | 54 +++ .../src/connection-verification.ts | 63 +++ .../src/deployment-runtime.test.ts | 109 +++++ .../src/deployment-runtime.ts | 118 +++++ .../src/device-session.test.ts | 38 ++ .../src/device-session.ts | 40 ++ .../provider-doublespeed/src/device.test.ts | 49 ++ packages/provider-doublespeed/src/device.ts | 50 ++ .../src/facts-runtime.test.ts | 60 +++ .../provider-doublespeed/src/facts-runtime.ts | 254 ++++++++++ packages/provider-doublespeed/src/index.ts | 5 + .../src/interaction-operations.test.ts | 108 +++++ .../src/interaction-operations.ts | 241 ++++++++++ packages/provider-doublespeed/src/ios.test.ts | 192 ++++++++ packages/provider-doublespeed/src/ios.ts | 434 ++++++++++++++++++ .../src/lifecycle.test.ts | 217 +++++++++ .../provider-doublespeed/src/lifecycle.ts | 38 ++ .../src/runtime-dependencies.ts | 18 + .../src/runtime-instance.test.ts | 28 ++ .../src/runtime-instance.ts | 35 ++ .../src/runtime.fixtures.ts | 144 ++++++ .../provider-doublespeed/src/runtime.test.ts | 124 +++++ packages/provider-doublespeed/src/runtime.ts | 334 ++++++++++++++ .../src/session-client.test.ts | 80 ++++ .../src/session-client.ts | 200 ++++++++ packages/provider-doublespeed/src/snapshot.ts | 62 +++ packages/provider-doublespeed/src/strings.ts | 4 + packages/provider-doublespeed/tsconfig.json | 12 + pnpm-lock.yaml | 15 + scripts/layering/model.ts | 1 + scripts/layering/package-boundaries.test.ts | 23 + src/__tests__/cloud-connect-profile.test.ts | 89 ++++ src/__tests__/eager-closure-budgets.ts | 3 + .../provider-device-runtimes.test.ts | 19 + src/cli-schema/cli-help-topics.test.ts | 4 + src/cli-schema/cli-help.ts | 21 +- src/cli-schema/command-overrides.ts | 2 +- src/cli/commands/connection-presentation.ts | 4 +- .../connection/connect-provider-adapters.ts | 20 + src/cli/connection/doublespeed-profile.ts | 74 +++ src/cli/connection/provider-policy.ts | 10 +- src/provider-device-runtimes.ts | 71 ++- src/provider-doublespeed-dependencies.ts | 39 ++ website/docs/docs/_meta.json | 5 + website/docs/docs/device-clouds.md | 3 +- website/docs/docs/doublespeed.md | 49 ++ 60 files changed, 5067 insertions(+), 31 deletions(-) create mode 100644 packages/provider-doublespeed/package.json create mode 100644 packages/provider-doublespeed/src/api-client.test.ts create mode 100644 packages/provider-doublespeed/src/api-client.ts create mode 100644 packages/provider-doublespeed/src/app-log-descriptor.ts create mode 100644 packages/provider-doublespeed/src/app-log-poller.test.ts create mode 100644 packages/provider-doublespeed/src/app-log-poller.ts create mode 100644 packages/provider-doublespeed/src/app-log-reconnect.test.ts create mode 100644 packages/provider-doublespeed/src/app-log-reconnect.ts create mode 100644 packages/provider-doublespeed/src/app-log-runtime.test.ts create mode 100644 packages/provider-doublespeed/src/app-log-runtime.ts create mode 100644 packages/provider-doublespeed/src/connection-verification.test.ts create mode 100644 packages/provider-doublespeed/src/connection-verification.ts create mode 100644 packages/provider-doublespeed/src/deployment-runtime.test.ts create mode 100644 packages/provider-doublespeed/src/deployment-runtime.ts create mode 100644 packages/provider-doublespeed/src/device-session.test.ts create mode 100644 packages/provider-doublespeed/src/device-session.ts create mode 100644 packages/provider-doublespeed/src/device.test.ts create mode 100644 packages/provider-doublespeed/src/device.ts create mode 100644 packages/provider-doublespeed/src/facts-runtime.test.ts create mode 100644 packages/provider-doublespeed/src/facts-runtime.ts create mode 100644 packages/provider-doublespeed/src/index.ts create mode 100644 packages/provider-doublespeed/src/interaction-operations.test.ts create mode 100644 packages/provider-doublespeed/src/interaction-operations.ts create mode 100644 packages/provider-doublespeed/src/ios.test.ts create mode 100644 packages/provider-doublespeed/src/ios.ts create mode 100644 packages/provider-doublespeed/src/lifecycle.test.ts create mode 100644 packages/provider-doublespeed/src/lifecycle.ts create mode 100644 packages/provider-doublespeed/src/runtime-dependencies.ts create mode 100644 packages/provider-doublespeed/src/runtime-instance.test.ts create mode 100644 packages/provider-doublespeed/src/runtime-instance.ts create mode 100644 packages/provider-doublespeed/src/runtime.fixtures.ts create mode 100644 packages/provider-doublespeed/src/runtime.test.ts create mode 100644 packages/provider-doublespeed/src/runtime.ts create mode 100644 packages/provider-doublespeed/src/session-client.test.ts create mode 100644 packages/provider-doublespeed/src/session-client.ts create mode 100644 packages/provider-doublespeed/src/snapshot.ts create mode 100644 packages/provider-doublespeed/src/strings.ts create mode 100644 packages/provider-doublespeed/tsconfig.json create mode 100644 src/cli/connection/doublespeed-profile.ts create mode 100644 src/provider-doublespeed-dependencies.ts create mode 100644 website/docs/docs/doublespeed.md diff --git a/README.md b/README.md index c95693f227..30f5eb46a9 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. 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. 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 3bfa2f704b..7bc592cc27 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm package:npm", - "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/capture-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/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/capture-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/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun 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", @@ -286,6 +286,7 @@ "@agent-device/platform-linux": "workspace:*", "@agent-device/platform-vega": "workspace:*", "@agent-device/platform-web": "workspace:*", + "@agent-device/provider-doublespeed": "workspace:*", "@agent-device/provider-limrun": "workspace:*", "@agent-device/provider-webdriver": "workspace:*", "@agent-device/replay-test": "workspace:*", diff --git a/packages/kernel/src/snapshot-provenance.test.ts b/packages/kernel/src/snapshot-provenance.test.ts index afcfb33f18..9ffc2f9968 100644 --- a/packages/kernel/src/snapshot-provenance.test.ts +++ b/packages/kernel/src/snapshot-provenance.test.ts @@ -32,4 +32,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 4fc96bcfdb..9f2b7b8f65 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -150,7 +150,7 @@ export type SnapshotNode = RawSnapshotNode & { /** * 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. @@ -162,7 +162,10 @@ export type SnapshotNode = RawSnapshotNode & { * snapshot-provenance.test.ts). */ export type SnapshotProvenance = - | { backend: 'xctest'; producer: 'apple-runner' | 'appium-source' | 'limrun-ios-tree' } + | { + backend: 'xctest'; + producer: 'apple-runner' | 'appium-source' | 'limrun-ios-tree' | 'doublespeed-ios-tree'; + } | { backend: 'android'; producer: 'android-uiautomator' | 'appium-source' } | { backend: 'harmonyos-arkui'; producer: 'harmonyos-uitest' } | { backend: 'macos-helper'; producer: 'macos-helper' } diff --git a/packages/provider-doublespeed/package.json b/packages/provider-doublespeed/package.json new file mode 100644 index 0000000000..1a3ed54a6a --- /dev/null +++ b/packages/provider-doublespeed/package.json @@ -0,0 +1,18 @@ +{ + "name": "@agent-device/provider-doublespeed", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Doublespeed provider runtime for agent-device. Internal workspace package bundled into the published artifact.", + "dependencies": { + "@agent-device/capture-kit": "workspace:*", + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} 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..ca18cdf103 --- /dev/null +++ b/packages/provider-doublespeed/src/api-client.test.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs'; +import os from 'node:os'; +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 = fs.mkdtempSync(path.join(os.tmpdir(), '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..42e85b2d1c --- /dev/null +++ b/packages/provider-doublespeed/src/app-log-runtime.ts @@ -0,0 +1,339 @@ +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, + home: liveSessionUnavailable, + orientation: liveSessionUnavailable, + tvRemote: liveSessionUnavailable, + keyboardStatus: liveSessionUnavailable, + keyboardDismiss: liveSessionUnavailable, + keyboardEnter: liveSessionUnavailable, + readClipboard: liveSessionUnavailable, + writeClipboard: liveSessionUnavailable, + appSwitcher: liveSessionUnavailable, + triggerAppEvent: liveSessionUnavailable, + setSetting: 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..a3bbb0f926 --- /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, + doublespeedAppSwitcherOperationFacts, + 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, + findSelector: observationUnavailable, + }), + ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), + ...doublespeedInteractionOperationFacts(device), + ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), + ...doublespeedNavigationOperationFacts(device), + ...doublespeedKeyboardOperationFacts(device), + ...doublespeedClipboardOperationFacts(device), + ...doublespeedAppSwitcherOperationFacts(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, + findSelector: liveSessionUnavailable, + }), + ...viewportRuntimeOperationFacts({ setViewport: liveSessionUnavailable }), + ...doublespeedInteractionOperationFacts(device, liveSessionUnavailable), + ...doublespeedNavigationOperationFacts(device, liveSessionUnavailable), + ...doublespeedKeyboardOperationFacts(device, liveSessionUnavailable), + ...doublespeedClipboardOperationFacts(device, liveSessionUnavailable), + ...doublespeedAppSwitcherOperationFacts(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..30e69b8f0a --- /dev/null +++ b/packages/provider-doublespeed/src/index.ts @@ -0,0 +1,5 @@ +export { DOUBLESPEED_PROVIDER } from './device.ts'; +export { createDoublespeedRuntime } from './runtime.ts'; +export { verifyDoublespeedConnection } from './connection-verification.ts'; + +export type { DoublespeedRuntimeDependencies } from './runtime-dependencies.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..62bb61f11b --- /dev/null +++ b/packages/provider-doublespeed/src/interaction-operations.test.ts @@ -0,0 +1,108 @@ +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, + doublespeedAppSwitcherOperationFacts, + 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(facts.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(navigation.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(doublespeedAppSwitcherOperationFacts(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), + }); + + 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..b93b433da7 --- /dev/null +++ b/packages/provider-doublespeed/src/interaction-operations.ts @@ -0,0 +1,241 @@ +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 { homeRuntimeOperationFacts } from '@agent-device/contracts/home-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 { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-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({ + 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({ + 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 }), + ...homeRuntimeOperationFacts({ home: liveSessionUnavailable ?? available }), + ...orientationRuntimeOperationFacts({ orientation: liveSessionUnavailable ?? available }), + ...tvRemoteRuntimeOperationFacts({ tvRemote: liveSessionUnavailable ?? tvRemoteUnavailable }), + }); +} + +export function doublespeedKeyboardOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = liveSessionUnavailable ?? keyboardUnavailable; + return Object.freeze({ + ...keyboardRuntimeOperationFacts({ status: cell, dismiss: cell, enter: cell }), + }); +} + +export function doublespeedClipboardOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = liveSessionUnavailable ?? clipboardUnavailable; + return Object.freeze({ ...clipboardRuntimeOperationFacts({ 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 doublespeedAppSwitcherOperationFacts( + _device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + return Object.freeze({ + ...appSwitcherRuntimeOperationFacts({ + 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 }), + }); +} diff --git a/packages/provider-doublespeed/src/ios.test.ts b/packages/provider-doublespeed/src/ios.test.ts new file mode 100644 index 0000000000..398f8d6e08 --- /dev/null +++ b/packages/provider-doublespeed/src/ios.test.ts @@ -0,0 +1,192 @@ +import fs from 'node:fs'; +import os from 'node:os'; +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(); + + 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 = fs.mkdtempSync(path.join(os.tmpdir(), '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 + .readdirSync(os.tmpdir()) + .filter((name) => name.startsWith('agent-device-doublespeed-ios-app-')), + ).toEqual([]); + 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/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/tsconfig.json b/packages/provider-doublespeed/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/provider-doublespeed/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26478f568f..ce9095c9f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@agent-device/platform-web': specifier: workspace:* version: link:packages/platform-web + '@agent-device/provider-doublespeed': + specifier: workspace:* + version: link:packages/provider-doublespeed '@agent-device/provider-limrun': specifier: workspace:* version: link:packages/provider-limrun @@ -283,6 +286,18 @@ importers: specifier: workspace:* version: link:../kernel + packages/provider-doublespeed: + dependencies: + '@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 + packages/provider-limrun: dependencies: '@agent-device/capture-kit': diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 2dc99f55f0..ac9a961c42 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -98,6 +98,7 @@ export const UNRANKED_ZONES: ReadonlySet = new Set([ 'platform-web', 'provider-webdriver', 'provider-limrun', + 'provider-doublespeed', 'xml', ]); diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index a48580d7fb..fc0025008a 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -632,6 +632,20 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/contracts', '@agent-device/kernel', ]); + const providerDoublespeedPackage = packages.find( + (pkg) => pkg.name === '@agent-device/provider-doublespeed', + ); + assert.ok(providerDoublespeedPackage, 'provider-doublespeed package must exist'); + assert.deepEqual( + [...providerDoublespeedPackage.exportTargets.keys()], + ['@agent-device/provider-doublespeed'], + ); + assert.deepEqual([...providerDoublespeedPackage.workspaceDependencies].sort(), [ + '@agent-device/capture-kit', + '@agent-device/contracts', + '@agent-device/kernel', + ]); + assert.equal(providerDoublespeedPackage.externalDependencies.size, 0); const rootExternalDependencies = rootExternalDependencyRanges(repoRoot); for (const pkg of packages) { for (const [name, range] of pkg.externalDependencies) { @@ -682,6 +696,10 @@ 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.ok( + rootWorkspaceDependencyNames(repoRoot).has('@agent-device/provider-doublespeed'), + 'root must declare the provider-doublespeed workspace dependency', + ); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/xml'), 'root must declare the xml workspace dependency', @@ -765,6 +783,11 @@ test('Node resolution enforces the exports map at runtime', () => { providerLimrunResolved.endsWith('packages/provider-limrun/src/index.ts'), providerLimrunResolved, ); + const providerDoublespeedResolved = import.meta.resolve('@agent-device/provider-doublespeed'); + assert.ok( + providerDoublespeedResolved.endsWith('packages/provider-doublespeed/src/index.ts'), + providerDoublespeedResolved, + ); 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/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index b075fbafa6..d444d49c72 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -14,6 +14,7 @@ import { import type { AgentDeviceClient } from '../agent-device-client.ts'; import { resolveCloudWebDriverConnectProfile } from '../cli/connection/cloud-webdriver-profile.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { verifyDoublespeedConnection } from '@agent-device/provider-doublespeed'; import { verifyLimrunConnection } from '@agent-device/provider-limrun'; import { providerWebDriver } from '../provider-webdriver.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; @@ -28,6 +29,11 @@ vi.mock('@agent-device/provider-limrun', async (importOriginal) => ({ verifyLimrunConnection: vi.fn(), })); +vi.mock('@agent-device/provider-doublespeed', async (importOriginal) => ({ + ...(await importOriginal()), + verifyDoublespeedConnection: vi.fn(), +})); + vi.mock('../provider-webdriver.ts', () => ({ providerWebDriver: { verifyConnection: vi.fn() }, })); @@ -39,9 +45,24 @@ afterEach(() => { const mockedResolveCloudAccessForConnect = vi.mocked(resolveCloudAccessForConnect); const mockedVerifyLimrunConnection = vi.mocked(verifyLimrunConnection); +const mockedVerifyDoublespeedConnection = vi.mocked(verifyDoublespeedConnection); const mockedVerifyWebDriverConnection = vi.mocked(providerWebDriver.verifyConnection); beforeEach(() => { + 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.', + }, + }); mockedVerifyLimrunConnection.mockResolvedValue({ provider: 'limrun', service: 'Limrun', @@ -198,6 +219,74 @@ test('connect limrun generates a local daemon remote profile', async () => { } }); +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), [ + '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 }); + } +}); + test('connect limrun persists deferred Metro bridge settings', async () => { const tempRoot = mkdtempForTestSync('agent-device-connect-limrun-metro-'); const stateDir = path.join(tempRoot, '.state'); diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index b3108d9f6f..d937a3220d 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -272,6 +272,9 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ // --- @agent-device/provider-limrun --- 'packages/provider-limrun/src/index.ts': 29, + // --- @agent-device/provider-doublespeed --- + 'packages/provider-doublespeed/src/index.ts': 28, + // --- @agent-device/provider-webdriver --- 'packages/provider-webdriver/src/index.ts': 49, diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index d47efd876b..0b16069a1e 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -34,6 +34,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('default provider runtimes load Doublespeed when a Doublespeed API key is configured', async () => { + const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ + DOUBLESPEED_API_KEY: 'dsx_test_key', + }); + + const doublespeed = runtimes.find((runtime) => runtime.provider === 'doublespeed'); + assert.ok(doublespeed); + assert.equal( + runtimes.some((runtime) => runtime.provider === 'limrun'), + false, + ); + assertPlatformModuleCoverage(runtimes, platformModules, [doublespeed]); + assert.equal( + platformModules.some(({ runtime }) => runtime === doublespeed), + true, + ); + await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); +}); + function assertPlatformModuleCoverage( runtimes: readonly object[], platformModules: ReadonlyArray< diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index ef4f4c7896..3262fe29fb 100644 --- a/src/cli-schema/cli-help-topics.test.ts +++ b/src/cli-schema/cli-help-topics.test.ts @@ -433,6 +433,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/); @@ -445,6 +447,8 @@ 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, /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/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index 83647f9ec5..eb7888e0c9 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -732,16 +732,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. A new Limrun instance has no user app. Run install first; install allocates the instance, then open launches the installed id. + A new Doublespeed simulator has no user app either. Run install first; install allocates the simulator, then open launches 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 or apps as a pre-open catalog probe for direct providers; those commands can allocate the deferred provider session and only inspect that live device. 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. @@ -793,6 +795,17 @@ Limrun direct-device flow: agent-device close agent-device disconnect +Doublespeed direct-simulator flow: + DOUBLESPEED_API_KEY=... + agent-device connect doublespeed --platform ios + + 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 @@ -809,13 +822,13 @@ 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. 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. 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. 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, 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/cli-schema/command-overrides.ts b/src/cli-schema/command-overrides.ts index 29181b5cb9..20bc75e49e 100644 --- a/src/cli-schema/command-overrides.ts +++ b/src/cli-schema/command-overrides.ts @@ -64,7 +64,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 [cloud|proxy|limrun|doublespeed|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', listUsageOverride: 'connect', positionalArgs: ['provider?'], allowedFlags: [ diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index 34e83478cc..420620bda5 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -329,6 +329,8 @@ function appIdPlaceholder(platform: RemoteConnectionState['platform']): string { function missingAppLabel(state: RemoteConnectionState): string { if (state.leaseProvider === 'aws-device-farm') return 'not attached'; - if (state.leaseProvider === 'limrun') return 'not installed yet'; + if (state.leaseProvider === 'limrun' || state.leaseProvider === 'doublespeed') { + return 'not installed yet'; + } return 'not available'; } diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index f65bfd742c..313babf920 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -1,5 +1,6 @@ import type { CliFlags } from '@agent-device/contracts/command'; import type { ProviderConnectionVerification } from '@agent-device/contracts/remote'; +import { verifyDoublespeedConnection } from '@agent-device/provider-doublespeed'; import { verifyLimrunConnection } from '@agent-device/provider-limrun'; import { AppError } from '@agent-device/kernel/errors'; import { providerWebDriver } from '../../provider-webdriver.ts'; @@ -8,6 +9,7 @@ import type { EnvMap } from '../../utils/env-map.ts'; import { readVersion } from '../../utils/version.ts'; import { resolveCloudConnectProfile } from './cloud-profile.ts'; import { resolveCloudWebDriverConnectProfile } from './cloud-webdriver-profile.ts'; +import { resolveDoublespeedConnectProfile } from './doublespeed-profile.ts'; import { resolveLimrunConnectProfile } from './limrun-profile.ts'; import { resolveProxyConnectProfile } from './proxy-profile.ts'; import { profileToCliFlags } from '../remote-config-flags.ts'; @@ -72,6 +74,10 @@ const CONNECT_PROVIDER_ADAPTERS = { resolve: resolveLimrunConnectProfile, verify: verifyLimrun, }, + doublespeed: { + resolve: resolveDoublespeedConnectProfile, + verify: verifyDoublespeed, + }, } satisfies Record; export async function resolveConnectProviderProfile(options: { @@ -189,6 +195,20 @@ async function verifyLimrun( }); } +async function verifyDoublespeed( + context: Pick, +): Promise { + return await verifyDoublespeedConnection({ + apiKey: requiredResolvedValue( + context.env.DOUBLESPEED_API_KEY, + 'Doublespeed profile missed DOUBLESPEED_API_KEY.', + ), + apiUrl: context.env.DOUBLESPEED_API_URL?.trim() || undefined, + clientVersion: readVersion(), + device: context.env.DOUBLESPEED_DEVICE?.trim() || undefined, + }); +} + function shouldUseProxyConnectShortcut(flags: CliFlags): boolean { if (!flags.daemonBaseUrl || flags.tenant || flags.runId || flags.leaseId || flags.leaseBackend) { return false; diff --git a/src/cli/connection/doublespeed-profile.ts b/src/cli/connection/doublespeed-profile.ts new file mode 100644 index 0000000000..3e9f3b56ac --- /dev/null +++ b/src/cli/connection/doublespeed-profile.ts @@ -0,0 +1,74 @@ +import { resolveDaemonPaths } from '../../daemon/config.ts'; +import type { RemoteConfigProfile } from '../../remote/remote-config-schema.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import type { CliFlags } from '@agent-device/contracts/command'; +import type { EnvMap } from '../../utils/env-map.ts'; +import { readMetroProfileFields } from './profile-fields.ts'; +import { persistAndResolveGeneratedProfile } from './generated-config.ts'; +import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts'; + +const DEFAULT_DOUBLESPEED_TENANT = 'doublespeed'; +const DOUBLESPEED_LEASE_BACKEND = 'ios-instance'; + +export function resolveDoublespeedConnectProfile(options: { + flags: CliFlags; + stateDir: string; + cwd: string; + env?: EnvMap; +}): { flags: CliFlags; remoteConfigPath: string } { + const env = options.env ?? process.env; + const apiKey = env.DOUBLESPEED_API_KEY?.trim(); + if (!apiKey) { + throw new AppError('INVALID_ARGS', 'connect doublespeed requires DOUBLESPEED_API_KEY.', { + hint: 'Set DOUBLESPEED_API_KEY in the environment before running agent-device connect doublespeed.', + }); + } + + const profile = buildDoublespeedRemoteProfile({ flags: options.flags }); + return persistAndResolveGeneratedProfile({ + stateDir: options.stateDir, + provider: 'doublespeed', + profile, + cwd: options.cwd, + env, + flags: options.flags, + }); +} + +function buildDoublespeedRemoteProfile(options: { flags: CliFlags }): RemoteConfigProfile { + const flags = options.flags; + validateDoublespeedConnectFlags(flags); + const daemonPaths = resolveDaemonPaths(flags.stateDir); + return { + stateDir: daemonPaths.baseDir, + daemonTransport: 'auto', + tenant: flags.tenant ?? DEFAULT_DOUBLESPEED_TENANT, + runId: flags.runId ?? `cli-${Date.now().toString(36)}`, + sessionIsolation: 'tenant', + leaseBackend: DOUBLESPEED_LEASE_BACKEND, + leaseProvider: 'doublespeed', + platform: 'ios', + target: 'mobile', + session: flags.session, + ...readMetroProfileFields(flags), + }; +} + +function validateDoublespeedConnectFlags(flags: CliFlags): void { + if (flags.platform !== undefined && flags.platform !== 'ios') { + throw new AppError('INVALID_ARGS', 'connect doublespeed supports --platform ios only.'); + } + if (flags.device !== undefined) { + throw new AppError( + 'INVALID_ARGS', + 'connect doublespeed does not accept --device; set DOUBLESPEED_DEVICE to pick the simulator model.', + ); + } + const leaseBackend = resolveRequestedLeaseBackend({ ...flags, platform: 'ios' }); + if (leaseBackend !== DOUBLESPEED_LEASE_BACKEND) { + throw new AppError( + 'INVALID_ARGS', + `connect doublespeed requires --lease-backend ${DOUBLESPEED_LEASE_BACKEND}.`, + ); + } +} diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 8bdcecc48d..5e0af99b51 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -4,7 +4,10 @@ import { type CloudWebDriverKnownProviderName, } from '@agent-device/provider-webdriver'; -export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; +export type DirectDeviceConnectProvider = + | CloudWebDriverKnownProviderName + | 'limrun' + | 'doublespeed'; export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; export function isConnectProviderName(value: string | undefined): value is ConnectProvider { @@ -14,7 +17,9 @@ export function isConnectProviderName(value: string | undefined): value is Conne function isDirectDeviceConnectProvider( provider: string | undefined, ): provider is DirectDeviceConnectProvider { - return provider === 'limrun' || isCloudWebDriverProviderName(provider); + return ( + provider === 'limrun' || provider === 'doublespeed' || isCloudWebDriverProviderName(provider) + ); } export function connectProviderNamesForError(): string { @@ -24,6 +29,7 @@ export function connectProviderNamesForError(): string { CLOUD_WEBDRIVER_PROVIDERS.browserStack, CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, 'limrun', + 'doublespeed', ].join(', '); } diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 310da7e70c..6be2339c4d 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,5 +1,6 @@ import type { DefaultCloudWebDriverProviderRuntimeEnv } from '@agent-device/provider-webdriver'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import type { DOUBLESPEED_PROVIDER } from '@agent-device/provider-doublespeed'; import type { LIMRUN_PROVIDER } from '@agent-device/provider-limrun'; import type { PlatformRuntimeHost, @@ -15,6 +16,7 @@ export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRunti export const DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS = [ ...providerWebDriver.providerIds, 'limrun' satisfies typeof LIMRUN_PROVIDER, + 'doublespeed' satisfies typeof DOUBLESPEED_PROVIDER, ] as const; export type DefaultProviderRuntimeComposition = Readonly<{ @@ -45,30 +47,65 @@ export async function createDefaultProviderRuntimeComposition( ): Promise { const runtimes = providerWebDriver.createDefaultRuntimes(env); const platformModules = [...createProviderPlatformRuntimeRegistrations(runtimes)]; - const apiKey = env.LIMRUN_API_KEY?.trim(); - if (!apiKey) return Object.freeze({ runtimes, platformModules: Object.freeze(platformModules) }); - - const [limrunRuntime, dependencies] = await Promise.all([ - import('@agent-device/provider-limrun'), - import('./sdk/limrun-runtime-dependencies.ts'), - ]); - const registration = limrunRuntime.createLimrunRuntime( - { - apiKey, - region: env.LIMRUN_REGION?.trim() || undefined, - }, - dependencies.createLimrunRuntimeDependencies(), - { includePlatformModule: true }, - ); + const registrations = [ + ...(await loadLimrunRegistration(env)), + ...(await loadDoublespeedRegistration(env)), + ]; return Object.freeze({ - runtimes: Object.freeze([...runtimes, registration.runtime]), + runtimes: Object.freeze([...runtimes, ...registrations.map(({ runtime }) => runtime)]), platformModules: Object.freeze([ ...platformModules, - { runtime: registration.runtime, module: registration.platformModule }, + ...registrations.map(({ runtime, platformModule }) => ({ runtime, module: platformModule })), ]), }); } +type ProviderRegistration = Readonly<{ + runtime: ProviderDeviceRuntime; + platformModule: PlatformRuntimeProviderModule; +}>; + +async function loadLimrunRegistration( + env: DefaultProviderDeviceRuntimeEnv, +): Promise { + const apiKey = env.LIMRUN_API_KEY?.trim(); + if (!apiKey) return []; + const [limrunRuntime, dependencies] = await Promise.all([ + import('@agent-device/provider-limrun'), + import('./sdk/limrun-runtime-dependencies.ts'), + ]); + return [ + limrunRuntime.createLimrunRuntime( + { + apiKey, + region: env.LIMRUN_REGION?.trim() || undefined, + }, + dependencies.createLimrunRuntimeDependencies(), + { includePlatformModule: true }, + ), + ]; +} + +async function loadDoublespeedRegistration( + env: DefaultProviderDeviceRuntimeEnv, +): Promise { + const apiKey = env.DOUBLESPEED_API_KEY?.trim(); + if (!apiKey) return []; + const doublespeedRuntime = await import('@agent-device/provider-doublespeed'); + const dependencies = await import('./provider-doublespeed-dependencies.ts'); + return [ + doublespeedRuntime.createDoublespeedRuntime( + { + apiKey, + apiUrl: env.DOUBLESPEED_API_URL?.trim() || undefined, + device: env.DOUBLESPEED_DEVICE?.trim() || undefined, + }, + dependencies.createDoublespeedRuntimeDependencies(), + { includePlatformModule: true }, + ), + ]; +} + type ProviderRuntimeWithPlatformModule = ProviderDeviceRuntime & Readonly<{ owner: PlatformRuntimeProviderModule['owner']; diff --git a/src/provider-doublespeed-dependencies.ts b/src/provider-doublespeed-dependencies.ts new file mode 100644 index 0000000000..ba61aba37d --- /dev/null +++ b/src/provider-doublespeed-dependencies.ts @@ -0,0 +1,39 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { DoublespeedRuntimeDependencies } from '@agent-device/provider-doublespeed'; +import { execFailureDetails, runCmd } from './utils/exec.ts'; +import { readVersion } from './utils/version.ts'; + +export function createDoublespeedRuntimeDependencies(): DoublespeedRuntimeDependencies { + return { + clientVersion: readVersion(), + host: { + 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 Doublespeed install', + { + command: ['zip', ...args].join(' '), + ...execFailureDetails(result), + }, + ); + } + }, + }, + ios: { + resolveAppAlias: async (app) => { + const { resolveIosAppAlias } = await import('./platforms/apple/core/app-resolution.ts'); + return resolveIosAppAlias(app); + }, + readBundleAppName: async (appPath) => { + const { readIosBundleInfo } = await import('./platforms/apple/core/install-artifact.ts'); + return (await readIosBundleInfo(appPath)).appName; + }, + }, + }; +} diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index 68655a6001..7c6f60050a 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -117,6 +117,11 @@ "type": "custom-link", "label": "Limrun", "link": "/docs/limrun" + }, + { + "type": "custom-link", + "label": "Doublespeed", + "link": "/docs/doublespeed" } ] }, diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index b7f165dd5f..53888d08f6 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..9e696a0062 --- /dev/null +++ b/website/docs/docs/doublespeed.md @@ -0,0 +1,49 @@ +--- +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`. + +## 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). From 4b81820698ea0e99c4046e10db88918884b9c9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 16:54:29 +0200 Subject: [PATCH 02/11] feat: support provider-owned plugin connections and WebDriver adapters --- packages/kernel/src/app-error.ts | 14 + packages/kernel/src/errors.ts | 15 +- .../src/artifact-results.ts | 15 + .../browserstack-connection-verification.ts | 55 +--- .../provider-webdriver/src/browserstack.ts | 129 ++++---- packages/provider-webdriver/src/plugin.ts | 17 ++ packages/provider-webdriver/src/providers.ts | 4 + .../src/webdriver-utils.test.ts | 213 ++++++++++++- .../provider-webdriver/src/webdriver-utils.ts | 285 +++++++++++++++++- .../connection/connect-provider-adapters.ts | 65 +++- src/cli/connection/provider-policy.ts | 30 +- src/plugins/connection.ts | 32 ++ src/plugins/host.ts | 63 ++++ src/plugins/load.test.ts | 43 ++- src/plugins/load.ts | 68 ++++- src/plugins/manifest.ts | 30 +- src/plugins/store.test.ts | 1 + src/plugins/store.ts | 1 + src/sdk/plugin-webdriver.ts | 3 + src/sdk/plugins.ts | 10 + website/docs/docs/client-api.md | 2 + website/docs/docs/plugins.md | 23 +- 22 files changed, 955 insertions(+), 163 deletions(-) create mode 100644 packages/kernel/src/app-error.ts create mode 100644 packages/provider-webdriver/src/plugin.ts create mode 100644 src/plugins/connection.ts create mode 100644 src/plugins/host.ts create mode 100644 src/sdk/plugin-webdriver.ts diff --git a/packages/kernel/src/app-error.ts b/packages/kernel/src/app-error.ts new file mode 100644 index 0000000000..d748efb9b2 --- /dev/null +++ b/packages/kernel/src/app-error.ts @@ -0,0 +1,14 @@ +import type { AppErrorCode, AppErrorDetails } from './errors.ts'; + +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; + } +} diff --git a/packages/kernel/src/errors.ts b/packages/kernel/src/errors.ts index 747541f4e5..42dd4f1a85 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -1,3 +1,5 @@ +import { AppError } from './app-error.ts'; +export { AppError } from './app-error.ts'; import { redactDiagnosticData, sanitizeErrorCause as normalizeErrorCause } from './redaction.ts'; /** @@ -219,19 +221,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/provider-webdriver/src/artifact-results.ts b/packages/provider-webdriver/src/artifact-results.ts index bf02c7c77e..3aaabc69b5 100644 --- a/packages/provider-webdriver/src/artifact-results.ts +++ b/packages/provider-webdriver/src/artifact-results.ts @@ -28,3 +28,18 @@ export function unavailableCloudArtifactsResult(options: { message: options.error instanceof Error ? options.error.message : String(options.error), }; } + +/** A ready URL artifact read off a provider's session-details record, or nothing when the field is absent. */ +export function urlArtifactFromDetails( + provider: string, + providerSessionId: string, + details: Record, + field: string, + kind: CloudArtifact['kind'], + name: string, +): CloudArtifact | undefined { + const value = details[field]; + const url = typeof value === 'string' ? value.trim() : ''; + if (url.length === 0) return undefined; + return { provider, providerSessionId, kind, name, url, availability: 'ready' }; +} diff --git a/packages/provider-webdriver/src/browserstack-connection-verification.ts b/packages/provider-webdriver/src/browserstack-connection-verification.ts index efdfb4e3c8..dfb18bd989 100644 --- a/packages/provider-webdriver/src/browserstack-connection-verification.ts +++ b/packages/provider-webdriver/src/browserstack-connection-verification.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { agentDeviceRequestHeaders } from './request-headers.ts'; -import { basicAuthHeader } from './webdriver-utils.ts'; +import { asRecord, fetchProviderVerificationJson, sameOsVersion } from './webdriver-utils.ts'; import type { CloudWebDriverConnectionVerification, CloudWebDriverConnectionVerificationOptions, @@ -105,42 +104,16 @@ async function fetchBrowserStackJson( auth: { username: string; accessKey: string }, clientVersion: string, ): Promise { - try { - const response = await fetch(endpoint, { - headers: { - ...agentDeviceRequestHeaders(clientVersion), - Authorization: basicAuthHeader(auth), - }, - signal: AbortSignal.timeout(15_000), - }); - if (!response.ok) { - const unauthorized = response.status === 401 || response.status === 403; - throw new AppError( - unauthorized ? 'UNAUTHORIZED' : 'COMMAND_FAILED', - 'BrowserStack rejected connection verification.', - { - status: response.status, - hint: unauthorized - ? 'Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.' - : 'Retry connect or check the BrowserStack service status.', - }, - ); - } - return (await response.json()) as unknown; - } catch (error) { - if (error instanceof AppError) throw error; - throw new AppError( - 'COMMAND_FAILED', - 'BrowserStack connection verification failed.', - { hint: 'Check network access to api-cloud.browserstack.com and retry connect.' }, - error, - ); - } -} - -function sameOsVersion(left: string, right: string): boolean { - const normalize = (value: string) => value.replace(/(?:\.0)+$/, ''); - return normalize(left) === normalize(right); + return await fetchProviderVerificationJson(endpoint, { + clientVersion, + auth, + hints: { + service: 'BrowserStack', + unauthorizedHint: 'Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.', + serviceHint: 'Retry connect or check the BrowserStack service status.', + networkHint: 'Check network access to api-cloud.browserstack.com and retry connect.', + }, + }); } function readBrowserStackDevices( @@ -184,9 +157,3 @@ function readBrowserStackApps( ]; }); } - -function asRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} diff --git a/packages/provider-webdriver/src/browserstack.ts b/packages/provider-webdriver/src/browserstack.ts index 6c8d10fd4b..dc55978c8f 100644 --- a/packages/provider-webdriver/src/browserstack.ts +++ b/packages/provider-webdriver/src/browserstack.ts @@ -1,12 +1,17 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; import type { CloudArtifact, CloudArtifactsResult } from '@agent-device/contracts/observability'; import type { CloudWebDriverCapabilityOverrides } from './capabilities.ts'; import type { CloudWebDriverUploadApp } from './runtime.ts'; -import { AppError } from '@agent-device/kernel/errors'; -import { agentDeviceRequestHeaders } from './request-headers.ts'; -import { cloudArtifactsReadyOrPending } from './artifact-results.ts'; -import { basicAuthHeader, trimTrailingSlash } from './webdriver-utils.ts'; +import { cloudArtifactsReadyOrPending, urlArtifactFromDetails } from './artifact-results.ts'; +import { isBrowserStackAppReference } from './providers.ts'; +import { + appendUrlPath, + appFileUploadForm, + asRecord, + createHubUploadApp, + fetchProviderSessionDetails, + postHubAppUpload, + resolveHubAppReference, +} from './webdriver-utils.ts'; export const BROWSERSTACK_APP_AUTOMATE_ENDPOINT = 'https://hub-cloud.browserstack.com/wd/hub/'; export const BROWSERSTACK_APP_UPLOAD_ENDPOINT = @@ -76,41 +81,42 @@ export async function uploadBrowserStackApp( signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); - const file = await fs.readFile(appPath); - const form = new FormData(); - form.set('file', new Blob([file]), path.basename(appPath)); - const response = await fetch(options.endpoint ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT, { - method: 'POST', - headers: { - ...agentDeviceRequestHeaders(options.clientVersion), - Authorization: basicAuthHeader(options), + return await postHubAppUpload( + await appFileUploadForm(appPath, 'file'), + { + service: 'BrowserStack', + endpoint: options.endpoint ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT, + clientVersion: options.clientVersion, + auth: options, + readAppReference: readBrowserStackAppUrl, }, - body: form, signal, - }); - const json = (await response.json()) as unknown; - const appUrl = readBrowserStackAppUrl(json); - if (!response.ok || !appUrl) { - throw new AppError('COMMAND_FAILED', 'BrowserStack app upload failed.', { - status: response.status, - response: json, - }); - } - return appUrl; + ); } export function createBrowserStackUploadApp( options: Required, ): CloudWebDriverUploadApp { - return async ({ appPath, options: installOptions, signal }) => { - const appReference = await uploadBrowserStackApp(appPath, options, signal); - return { - appReference, - bundleId: installOptions?.appIdentifierHint, - packageName: installOptions?.packageNameHint, - launchTarget: installOptions?.appIdentifierHint ?? installOptions?.packageNameHint, - }; - }; + return createHubUploadApp( + async (appPath, signal) => await uploadBrowserStackApp(appPath, options, signal), + ); +} + +/** The hub fetches a public URL itself, so only a local path is uploaded. */ +export async function resolveBrowserStackAppReference( + app: string, + options: BrowserStackUploadOptions & { cwd?: string; signal?: AbortSignal }, +): Promise { + return await resolveHubAppReference({ + service: 'BrowserStack', + app, + cwd: options.cwd, + referenceScheme: 'bs://', + referenceLabel: 'a bs:// app id', + isReference: isBrowserStackAppReference, + uploadFile: async (appPath, signal) => await uploadBrowserStackApp(appPath, options, signal), + signal: options.signal, + }); } /** @@ -138,39 +144,25 @@ export function buildBrowserStackCapabilities( buildName: options.buildName, sessionName: options.sessionName, ...(options.deviceFeatures ?? {}), - ...asRecord(configuredBstackOptions), + ...(asRecord(configuredBstackOptions) ?? {}), }, }; } -function asRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {}; -} - async function fetchBrowserStackSessionDetails( sessionId: string, options: BrowserStackSessionDetailsOptions, ): Promise> { - const endpoint = new URL( - `${trimTrailingSlash(String(options.endpoint ?? BROWSERSTACK_SESSION_DETAILS_ENDPOINT))}/${sessionId}.json`, + const endpoint = appendUrlPath( + options.endpoint ?? BROWSERSTACK_SESSION_DETAILS_ENDPOINT, + `${sessionId}.json`, ); - const response = await fetch(endpoint, { - headers: { - ...agentDeviceRequestHeaders(options.clientVersion), - Authorization: basicAuthHeader(options), - }, + const json = await fetchProviderSessionDetails(endpoint, { + clientVersion: options.clientVersion, + auth: options, + service: 'BrowserStack', }); - const json = (await response.json()) as unknown; - if (!response.ok || !json || typeof json !== 'object') { - throw new AppError('COMMAND_FAILED', 'BrowserStack session details lookup failed.', { - status: response.status, - response: json, - }); - } - const details = (json as { automation_session?: unknown }).automation_session ?? json; - return details && typeof details === 'object' ? (details as Record) : {}; + return asRecord(json.automation_session) ?? json; } function mapBrowserStackArtifacts( @@ -179,7 +171,7 @@ function mapBrowserStackArtifacts( details: Record, ): CloudArtifact[] { return [ - browserStackUrlArtifact( + urlArtifactFromDetails( provider, providerSessionId, details, @@ -187,7 +179,7 @@ function mapBrowserStackArtifacts( 'video', 'Session video', ), - browserStackUrlArtifact( + urlArtifactFromDetails( provider, providerSessionId, details, @@ -195,7 +187,7 @@ function mapBrowserStackArtifacts( 'appium-log', 'Appium logs', ), - browserStackUrlArtifact( + urlArtifactFromDetails( provider, providerSessionId, details, @@ -203,7 +195,7 @@ function mapBrowserStackArtifacts( 'device-log', 'Device logs', ), - browserStackUrlArtifact( + urlArtifactFromDetails( provider, providerSessionId, details, @@ -211,7 +203,7 @@ function mapBrowserStackArtifacts( 'provider-session', 'BrowserStack dashboard', ), - browserStackUrlArtifact( + urlArtifactFromDetails( provider, providerSessionId, details, @@ -222,19 +214,6 @@ function mapBrowserStackArtifacts( ].filter((artifact): artifact is CloudArtifact => artifact !== undefined); } -function browserStackUrlArtifact( - provider: string, - providerSessionId: string, - details: Record, - field: string, - kind: CloudArtifact['kind'], - name: string, -): CloudArtifact | undefined { - const url = details[field]; - if (typeof url !== 'string' || url.length === 0) return undefined; - return { provider, providerSessionId, kind, name, url, availability: 'ready' }; -} - function readBrowserStackAppUrl(value: unknown): string | undefined { if (!value || typeof value !== 'object') return undefined; const appUrl = (value as { app_url?: unknown }).app_url; diff --git a/packages/provider-webdriver/src/plugin.ts b/packages/provider-webdriver/src/plugin.ts new file mode 100644 index 0000000000..e1b46966b4 --- /dev/null +++ b/packages/provider-webdriver/src/plugin.ts @@ -0,0 +1,17 @@ +export { createCloudWebDriverRuntime } from './runtime.ts'; +export type { CloudWebDriverRuntimeOptions } from './runtime.ts'; +export { + appFileUploadForm, + appendUrlPath, + asRecord, + basicAuthHeader, + createHubUploadApp, + fetchProviderSessionDetails, + fetchProviderVerificationJson, + postHubAppUpload, + requireProviderDeviceOrientation, + resolveHubAppReference, + sameOsVersion, +} from './webdriver-utils.ts'; +export { cloudArtifactsReadyOrPending, urlArtifactFromDetails } from './artifact-results.ts'; +export { buildCloudWebDriverBaseCapabilities } from './runtime-session.ts'; diff --git a/packages/provider-webdriver/src/providers.ts b/packages/provider-webdriver/src/providers.ts index 814a0c19ae..a4799ba975 100644 --- a/packages/provider-webdriver/src/providers.ts +++ b/packages/provider-webdriver/src/providers.ts @@ -13,3 +13,7 @@ export function isCloudWebDriverProviderName( ): provider is CloudWebDriverKnownProviderName { return provider !== undefined && CLOUD_WEBDRIVER_KNOWN_PROVIDERS.has(provider); } + +export function isBrowserStackAppReference(value: string): boolean { + return /^bs:\/\/[\w.-]+$/.test(value); +} diff --git a/packages/provider-webdriver/src/webdriver-utils.test.ts b/packages/provider-webdriver/src/webdriver-utils.test.ts index 58d326c241..58a182c789 100644 --- a/packages/provider-webdriver/src/webdriver-utils.test.ts +++ b/packages/provider-webdriver/src/webdriver-utils.test.ts @@ -1,6 +1,25 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { trimLeadingSlash, trimTrailingSlash } from './webdriver-utils.ts'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + appendUrlPath, + asRecord, + createHubUploadApp, + fetchProviderVerificationJson, + postHubAppUpload, + resolveHubAppReference, + trimLeadingSlash, + trimTrailingSlash, +} from './webdriver-utils.ts'; +import { mkdtempForTest } from './tmp-dir.fixtures.ts'; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); test('slash trimming utilities handle slash-heavy strings without regular expressions', () => { const slashRun = '/'.repeat(10_000); @@ -15,3 +34,193 @@ test('slash trimming utilities handle slash-heavy strings without regular expres assert.equal(trimLeadingSlash(slashRun), ''); assert.equal(trimTrailingSlash(slashRun), ''); }); + +test('asRecord admits plain objects only', () => { + assert.deepEqual(asRecord({ a: 1 }), { a: 1 }); + assert.equal(asRecord([]), undefined); + assert.equal(asRecord(null), undefined); + assert.equal(asRecord('x'), undefined); +}); + +const hub = { + service: 'Hub', + endpoint: 'https://upload.example.test/app', + clientVersion: '0.0.0-test', + auth: { username: 'user', accessKey: 'key' }, + readAppReference: (body: unknown) => asRecord(body)?.ref as string | undefined, +}; + +test('the hub upload helper posts with credentials and returns the vendor reference', async () => { + const form = new FormData(); + globalThis.fetch = async (input, init) => { + assert.equal(String(input), hub.endpoint); + assert.equal(init?.method, 'POST'); + assert.equal(init?.body, form); + const headers = init?.headers as Record; + assert.equal(headers.Authorization, `Basic ${Buffer.from('user:key').toString('base64')}`); + assert.equal(headers['x-agent-device-version'], '0.0.0-test'); + return new Response(JSON.stringify({ ref: 'hub://APP1' }), { status: 200 }); + }; + assert.equal(await postHubAppUpload(form, hub), 'hub://APP1'); +}); + +test('the hub upload helper fails typed with the status on an error page or a missing reference', async () => { + for (const response of [ + new Response('502 Bad Gateway', { status: 502 }), + new Response(JSON.stringify({ message: 'ok' }), { status: 200 }), + ]) { + globalThis.fetch = async () => response; + await assert.rejects(postHubAppUpload(new FormData(), hub), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.message, 'Hub app upload failed.'); + assert.equal(error.details?.status, response.status); + return true; + }); + } +}); + +test('the hub install adapter uploads the build and launches the hinted app', async () => { + const upload = vi.fn(async () => 'hub://APP2'); + const signal = new AbortController().signal; + const result = await createHubUploadApp(upload)({ + appPath: '/builds/App.ipa', + options: { appIdentifierHint: 'com.example.app' }, + signal, + }); + assert.deepEqual(upload.mock.calls, [['/builds/App.ipa', signal]]); + assert.deepEqual(result, { + appReference: 'hub://APP2', + bundleId: 'com.example.app', + packageName: undefined, + launchTarget: 'com.example.app', + }); +}); + +test('the hub app resolver passes references through, uploads local files, and routes URLs per hub', async () => { + const tempDir = await mkdtempForTest('agent-device-hub-resolve-'); + try { + await fs.writeFile(path.join(tempDir, 'App.apk'), 'placeholder'); + const uploadFile = vi.fn(async (appPath: string) => `hub://${path.basename(appPath)}`); + const resolve = (app: string, uploadUrl?: (url: string) => Promise) => + resolveHubAppReference({ + service: 'Hub', + app, + cwd: tempDir, + referenceScheme: 'hub://', + referenceLabel: 'a hub:// app id', + uploadFile, + uploadUrl, + }); + + assert.equal(await resolve('hub://APP3'), 'hub://APP3'); + assert.equal(await resolve('HUB://APP3'), 'hub://APP3'); + assert.equal(await resolve('https://builds.example/App.apk'), 'https://builds.example/App.apk'); + assert.equal( + await resolve('https://builds.example/App.apk', async (url) => `fetched:${url}`), + 'fetched:https://builds.example/App.apk', + ); + assert.equal(await resolve('App.apk'), 'hub://App.apk'); + assert.deepEqual(uploadFile.mock.calls, [[path.join(tempDir, 'App.apk'), undefined]]); + await assert.rejects(resolve('missing.apk'), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal( + error.message, + 'Hub --provider-app must be a hub:// app id, URL, or existing local app path.', + ); + return true; + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('the hub app resolver refuses an empty reference and a directory, typed', async () => { + const tempDir = await mkdtempForTest('agent-device-hub-resolve-invalid-'); + try { + await fs.mkdir(path.join(tempDir, 'App.app')); + const uploadFile = vi.fn(async () => 'hub://never'); + const resolve = (app: string) => + resolveHubAppReference({ + service: 'Hub', + app, + cwd: tempDir, + referenceScheme: 'hub://', + referenceLabel: 'a hub:// app id', + isReference: (reference) => /^hub:\/\/\w+$/.test(reference), + uploadFile, + }); + + for (const [app, message] of [ + ['hub://', /^Hub --provider-app hub:\/\/ is not a hub:\/\/ app id\.$/], + ['hub://a b', /is not a hub:\/\/ app id/], + ['App.app', /must be an app file, not a directory: .*App\.app$/], + ] as const) { + await assert.rejects( + resolve(app), + (error: unknown) => + error instanceof AppError && error.code === 'INVALID_ARGS' && message.test(error.message), + ); + } + assert.equal(uploadFile.mock.calls.length, 0); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('appending a route keeps a query on the base endpoint', () => { + assert.equal( + appendUrlPath('https://api.example.test/v1/?region=eu', 'sessions/S%201').toString(), + 'https://api.example.test/v1/sessions/S%201?region=eu', + ); + assert.equal( + appendUrlPath('https://api.example.test/v1', 'sessions/S1').toString(), + 'https://api.example.test/v1/sessions/S1', + ); +}); + +const verificationHints = { + service: 'Hub', + unauthorizedHint: 'Check HUB_KEY.', + serviceHint: 'Retry connect or check the Hub service status.', + networkHint: 'Check network access to the hub.', +}; + +test('connection verification reports a non-JSON success typed, with its status', async () => { + globalThis.fetch = async () => new Response('maintenance', { status: 200 }); + await assert.rejects( + fetchProviderVerificationJson('https://api.example.test/apps', { + clientVersion: '0.0.0-test', + hints: verificationHints, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.message, 'Hub connection verification answer was not JSON.'); + assert.equal(error.details?.status, 200); + assert.equal(error.details?.hint, verificationHints.serviceHint); + return true; + }, + ); +}); + +test('connection verification gives each failure its provider hint', async () => { + for (const [status, code, hint] of [ + [401, 'UNAUTHORIZED', verificationHints.unauthorizedHint], + [503, 'COMMAND_FAILED', verificationHints.serviceHint], + ] as const) { + globalThis.fetch = async () => new Response('nope', { status }); + await assert.rejects( + fetchProviderVerificationJson('https://api.example.test/apps', { + clientVersion: '0.0.0-test', + hints: verificationHints, + }), + (error: unknown) => + error instanceof AppError && + error.code === code && + error.details?.status === status && + error.details?.hint === hint, + ); + } +}); diff --git a/packages/provider-webdriver/src/webdriver-utils.ts b/packages/provider-webdriver/src/webdriver-utils.ts index 5af69a9f4c..d75fd560ba 100644 --- a/packages/provider-webdriver/src/webdriver-utils.ts +++ b/packages/provider-webdriver/src/webdriver-utils.ts @@ -1,5 +1,17 @@ -import type { DeviceLease } from '@agent-device/contracts/device'; +import fs from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { + DeviceLease, + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, +} from '@agent-device/contracts/device'; +import { + PROVIDER_DEVICE_ORIENTATIONS, + type ProviderDeviceOrientation, +} from '@agent-device/contracts/remote'; import { AppError, errorMessage } from '@agent-device/kernel/errors'; +import { agentDeviceRequestHeaders } from './request-headers.ts'; export type LeaseValue = T | ((lease: DeviceLease) => T); @@ -44,9 +56,280 @@ export function trimTrailingSlash(value: string): string { return lastNonSlash === value.length - 1 ? value : value.slice(0, lastNonSlash + 1); } +/** Appends `route` to the base's path; a query on the base is kept rather than swallowing the route. */ +export function appendUrlPath(base: string | URL, route: string): URL { + const url = new URL(base); + url.pathname = `${trimTrailingSlash(url.pathname)}/${route}`; + return url; +} + export function withTrailingSlash(url: URL): URL { if (url.pathname.endsWith('/')) return url; const copy = new URL(url); copy.pathname = `${copy.pathname}/`; return copy; } + +export function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +type HubCredentials = { username: string; accessKey: string }; + +/** A multipart form carrying the local app file under the hub's field name. */ +export async function appFileUploadForm(appPath: string, fileField: string): Promise { + const form = new FormData(); + form.set(fileField, new Blob([await readFile(appPath)]), path.basename(appPath)); + return form; +} + +/** + * POSTs an app upload to a hosted hub and returns the hub's app reference. A non-2xx answer, a + * body that is not JSON, or one without a reference is `COMMAND_FAILED` with the HTTP status. + */ +export async function postHubAppUpload( + form: FormData, + options: { + service: string; + endpoint: string | URL; + clientVersion: string; + auth: HubCredentials; + readAppReference: (body: unknown) => string | undefined; + }, + signal?: AbortSignal, +): Promise { + const response = await fetch(options.endpoint, { + method: 'POST', + headers: { + ...agentDeviceRequestHeaders(options.clientVersion), + Authorization: basicAuthHeader(options.auth), + }, + body: form, + signal, + }); + const json = await readProviderJsonBody(response); + const appReference = options.readAppReference(json); + if (!response.ok || !appReference) { + throw new AppError('COMMAND_FAILED', `${options.service} app upload failed.`, { + status: response.status, + response: json, + }); + } + return appReference; +} + +/** The `install` adapter of a hosted hub: upload the local build, then launch the hinted app. */ +export function createHubUploadApp( + upload: (appPath: string, signal?: AbortSignal) => Promise, +): (params: { + appPath: string; + options?: ProviderDeviceInstallOptions; + signal?: AbortSignal; +}) => Promise { + return async ({ appPath, options, signal }) => ({ + appReference: await upload(appPath, signal), + bundleId: options?.appIdentifierHint, + packageName: options?.packageNameHint, + launchTarget: options?.appIdentifierHint ?? options?.packageNameHint, + }); +} + +/** + * Turns `--provider-app` into a reference the hub accepts: its own reference scheme passes + * through, a public URL passes through unless the hub only takes its own references (then + * `uploadUrl` has the hub fetch it), and anything else must be a local file to upload. + */ +export async function resolveHubAppReference(options: { + service: string; + app: string; + cwd?: string; + referenceScheme: string; + /** How the scheme reads in the error message, e.g. `a bs:// app id`. */ + referenceLabel: string; + /** Validates a canonical reference; by default any non-empty id after the scheme is accepted. */ + isReference?: (reference: string) => boolean; + uploadFile: (appPath: string, signal?: AbortSignal) => Promise; + uploadUrl?: (url: string, signal?: AbortSignal) => Promise; + signal?: AbortSignal; +}): Promise { + const { app } = options; + const reference = canonicalHubAppReference(app, options.referenceScheme); + if (reference !== undefined) { + const isReference = + options.isReference ?? ((value: string) => value.length > options.referenceScheme.length); + if (isReference(reference)) return reference; + throw new AppError( + 'INVALID_ARGS', + `${options.service} --provider-app ${app} is not ${options.referenceLabel}.`, + { providerApp: app }, + ); + } + if (/^https?:\/\//i.test(app)) { + return options.uploadUrl ? await options.uploadUrl(app, options.signal) : app; + } + const appPath = path.resolve(options.cwd ?? process.cwd(), app); + const stat = fs.statSync(appPath, { throwIfNoEntry: false }); + if (!stat) { + throw new AppError( + 'INVALID_ARGS', + `${options.service} --provider-app must be ${options.referenceLabel}, URL, or existing local app path.`, + { providerApp: app }, + ); + } + if (!stat.isFile()) { + throw new AppError( + 'INVALID_ARGS', + `${options.service} --provider-app must be an app file, not a directory: ${appPath}`, + { + providerApp: app, + hint: 'Zip an iOS simulator .app bundle and pass the .zip, or pass the .ipa, .apk, or .aab.', + }, + ); + } + return await options.uploadFile(appPath, options.signal); +} + +/** URI schemes are case-insensitive, so `LT://id` is the hub reference `lt://id`. */ +function canonicalHubAppReference(app: string, scheme: string): string | undefined { + if (app.slice(0, scheme.length).toLowerCase() !== scheme) return undefined; + return `${scheme}${app.slice(scheme.length)}`; +} + +const PROVIDER_API_TIMEOUT_MS = 15_000; + +/** The provider rejected or could not answer a verification call; typed so callers never sniff text. */ +export type ProviderJsonFailureHints = { + service: string; + unauthorizedHint: string; + /** For any other non-2xx answer, or a 2xx answer that is not JSON. */ + serviceHint: string; + networkHint: string; +}; + +/** + * Fetches JSON from a hosted provider's API during connection verification. A 401/403 is + * `UNAUTHORIZED` with a credential hint, any other non-2xx or a body that is not JSON is + * `COMMAND_FAILED` with the status, and a transport failure is wrapped so its cause survives + * without leaking the credentials. + */ +export async function fetchProviderVerificationJson( + endpoint: string | URL, + options: { + clientVersion: string; + auth?: { username: string; accessKey: string }; + hints: ProviderJsonFailureHints; + }, +): Promise { + const { service, unauthorizedHint, serviceHint, networkHint } = options.hints; + try { + const response = await fetch(endpoint, { + headers: { + ...agentDeviceRequestHeaders(options.clientVersion), + ...(options.auth ? { Authorization: basicAuthHeader(options.auth) } : {}), + }, + signal: AbortSignal.timeout(PROVIDER_API_TIMEOUT_MS), + }); + if (!response.ok) { + const unauthorized = response.status === 401 || response.status === 403; + throw new AppError( + unauthorized ? 'UNAUTHORIZED' : 'COMMAND_FAILED', + `${service} rejected connection verification.`, + { + status: response.status, + hint: unauthorized ? unauthorizedHint : serviceHint, + }, + ); + } + const json = await readProviderJsonBody(response); + if (json === undefined) { + throw new AppError( + 'COMMAND_FAILED', + `${service} connection verification answer was not JSON.`, + { status: response.status, hint: serviceHint }, + ); + } + return json; + } catch (error) { + if (error instanceof AppError) throw error; + throw new AppError( + 'COMMAND_FAILED', + `${service} connection verification failed.`, + { hint: networkHint }, + error, + ); + } +} + +/** + * Fetches a provider's session-details JSON with basic auth under a deadline. A transport failure, + * a non-2xx answer, or a body that is not a JSON object is `COMMAND_FAILED`. + */ +export async function fetchProviderSessionDetails( + endpoint: string | URL, + options: { + clientVersion: string; + auth: { username: string; accessKey: string }; + service: string; + }, +): Promise> { + let response: Response; + let json: unknown; + try { + response = await fetch(endpoint, { + headers: { + ...agentDeviceRequestHeaders(options.clientVersion), + Authorization: basicAuthHeader(options.auth), + }, + signal: AbortSignal.timeout(PROVIDER_API_TIMEOUT_MS), + }); + json = await readProviderJsonBody(response); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `${options.service} session details lookup failed.`, + { hint: `Check network access to the ${options.service} API, then retry.` }, + error, + ); + } + const details = asRecord(json); + if (!response.ok || !details) { + throw new AppError('COMMAND_FAILED', `${options.service} session details lookup failed.`, { + status: response.status, + response: json, + }); + } + return details; +} + +/** A provider response body parsed as JSON, or `undefined` when it is empty or not JSON (a gateway error page). */ +async function readProviderJsonBody(response: Response): Promise { + const text = await response.text(); + if (text.length === 0) return undefined; + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +/** `1.0` and `1` name the same OS release on BrowserStack's catalog; TestMu's hub matches spellings exactly. */ +export function sameOsVersion(left: string, right: string): boolean { + const normalize = (value: string) => value.replace(/(?:\.0)+$/, ''); + return normalize(left) === normalize(right); +} + +/** Validates a device-orientation flag against the shared enum before it reaches a hub that would ignore it. */ +export function requireProviderDeviceOrientation( + spec: { flag: string; capability: string }, + value: string, +): ProviderDeviceOrientation { + const match = PROVIDER_DEVICE_ORIENTATIONS.find((orientation) => orientation === value); + if (match) return match; + throw new AppError('INVALID_ARGS', `Invalid ${spec.flag} value: ${value}.`, { + hint: `Use ${PROVIDER_DEVICE_ORIENTATIONS.join('|')}.`, + flag: spec.flag, + capability: spec.capability, + }); +} diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 27d9c5c564..b7312fca31 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -12,7 +12,15 @@ import { resolveCloudWebDriverConnectProfile } from './cloud-webdriver-profile.t import { resolveLimrunConnectProfile } from './limrun-profile.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 }; @@ -73,7 +81,7 @@ const CONNECT_PROVIDER_ADAPTERS = { resolve: resolveLimrunConnectProfile, verify: verifyLimrun, }, -} satisfies Record; +} satisfies Record; export async function resolveConnectProviderProfile(options: { provider?: ConnectProvider; @@ -103,12 +111,47 @@ export async function resolveConnectProviderProfile(options: { } 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 }; } @@ -123,10 +166,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..d7fd72b2ee 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -3,9 +3,18 @@ import { 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; +export const BUILTIN_CONNECT_PROVIDERS = [ + 'cloud', + 'proxy', + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + 'limrun', +] as const; +export type BuiltinConnectProvider = (typeof BUILTIN_CONNECT_PROVIDERS)[number]; +export type ConnectProvider = BuiltinConnectProvider | (string & {}); export type ConnectionProviderCapabilities = { leaseKind: 'proxy' | 'direct-device-provider' | 'remote-provider'; @@ -18,7 +27,12 @@ export type ConnectionProviderCapabilities = { }; export function isConnectProviderName(value: string | undefined): value is ConnectProvider { - return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); + return ( + value === 'cloud' || + value === 'proxy' || + isDirectDeviceConnectProvider(value) || + pluginConnectionCapabilities(value) !== undefined + ); } function isDirectDeviceConnectProvider( @@ -28,13 +42,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 +50,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/plugins/connection.ts b/src/plugins/connection.ts new file mode 100644 index 0000000000..734b14dc2d --- /dev/null +++ b/src/plugins/connection.ts @@ -0,0 +1,32 @@ +import type { CliFlags } from '@agent-device/contracts/command'; +import type { 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 type { ConnectionProviderCapabilities } from '../cli/connection/provider-policy.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..66ce4e70db --- /dev/null +++ b/src/plugins/host.ts @@ -0,0 +1,63 @@ +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { AppError } from '@agent-device/kernel/errors'; +import { execFailureDetails, runCmd } from '@agent-device/host-kit/command'; +import { findProjectRoot, readVersion } from '@agent-device/host-kit/version'; +import type { ProviderPluginHost } from '../sdk/plugins.ts'; + +let hostBound = false; + +export function bindPluginHost(): void { + if (hostBound) return; + const root = pathToFileURL(`${findProjectRoot()}/`).href; + const source = import.meta.url.startsWith(`${root}src/`); + register( + `data:text/javascript,${encodeURIComponent(` + let host; + export function initialize(data) { host = data; } + export async function resolve(specifier, context, nextResolve) { + if (specifier === 'agent-device' && host.source) { + return { url: new URL('src/sdk/index.ts', host.root).href, shortCircuit: true }; + } + if (specifier === 'agent-device' || specifier.startsWith('agent-device/')) { + return nextResolve(specifier, { ...context, parentURL: new URL('plugin-host.mjs', host.root).href }); + } + return nextResolve(specifier, context); + } + `)}`, + { parentURL: import.meta.url, data: { root, source } }, + ); + hostBound = true; +} + +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..5fd08fde5a 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,44 @@ 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); + const profile = await withPluginConnection( + 'example', + env, + async (connection) => await connection.resolve({ flags: {}, 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: {}, 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..0b91f944e5 100644 --- a/src/plugins/load.ts +++ b/src/plugins/load.ts @@ -5,10 +5,15 @@ import type { PlatformRuntimeProviderModule } from '@agent-device/contracts/plat import type { ProviderPluginHost } from '../sdk/plugins.ts'; import { installedPlugins } from './store.ts'; import { resolvePluginEntry, assertUniquePluginProviders } from './manifest.ts'; +import { bindPluginHost, createPluginHost } from './host.ts'; +import type { PluginConnection } from './connection.ts'; +import { BUILTIN_CONNECT_PROVIDERS } from '../cli/connection/provider-policy.ts'; +import type { WebDriverPluginOptions } from '../sdk/plugin-webdriver.ts'; type ProviderPluginRegistration = Readonly<{ runtime: ProviderDeviceRuntime; platformModule: PlatformRuntimeProviderModule; + connection?: PluginConnection; }>; export async function loadProviderPlugins( @@ -20,22 +25,44 @@ export async function loadProviderPlugins( const registrations: ProviderPluginRegistration[] = []; try { for (const plugin of plugins) { + bindPluginHost(); 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 ( + const host = createPluginHost(env, plugin.selection.options); + const result = 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), - }), - ); + ) => + | 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 = 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}`); } @@ -51,7 +78,10 @@ export async function loadProviderPlugins( 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' + typeof registration.platformModule.loadRuntime !== 'function' || + (plugin.agentDevicePlugin.connection && + (typeof registration.connection?.resolve !== 'function' || + typeof registration.connection?.verify !== 'function')) ) { throw new AppError( 'INVALID_ARGS', @@ -65,3 +95,21 @@ export async function loadProviderPlugins( throw error; } } + +export async function withPluginConnection( + provider: string, + env: NodeJS.ProcessEnv, + use: (connection: PluginConnection) => Promise, +): Promise { + const registrations = await loadProviderPlugins(env, BUILTIN_CONNECT_PROVIDERS); + 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 use(connection); + } finally { + await Promise.allSettled(registrations.map(async ({ runtime }) => await runtime.shutdown())); + } +} diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 44b6d2a1c9..c30f6b113b 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -1,12 +1,18 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; +import type { ConnectionProviderCapabilities } from '../cli/connection/provider-policy.ts'; 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( @@ -53,6 +59,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/plugins/store.test.ts b/src/plugins/store.test.ts index d10648a187..32fcef3d2c 100644 --- a/src/plugins/store.test.ts +++ b/src/plugins/store.test.ts @@ -20,6 +20,7 @@ function installFixture(name = packageName, apiVersion = 1, provider = 'example' assert.equal(argv[argv.indexOf('--prefix') + 1], options!.cwd); assert.ok(argv.includes('--global=false')); assert.ok(argv.includes('--ignore-scripts')); + assert.ok(argv.includes('--omit=peer')); assert.ok(argv.includes('--workspaces=false')); assert.ok(argv.includes('--package-lock=true')); writePlugin(options!.cwd!, name, apiVersion, provider); diff --git a/src/plugins/store.ts b/src/plugins/store.ts index 4435a624cb..000653787c 100644 --- a/src/plugins/store.ts +++ b/src/plugins/store.ts @@ -112,6 +112,7 @@ async function stagePlugin( [ 'install', '--ignore-scripts', + '--omit=peer', '--no-audit', '--no-fund', '--global=false', 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/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 19aa0b8749..3ff7ec5e27 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -97,6 +97,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/plugins.md b/website/docs/docs/plugins.md index a668163fa2..24618c8451 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -27,8 +27,29 @@ 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. + + +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 +{ + "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 and omits peer dependencies. Runtime imports of public `agent-device` exports resolve against the running host, so plugins can use its `AppError` without installing a second core package. Import types with `import type` to keep them out of the runtime dependency graph. From f3e6d0474b4f97982c4f6041f451259e234fdbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 16:54:29 +0200 Subject: [PATCH 03/11] chore(gates): expose the optional WebDriver plugin configuration --- .fallowrc.json | 1 + package.json | 4 ++++ packages/provider-webdriver/package.json | 4 ++++ tsdown.config.ts | 1 + 4 files changed, 10 insertions(+) diff --git a/.fallowrc.json b/.fallowrc.json index 635559ff05..bd9b3ad99a 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -10,6 +10,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", diff --git a/package.json b/package.json index 396323377e..78e463ea72 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": { 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/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', From 698503e2a5e9c0ca42a260b9c58cbfc1c646b193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 17:38:02 +0200 Subject: [PATCH 04/11] feat: ship Doublespeed as an optional provider plugin --- .../src/ios-snapshot-acquisition.ts | 9 ++ .../src/ios-snapshot-planning.test.ts | 1 + packages/contracts/src/ios-snapshot.ts | 5 +- packages/kernel/src/app-error.ts | 14 -- packages/kernel/src/errors.test.ts | 17 +++ packages/kernel/src/errors.ts | 27 +++- packages/kernel/src/snapshot.ts | 7 +- .../src/app-log-runtime.ts | 15 +- .../provider-doublespeed/src/facts-runtime.ts | 10 +- packages/provider-doublespeed/src/index.ts | 5 - .../src/interaction-operations.test.ts | 15 +- .../src/interaction-operations.ts | 29 ++-- packages/provider-doublespeed/src/ios.test.ts | 3 +- packages/provider-doublespeed/src/plugin.ts | 58 ++++++++ .../test/package-smoke.mjs | 7 + .../provider-doublespeed/tsdown.config.ts | 19 +++ packages/provider-webdriver/src/plugin.ts | 15 -- .../src/provider-definitions.ts | 64 ++------- scripts/check-provider-plugin.mjs | 130 ++++++++++++++++++ src/__tests__/cloud-connect-profile.test.ts | 45 +++++- .../provider-device-runtimes.test.ts | 8 +- .../connection/connect-provider-adapters.ts | 2 +- src/cli/connection/doublespeed-profile.ts | 74 ---------- src/cli/connection/provider-policy.ts | 16 +-- src/commands/capture/runtime/snapshot.test.ts | 1 + src/commands/schema/cli-help-topics.test.ts | 1 + src/commands/schema/cli-help.ts | 1 + src/commands/schema/command-overrides.ts | 2 +- src/plugins/host.ts | 29 +--- src/plugins/load.test.ts | 12 +- src/plugins/load.ts | 12 +- src/plugins/manifest.ts | 12 +- src/plugins/store.test.ts | 1 - src/plugins/store.ts | 1 - src/provider-device-runtimes.ts | 71 +++------- src/provider-doublespeed-dependencies.ts | 39 ------ website/docs/docs/doublespeed.md | 10 ++ website/docs/docs/plugins.md | 2 +- 38 files changed, 447 insertions(+), 342 deletions(-) delete mode 100644 packages/kernel/src/app-error.ts delete mode 100644 packages/provider-doublespeed/src/index.ts create mode 100644 packages/provider-doublespeed/src/plugin.ts create mode 100644 packages/provider-doublespeed/test/package-smoke.mjs create mode 100644 packages/provider-doublespeed/tsdown.config.ts create mode 100644 scripts/check-provider-plugin.mjs delete mode 100644 src/cli/connection/doublespeed-profile.ts delete mode 100644 src/provider-doublespeed-dependencies.ts 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/ios-snapshot.ts b/packages/contracts/src/ios-snapshot.ts index 5f84381f26..173bb931de 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/kernel/src/app-error.ts b/packages/kernel/src/app-error.ts deleted file mode 100644 index d748efb9b2..0000000000 --- a/packages/kernel/src/app-error.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { AppErrorCode, AppErrorDetails } from './errors.ts'; - -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; - } -} diff --git a/packages/kernel/src/errors.test.ts b/packages/kernel/src/errors.test.ts index 3bf8104af2..451a7566e1 100644 --- a/packages/kernel/src/errors.test.ts +++ b/packages/kernel/src/errors.test.ts @@ -117,3 +117,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 42dd4f1a85..467b630d94 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -1,5 +1,3 @@ -import { AppError } from './app-error.ts'; -export { AppError } from './app-error.ts'; import { redactDiagnosticData, sanitizeErrorCause as normalizeErrorCause } from './redaction.ts'; /** @@ -38,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', diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index a61a075abe..5632f7a59f 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -458,7 +458,12 @@ export function formatRole(type: string): string { export type SnapshotProvenance = | { backend: 'xctest'; - producer: 'apple-runner' | 'simulator-ax-bridge' | 'appium-source' | 'limrun-ios-tree' | 'doublespeed-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/src/app-log-runtime.ts b/packages/provider-doublespeed/src/app-log-runtime.ts index 42e85b2d1c..13937afaf0 100644 --- a/packages/provider-doublespeed/src/app-log-runtime.ts +++ b/packages/provider-doublespeed/src/app-log-runtime.ts @@ -106,17 +106,14 @@ export function createDoublespeedPlatformRuntimeOwner( touch: liveSessionUnavailable, elementText: liveSessionUnavailable, back: liveSessionUnavailable, - home: liveSessionUnavailable, + systemButton: liveSessionUnavailable, orientation: liveSessionUnavailable, + fold: liveSessionUnavailable, tvRemote: liveSessionUnavailable, - keyboardStatus: liveSessionUnavailable, - keyboardDismiss: liveSessionUnavailable, - keyboardEnter: liveSessionUnavailable, - readClipboard: liveSessionUnavailable, - writeClipboard: liveSessionUnavailable, - appSwitcher: liveSessionUnavailable, + keyboard: liveSessionUnavailable, + clipboard: liveSessionUnavailable, triggerAppEvent: liveSessionUnavailable, - setSetting: liveSessionUnavailable, + settings: liveSessionUnavailable, readAlert: liveSessionUnavailable, awaitAlert: liveSessionUnavailable, acceptAlert: liveSessionUnavailable, @@ -263,7 +260,7 @@ function bindDoublespeedAppLogs( ...recovery, networkDump: async (input) => { const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); - const dump = readRecentNetworkTrafficFromText(recent.text, { + const { dump } = readRecentNetworkTrafficFromText(recent.text, { ...input, path: recent.path, exists: recent.exists, diff --git a/packages/provider-doublespeed/src/facts-runtime.ts b/packages/provider-doublespeed/src/facts-runtime.ts index a3bbb0f926..f721917a6f 100644 --- a/packages/provider-doublespeed/src/facts-runtime.ts +++ b/packages/provider-doublespeed/src/facts-runtime.ts @@ -18,7 +18,7 @@ import { import { doublespeedAlertOperationFacts, doublespeedAppEventOperationFacts, - doublespeedAppSwitcherOperationFacts, + doublespeedSystemButtonOperationFacts, doublespeedClipboardOperationFacts, doublespeedInteractionOperationFacts, doublespeedKeyboardOperationFacts, @@ -168,15 +168,15 @@ export function doublespeedRuntimeFacts( ...screenshotRuntimeOperationFacts({ capture: available }), ...selectorObservationRuntimeOperationFacts({ findText: observationUnavailable, - findSelector: observationUnavailable, }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), + setFoldPose: viewportUnavailable, ...doublespeedInteractionOperationFacts(device), ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), ...doublespeedNavigationOperationFacts(device), ...doublespeedKeyboardOperationFacts(device), ...doublespeedClipboardOperationFacts(device), - ...doublespeedAppSwitcherOperationFacts(device), + ...doublespeedSystemButtonOperationFacts(device), ...doublespeedAppEventOperationFacts(device), ...doublespeedSettingsOperationFacts(device), ...doublespeedAlertOperationFacts(device), @@ -228,14 +228,14 @@ export function doublespeedRecoveryFacts( ...screenshotRuntimeOperationFacts({ capture: liveSessionUnavailable }), ...selectorObservationRuntimeOperationFacts({ findText: liveSessionUnavailable, - findSelector: liveSessionUnavailable, }), ...viewportRuntimeOperationFacts({ setViewport: liveSessionUnavailable }), + setFoldPose: liveSessionUnavailable, ...doublespeedInteractionOperationFacts(device, liveSessionUnavailable), ...doublespeedNavigationOperationFacts(device, liveSessionUnavailable), ...doublespeedKeyboardOperationFacts(device, liveSessionUnavailable), ...doublespeedClipboardOperationFacts(device, liveSessionUnavailable), - ...doublespeedAppSwitcherOperationFacts(device, liveSessionUnavailable), + ...doublespeedSystemButtonOperationFacts(device, liveSessionUnavailable), ...doublespeedAppEventOperationFacts(device, liveSessionUnavailable), ...doublespeedSettingsOperationFacts(device, liveSessionUnavailable), ...doublespeedAlertOperationFacts(device, liveSessionUnavailable), diff --git a/packages/provider-doublespeed/src/index.ts b/packages/provider-doublespeed/src/index.ts deleted file mode 100644 index 30e69b8f0a..0000000000 --- a/packages/provider-doublespeed/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { DOUBLESPEED_PROVIDER } from './device.ts'; -export { createDoublespeedRuntime } from './runtime.ts'; -export { verifyDoublespeedConnection } from './connection-verification.ts'; - -export type { DoublespeedRuntimeDependencies } from './runtime-dependencies.ts'; diff --git a/packages/provider-doublespeed/src/interaction-operations.test.ts b/packages/provider-doublespeed/src/interaction-operations.test.ts index 62bb61f11b..2fd94ae911 100644 --- a/packages/provider-doublespeed/src/interaction-operations.test.ts +++ b/packages/provider-doublespeed/src/interaction-operations.test.ts @@ -4,7 +4,7 @@ import { expect, test } from 'vitest'; import { doublespeedAlertOperationFacts, doublespeedAppEventOperationFacts, - doublespeedAppSwitcherOperationFacts, + doublespeedSystemButtonOperationFacts, doublespeedClipboardOperationFacts, doublespeedInteractionOperationFacts, doublespeedKeyboardOperationFacts, @@ -37,7 +37,7 @@ test('the session admits tap, long press, selector taps and scroll but no gestur test('navigation admits home and orientation but refuses back and tv-remote', () => { const facts = doublespeedNavigationOperationFacts(device); - expect(facts.home).toEqual({ available: true }); + expect(doublespeedSystemButtonOperationFacts(device).home).toEqual({ available: true }); expect(facts.setOrientation).toEqual({ available: true }); expect(facts.back).toEqual({ available: false, @@ -49,7 +49,9 @@ test('navigation admits home and orientation but refuses back and tv-remote', () test('a dead session closes every cell with the same reason', () => { const navigation = doublespeedNavigationOperationFacts(device, liveSessionUnavailable); - expect(navigation.home).toEqual(liveSessionUnavailable); + expect(doublespeedSystemButtonOperationFacts(device, liveSessionUnavailable).home).toEqual( + liveSessionUnavailable, + ); expect(navigation.setOrientation).toEqual(liveSessionUnavailable); expect(navigation.back).toEqual(liveSessionUnavailable); const interaction = doublespeedInteractionOperationFacts(device, liveSessionUnavailable); @@ -72,7 +74,7 @@ test('system leaves the session does not serve are refused with their own wordin available: false, }); expect(doublespeedSettingsOperationFacts(device).setSetting).toMatchObject({ available: false }); - expect(doublespeedAppSwitcherOperationFacts(device).appSwitcher).toMatchObject({ + expect(doublespeedSystemButtonOperationFacts(device).appSwitcher).toMatchObject({ available: false, }); expect(doublespeedKeyboardOperationFacts(device).keyboardEnter).toMatchObject({ @@ -95,7 +97,10 @@ test('binding exposes only the admitted navigation operations and drives the int device, signal: new AbortController().signal, resolveInteractor: () => interactor, - facts: doublespeedNavigationOperationFacts(device), + facts: { + ...doublespeedNavigationOperationFacts(device), + ...doublespeedSystemButtonOperationFacts(device), + }, }); expect(operations.home).toBeTypeOf('function'); diff --git a/packages/provider-doublespeed/src/interaction-operations.ts b/packages/provider-doublespeed/src/interaction-operations.ts index b93b433da7..8a51742028 100644 --- a/packages/provider-doublespeed/src/interaction-operations.ts +++ b/packages/provider-doublespeed/src/interaction-operations.ts @@ -12,11 +12,10 @@ import { bindProviderScrollInteractor, scrollRuntimeOperationFacts, } from '@agent-device/contracts/scroll-runtime'; -import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-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 { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-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'; @@ -102,6 +101,7 @@ function doublespeedGestureFacts( ): GestureRuntimeOperationFacts { const gesture = cell === available ? gestureUnavailable : cell; return gestureRuntimeOperationFacts({ + unsupported: gestureUnavailable, plan: gesture, directionalFling: gesture, multiTouch: gesture, @@ -123,6 +123,7 @@ export function doublespeedInteractionOperationFacts( ...focusRuntimeOperationFacts({ focus: cell }), ...typeTextRuntimeOperationFacts({ type: cell }), ...touchRuntimeOperationFacts({ + unsupported: unsupportedTouch, tap: cell, tapRef: unsupportedTouch, longPress: cell, @@ -176,7 +177,7 @@ export function doublespeedNavigationOperationFacts( ) { return Object.freeze({ ...backRuntimeOperationFacts({ back: liveSessionUnavailable ?? backUnavailable }), - ...homeRuntimeOperationFacts({ home: liveSessionUnavailable ?? available }), + ...orientationRuntimeOperationFacts({ orientation: liveSessionUnavailable ?? available }), ...tvRemoteRuntimeOperationFacts({ tvRemote: liveSessionUnavailable ?? tvRemoteUnavailable }), }); @@ -188,7 +189,12 @@ export function doublespeedKeyboardOperationFacts( ) { const cell = liveSessionUnavailable ?? keyboardUnavailable; return Object.freeze({ - ...keyboardRuntimeOperationFacts({ status: cell, dismiss: cell, enter: cell }), + ...keyboardRuntimeOperationFacts({ + unsupported: cell, + status: cell, + dismiss: cell, + enter: cell, + }), }); } @@ -197,7 +203,9 @@ export function doublespeedClipboardOperationFacts( liveSessionUnavailable?: RuntimeOperationUnavailability, ) { const cell = liveSessionUnavailable ?? clipboardUnavailable; - return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: cell, write: cell }) }); + return Object.freeze({ + ...clipboardRuntimeOperationFacts({ unsupported: cell, read: cell, write: cell }), + }); } export function doublespeedAlertOperationFacts( @@ -210,12 +218,14 @@ export function doublespeedAlertOperationFacts( }); } -export function doublespeedAppSwitcherOperationFacts( +export function doublespeedSystemButtonOperationFacts( _device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, ) { return Object.freeze({ - ...appSwitcherRuntimeOperationFacts({ + ...systemButtonRuntimeOperationFacts({ + unsupported: unsupportedTouch, + home: liveSessionUnavailable ?? available, appSwitcher: liveSessionUnavailable ?? appSwitcherUnavailable, }), }); @@ -236,6 +246,9 @@ export function doublespeedSettingsOperationFacts( liveSessionUnavailable?: RuntimeOperationUnavailability, ) { return Object.freeze({ - ...settingsRuntimeOperationFacts({ setSetting: liveSessionUnavailable ?? settingsUnavailable }), + ...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 index 398f8d6e08..7d5309d7a9 100644 --- a/packages/provider-doublespeed/src/ios.test.ts +++ b/packages/provider-doublespeed/src/ios.test.ts @@ -47,6 +47,7 @@ test('snapshot stamps the xctest channel with its own producer', async () => { 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([ @@ -69,7 +70,7 @@ test('routes open, deep links, home and orientation through the session', async 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.home?.(); await interactor.setOrientation('landscape-left'); await interactor.tapElementSelector?.({ key: 'text', value: 'Continue' }); diff --git a/packages/provider-doublespeed/src/plugin.ts b/packages/provider-doublespeed/src/plugin.ts new file mode 100644 index 0000000000..c72b2eb9e7 --- /dev/null +++ b/packages/provider-doublespeed/src/plugin.ts @@ -0,0 +1,58 @@ +import type { ProviderPluginHost } from 'agent-device/plugins'; +import { createDoublespeedRuntime } from './runtime.ts'; +import { verifyDoublespeedConnection } from './connection-verification.ts'; +import type { CliFlags } from '@agent-device/contracts/command'; + +export default function doublespeedPlugin(host: ProviderPluginHost) { + 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/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/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/src/plugin.ts b/packages/provider-webdriver/src/plugin.ts index e1b46966b4..735b7ed23e 100644 --- a/packages/provider-webdriver/src/plugin.ts +++ b/packages/provider-webdriver/src/plugin.ts @@ -1,17 +1,2 @@ export { createCloudWebDriverRuntime } from './runtime.ts'; export type { CloudWebDriverRuntimeOptions } from './runtime.ts'; -export { - appFileUploadForm, - appendUrlPath, - asRecord, - basicAuthHeader, - createHubUploadApp, - fetchProviderSessionDetails, - fetchProviderVerificationJson, - postHubAppUpload, - requireProviderDeviceOrientation, - resolveHubAppReference, - sameOsVersion, -} from './webdriver-utils.ts'; -export { cloudArtifactsReadyOrPending, urlArtifactFromDetails } from './artifact-results.ts'; -export { buildCloudWebDriverBaseCapabilities } from './runtime-session.ts'; diff --git a/packages/provider-webdriver/src/provider-definitions.ts b/packages/provider-webdriver/src/provider-definitions.ts index b2de85b689..313b3fe84c 100644 --- a/packages/provider-webdriver/src/provider-definitions.ts +++ b/packages/provider-webdriver/src/provider-definitions.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; import type { CloudArtifactsResult } from '@agent-device/contracts/observability'; import type { LeaseLifecycleContext } from '@agent-device/contracts/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -17,7 +15,7 @@ import { buildBrowserStackCapabilities, createBrowserStackUploadApp, listBrowserStackCloudArtifacts, - uploadBrowserStackApp, + resolveBrowserStackAppReference, } from './browserstack.ts'; import { buildBrowserStackDeviceFeatureCapabilities, @@ -108,22 +106,24 @@ export function createCloudWebDriverProviderDefinitions( 'providerOsVersion', 'BrowserStack requires --provider-os-version .', ); - const app = await resolveBrowserStackAppReference({ - clientVersion: dependencies.clientVersion, - app: requireFlag( + const app = await resolveBrowserStackAppReference( + requireFlag( request, 'providerApp', 'BrowserStack requires --provider-app .', ), - cwd: request.cwd, - username, - accessKey, - uploadEndpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, - // A local IPA/APK upload can run long (130 MB is routine); an - // upload is not a billed resource, so the request's cancellation - // may simply abort it — unlike the session creation that follows. - signal: request.signal, - }); + { + clientVersion: dependencies.clientVersion, + cwd: request.cwd, + username, + accessKey, + endpoint: env.BROWSERSTACK_APP_UPLOAD_ENDPOINT, + // A local IPA/APK upload can run long (130 MB is routine); an + // upload is not a billed resource, so the request's cancellation + // may simply abort it — unlike the session creation that follows. + signal: request.signal, + }, + ); return { ...base, platform, @@ -249,40 +249,6 @@ export function createCloudWebDriverProviderDefinitions( ]; } -async function resolveBrowserStackAppReference(options: { - clientVersion: string; - app: string; - cwd?: string; - username: string; - accessKey: string; - uploadEndpoint?: string; - signal?: AbortSignal; -}): Promise { - if (isProviderAppReference(options.app)) return options.app; - const appPath = path.resolve(options.cwd ?? process.cwd(), options.app); - if (!fs.existsSync(appPath)) { - throw new AppError( - 'INVALID_ARGS', - 'BrowserStack --provider-app must be a bs:// app id, URL, or existing local app path.', - { providerApp: options.app }, - ); - } - return await uploadBrowserStackApp( - appPath, - { - clientVersion: options.clientVersion, - username: options.username, - accessKey: options.accessKey, - endpoint: options.uploadEndpoint, - }, - options.signal, - ); -} - -function isProviderAppReference(value: string): boolean { - return value.startsWith('bs://') || /^https?:\/\//.test(value); -} - function requireRequest( req: LeaseLifecycleContext | undefined, providerLabel: string, diff --git a/scripts/check-provider-plugin.mjs b/scripts/check-provider-plugin.mjs new file mode 100644 index 0000000000..4ab7e5d21d --- /dev/null +++ b/scripts/check-provider-plugin.mjs @@ -0,0 +1,130 @@ +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) { + 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')); + 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/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 7049a28be7..6c21f039c8 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -14,8 +14,14 @@ import { import type { AgentDeviceClient } from '../agent-device-client.ts'; import { resolveCloudWebDriverConnectProfile } from '../cli/connection/cloud-webdriver-profile.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { verifyDoublespeedConnection } from '@agent-device/provider-doublespeed'; +import { verifyDoublespeedConnection } from '../../packages/provider-doublespeed/src/connection-verification.ts'; import { verifyLimrunConnection } from '@agent-device/provider-limrun'; +import doublespeedPlugin from '../../packages/provider-doublespeed/src/plugin.ts'; +import { createPluginHost } from '../plugins/host.ts'; +import { selectPlugin, pluginHome } from '../plugins/plugin.fixtures.ts'; +import { installedPlugins } from '../plugins/store.ts'; +import manifest from '../../packages/provider-doublespeed/package.json' with { type: 'json' }; +import type { PluginConnection } from '../plugins/connection.ts'; import { providerWebDriver } from '../provider-webdriver.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; @@ -29,16 +35,36 @@ vi.mock('@agent-device/provider-limrun', async (importOriginal) => ({ verifyLimrunConnection: vi.fn(), })); -vi.mock('@agent-device/provider-doublespeed', async (importOriginal) => ({ - ...(await importOriginal()), - verifyDoublespeedConnection: vi.fn(), -})); +vi.mock( + '../../packages/provider-doublespeed/src/connection-verification.ts', + async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../../packages/provider-doublespeed/src/connection-verification.ts') + >()), + verifyDoublespeedConnection: vi.fn(), + }), +); vi.mock('../provider-webdriver.ts', () => ({ providerWebDriver: { verifyConnection: vi.fn() }, })); +vi.mock('../plugins/load.ts', () => ({ + withPluginConnection: async ( + _provider: string, + env: NodeJS.ProcessEnv, + use: (connection: PluginConnection) => Promise, + ) => { + const registration = doublespeedPlugin(createPluginHost(env, undefined)); + try { + return await use(registration.connection); + } finally { + await registration.runtime.shutdown(); + } + }, +})); afterEach(() => { + vi.unstubAllEnvs(); vi.clearAllMocks(); vi.unstubAllGlobals(); }); @@ -49,6 +75,14 @@ const mockedVerifyDoublespeedConnection = vi.mocked(verifyDoublespeedConnection) const mockedVerifyWebDriverConnection = vi.mocked(providerWebDriver.verifyConnection); 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', @@ -252,6 +286,7 @@ test('connect doublespeed generates an iOS-only local daemon remote profile', as /remote-connections\/generated\/doublespeed-[a-f0-9]{16}\.json$/, ); assert.deepEqual(readGeneratedConfigKeys(state.remoteConfigPath), [ + 'clientId', 'daemonTransport', 'leaseBackend', 'leaseProvider', diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index 8cb044de2e..d48103e06f 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -67,21 +67,21 @@ test('default provider runtimes load Limrun when a Limrun API key is configured' await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); }); -test('default provider runtimes load Doublespeed when a Doublespeed API key is configured', async () => { +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.ok(doublespeed); + assert.equal(doublespeed, undefined); assert.equal( runtimes.some((runtime) => runtime.provider === 'limrun'), false, ); - assertPlatformModuleCoverage(runtimes, platformModules, [doublespeed]); + assertPlatformModuleCoverage(runtimes, platformModules); assert.equal( platformModules.some(({ runtime }) => runtime === doublespeed), - true, + false, ); await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); }); diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index b7312fca31..2b09748915 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -106,7 +106,7 @@ export async function resolveConnectProviderProfile(options: { remoteConfig: resolved.resolvedPath, }, remoteConfigPath: resolved.resolvedPath, - ...(isConnectProviderName(leaseProvider) ? { provider: leaseProvider } : {}), + ...(isConnectProviderName(leaseProvider, env) ? { provider: leaseProvider } : {}), }; } const provider = diff --git a/src/cli/connection/doublespeed-profile.ts b/src/cli/connection/doublespeed-profile.ts deleted file mode 100644 index 3e9f3b56ac..0000000000 --- a/src/cli/connection/doublespeed-profile.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { resolveDaemonPaths } from '../../daemon/config.ts'; -import type { RemoteConfigProfile } from '../../remote/remote-config-schema.ts'; -import { AppError } from '@agent-device/kernel/errors'; -import type { CliFlags } from '@agent-device/contracts/command'; -import type { EnvMap } from '../../utils/env-map.ts'; -import { readMetroProfileFields } from './profile-fields.ts'; -import { persistAndResolveGeneratedProfile } from './generated-config.ts'; -import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts'; - -const DEFAULT_DOUBLESPEED_TENANT = 'doublespeed'; -const DOUBLESPEED_LEASE_BACKEND = 'ios-instance'; - -export function resolveDoublespeedConnectProfile(options: { - flags: CliFlags; - stateDir: string; - cwd: string; - env?: EnvMap; -}): { flags: CliFlags; remoteConfigPath: string } { - const env = options.env ?? process.env; - const apiKey = env.DOUBLESPEED_API_KEY?.trim(); - if (!apiKey) { - throw new AppError('INVALID_ARGS', 'connect doublespeed requires DOUBLESPEED_API_KEY.', { - hint: 'Set DOUBLESPEED_API_KEY in the environment before running agent-device connect doublespeed.', - }); - } - - const profile = buildDoublespeedRemoteProfile({ flags: options.flags }); - return persistAndResolveGeneratedProfile({ - stateDir: options.stateDir, - provider: 'doublespeed', - profile, - cwd: options.cwd, - env, - flags: options.flags, - }); -} - -function buildDoublespeedRemoteProfile(options: { flags: CliFlags }): RemoteConfigProfile { - const flags = options.flags; - validateDoublespeedConnectFlags(flags); - const daemonPaths = resolveDaemonPaths(flags.stateDir); - return { - stateDir: daemonPaths.baseDir, - daemonTransport: 'auto', - tenant: flags.tenant ?? DEFAULT_DOUBLESPEED_TENANT, - runId: flags.runId ?? `cli-${Date.now().toString(36)}`, - sessionIsolation: 'tenant', - leaseBackend: DOUBLESPEED_LEASE_BACKEND, - leaseProvider: 'doublespeed', - platform: 'ios', - target: 'mobile', - session: flags.session, - ...readMetroProfileFields(flags), - }; -} - -function validateDoublespeedConnectFlags(flags: CliFlags): void { - if (flags.platform !== undefined && flags.platform !== 'ios') { - throw new AppError('INVALID_ARGS', 'connect doublespeed supports --platform ios only.'); - } - if (flags.device !== undefined) { - throw new AppError( - 'INVALID_ARGS', - 'connect doublespeed does not accept --device; set DOUBLESPEED_DEVICE to pick the simulator model.', - ); - } - const leaseBackend = resolveRequestedLeaseBackend({ ...flags, platform: 'ios' }); - if (leaseBackend !== DOUBLESPEED_LEASE_BACKEND) { - throw new AppError( - 'INVALID_ARGS', - `connect doublespeed requires --lease-backend ${DOUBLESPEED_LEASE_BACKEND}.`, - ); - } -} diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index d7fd72b2ee..94d935a4a2 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -6,13 +6,8 @@ import { import { pluginConnectionCapabilities, pluginConnectionNames } from '../../plugins/connection.ts'; export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; -export const BUILTIN_CONNECT_PROVIDERS = [ - 'cloud', - 'proxy', - CLOUD_WEBDRIVER_PROVIDERS.browserStack, - CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - 'limrun', -] as const; +export { RESERVED_PLUGIN_PROVIDERS as BUILTIN_CONNECT_PROVIDERS } from '../../plugins/manifest.ts'; +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 & {}); @@ -26,12 +21,15 @@ export type ConnectionProviderCapabilities = { usesCloudWebDriverLease: boolean; }; -export function isConnectProviderName(value: string | undefined): value is ConnectProvider { +export function isConnectProviderName( + value: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): value is ConnectProvider { return ( value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value) || - pluginConnectionCapabilities(value) !== undefined + pluginConnectionCapabilities(value, env) !== undefined ); } diff --git a/src/commands/capture/runtime/snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts index b5b908b7d9..26e453592d 100644 --- a/src/commands/capture/runtime/snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -71,6 +71,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 00728714cc..a4bbc1b149 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -416,6 +416,7 @@ 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/); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index d39fa9864a..6b9f2479c1 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -650,6 +650,7 @@ Limrun direct-device flow: agent-device disconnect Doublespeed direct-simulator flow: + agent-device plugins add @agent-device/doublespeed DOUBLESPEED_API_KEY=... agent-device connect doublespeed --platform ios diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index aefc8922e1..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|doublespeed|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/host.ts b/src/plugins/host.ts index 66ce4e70db..98961a65c2 100644 --- a/src/plugins/host.ts +++ b/src/plugins/host.ts @@ -1,35 +1,8 @@ -import { register } from 'node:module'; -import { pathToFileURL } from 'node:url'; import { AppError } from '@agent-device/kernel/errors'; import { execFailureDetails, runCmd } from '@agent-device/host-kit/command'; -import { findProjectRoot, readVersion } from '@agent-device/host-kit/version'; +import { readVersion } from '@agent-device/host-kit/version'; import type { ProviderPluginHost } from '../sdk/plugins.ts'; -let hostBound = false; - -export function bindPluginHost(): void { - if (hostBound) return; - const root = pathToFileURL(`${findProjectRoot()}/`).href; - const source = import.meta.url.startsWith(`${root}src/`); - register( - `data:text/javascript,${encodeURIComponent(` - let host; - export function initialize(data) { host = data; } - export async function resolve(specifier, context, nextResolve) { - if (specifier === 'agent-device' && host.source) { - return { url: new URL('src/sdk/index.ts', host.root).href, shortCircuit: true }; - } - if (specifier === 'agent-device' || specifier.startsWith('agent-device/')) { - return nextResolve(specifier, { ...context, parentURL: new URL('plugin-host.mjs', host.root).href }); - } - return nextResolve(specifier, context); - } - `)}`, - { parentURL: import.meta.url, data: { root, source } }, - ); - hostBound = true; -} - export function createPluginHost( env: NodeJS.ProcessEnv, options: Record | undefined, diff --git a/src/plugins/load.test.ts b/src/plugins/load.test.ts index 5fd08fde5a..4026c62558 100644 --- a/src/plugins/load.test.ts +++ b/src/plugins/load.test.ts @@ -71,10 +71,17 @@ test('connection callbacks run from the installed plugin and always release runt "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: {}, stateDir: home, cwd: home, 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)); @@ -83,7 +90,8 @@ test('connection callbacks run from the installed plugin and always release runt withPluginConnection( 'example', env, - async (connection) => await connection.verify({ flags: {}, env }), + async (connection) => + await connection.verify({ flags: { json: false, help: false, version: false }, env }), ), { code: 'COMMAND_FAILED' }, ); diff --git a/src/plugins/load.ts b/src/plugins/load.ts index 0b91f944e5..76c30b693b 100644 --- a/src/plugins/load.ts +++ b/src/plugins/load.ts @@ -5,9 +5,9 @@ import type { PlatformRuntimeProviderModule } from '@agent-device/contracts/plat import type { ProviderPluginHost } from '../sdk/plugins.ts'; import { installedPlugins } from './store.ts'; import { resolvePluginEntry, assertUniquePluginProviders } from './manifest.ts'; -import { bindPluginHost, createPluginHost } from './host.ts'; +import { createPluginHost } from './host.ts'; import type { PluginConnection } from './connection.ts'; -import { BUILTIN_CONNECT_PROVIDERS } from '../cli/connection/provider-policy.ts'; +import { RESERVED_PLUGIN_PROVIDERS } from './manifest.ts'; import type { WebDriverPluginOptions } from '../sdk/plugin-webdriver.ts'; type ProviderPluginRegistration = Readonly<{ @@ -19,13 +19,15 @@ type ProviderPluginRegistration = Readonly<{ 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) { - bindPluginHost(); + for (const plugin of plugins.filter( + (plugin) => onlyProvider === undefined || plugin.agentDevicePlugin.provider === onlyProvider, + )) { const module = await import( pathToFileURL(resolvePluginEntry(plugin.directory, plugin.agentDevicePlugin.entry)).href ); @@ -101,7 +103,7 @@ export async function withPluginConnection( env: NodeJS.ProcessEnv, use: (connection: PluginConnection) => Promise, ): Promise { - const registrations = await loadProviderPlugins(env, BUILTIN_CONNECT_PROVIDERS); + const registrations = await loadProviderPlugins(env, RESERVED_PLUGIN_PROVIDERS, provider); try { const connection = registrations.find( (entry) => entry.runtime.provider === provider, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index c30f6b113b..ff89459b65 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -3,6 +3,16 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import type { ConnectionProviderCapabilities } from '../cli/connection/provider-policy.ts'; +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; @@ -19,7 +29,7 @@ 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)) diff --git a/src/plugins/store.test.ts b/src/plugins/store.test.ts index 32fcef3d2c..d10648a187 100644 --- a/src/plugins/store.test.ts +++ b/src/plugins/store.test.ts @@ -20,7 +20,6 @@ function installFixture(name = packageName, apiVersion = 1, provider = 'example' assert.equal(argv[argv.indexOf('--prefix') + 1], options!.cwd); assert.ok(argv.includes('--global=false')); assert.ok(argv.includes('--ignore-scripts')); - assert.ok(argv.includes('--omit=peer')); assert.ok(argv.includes('--workspaces=false')); assert.ok(argv.includes('--package-lock=true')); writePlugin(options!.cwd!, name, apiVersion, provider); diff --git a/src/plugins/store.ts b/src/plugins/store.ts index 000653787c..4435a624cb 100644 --- a/src/plugins/store.ts +++ b/src/plugins/store.ts @@ -112,7 +112,6 @@ async function stagePlugin( [ 'install', '--ignore-scripts', - '--omit=peer', '--no-audit', '--no-fund', '--global=false', diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 28603f4478..9626e395cc 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,6 +1,5 @@ import type { DefaultCloudWebDriverProviderRuntimeEnv } from '@agent-device/provider-webdriver'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; -import type { DOUBLESPEED_PROVIDER } from '@agent-device/provider-doublespeed'; import type { LIMRUN_PROVIDER } from '@agent-device/provider-limrun'; import type { PlatformRuntimeHost, @@ -16,7 +15,6 @@ export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRunti export const DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS = [ ...providerWebDriver.providerIds, 'limrun' satisfies typeof LIMRUN_PROVIDER, - 'doublespeed' satisfies typeof DOUBLESPEED_PROVIDER, ] as const; export type DefaultProviderRuntimeComposition = Readonly<{ @@ -67,63 +65,28 @@ export async function createDefaultProviderRuntimeComposition( ): Promise { const runtimes = providerWebDriver.createDefaultRuntimes(env); const platformModules = [...createProviderPlatformRuntimeRegistrations(runtimes)]; - const registrations = [ - ...(await loadLimrunRegistration(env)), - ...(await loadDoublespeedRegistration(env)), - ]; - return Object.freeze({ - runtimes: Object.freeze([...runtimes, ...registrations.map(({ runtime }) => runtime)]), - platformModules: Object.freeze([ - ...platformModules, - ...registrations.map(({ runtime, platformModule }) => ({ runtime, module: platformModule })), - ]), - }); -} - -type ProviderRegistration = Readonly<{ - runtime: ProviderDeviceRuntime; - platformModule: PlatformRuntimeProviderModule; -}>; - -async function loadLimrunRegistration( - env: DefaultProviderDeviceRuntimeEnv, -): Promise { const apiKey = env.LIMRUN_API_KEY?.trim(); - if (!apiKey) return []; + if (!apiKey) return Object.freeze({ runtimes, platformModules: Object.freeze(platformModules) }); + const [limrunRuntime, dependencies] = await Promise.all([ import('@agent-device/provider-limrun'), import('./sdk/limrun-runtime-dependencies.ts'), ]); - return [ - limrunRuntime.createLimrunRuntime( - { - apiKey, - region: env.LIMRUN_REGION?.trim() || undefined, - }, - dependencies.createLimrunRuntimeDependencies(), - { includePlatformModule: true }, - ), - ]; -} - -async function loadDoublespeedRegistration( - env: DefaultProviderDeviceRuntimeEnv, -): Promise { - const apiKey = env.DOUBLESPEED_API_KEY?.trim(); - if (!apiKey) return []; - const doublespeedRuntime = await import('@agent-device/provider-doublespeed'); - const dependencies = await import('./provider-doublespeed-dependencies.ts'); - return [ - doublespeedRuntime.createDoublespeedRuntime( - { - apiKey, - apiUrl: env.DOUBLESPEED_API_URL?.trim() || undefined, - device: env.DOUBLESPEED_DEVICE?.trim() || undefined, - }, - dependencies.createDoublespeedRuntimeDependencies(), - { includePlatformModule: true }, - ), - ]; + const registration = limrunRuntime.createLimrunRuntime( + { + apiKey, + region: env.LIMRUN_REGION?.trim() || undefined, + }, + dependencies.createLimrunRuntimeDependencies(), + { includePlatformModule: true }, + ); + return Object.freeze({ + runtimes: Object.freeze([...runtimes, registration.runtime]), + platformModules: Object.freeze([ + ...platformModules, + { runtime: registration.runtime, module: registration.platformModule }, + ]), + }); } type ProviderRuntimeWithPlatformModule = ProviderDeviceRuntime & diff --git a/src/provider-doublespeed-dependencies.ts b/src/provider-doublespeed-dependencies.ts deleted file mode 100644 index ba61aba37d..0000000000 --- a/src/provider-doublespeed-dependencies.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { AppError } from '@agent-device/kernel/errors'; -import type { DoublespeedRuntimeDependencies } from '@agent-device/provider-doublespeed'; -import { execFailureDetails, runCmd } from './utils/exec.ts'; -import { readVersion } from './utils/version.ts'; - -export function createDoublespeedRuntimeDependencies(): DoublespeedRuntimeDependencies { - return { - clientVersion: readVersion(), - host: { - 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 Doublespeed install', - { - command: ['zip', ...args].join(' '), - ...execFailureDetails(result), - }, - ); - } - }, - }, - ios: { - resolveAppAlias: async (app) => { - const { resolveIosAppAlias } = await import('./platforms/apple/core/app-resolution.ts'); - return resolveIosAppAlias(app); - }, - readBundleAppName: async (appPath) => { - const { readIosBundleInfo } = await import('./platforms/apple/core/install-artifact.ts'); - return (await readIosBundleInfo(appPath)).appName; - }, - }, - }; -} diff --git a/website/docs/docs/doublespeed.md b/website/docs/docs/doublespeed.md index 9e696a0062..f954b47303 100644 --- a/website/docs/docs/doublespeed.md +++ b/website/docs/docs/doublespeed.md @@ -7,6 +7,16 @@ description: Drive Doublespeed iOS simulators with agent-device. 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. diff --git a/website/docs/docs/plugins.md b/website/docs/docs/plugins.md index 24618c8451..2cb55a16d5 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -52,4 +52,4 @@ To support `agent-device connect example`, declare `agentDevicePlugin.connection 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 and omits peer dependencies. Runtime imports of public `agent-device` exports resolve against the running host, so plugins can use its `AppError` without installing a second core package. Import types with `import type` to keep them out of the runtime dependency graph. +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. From f4f6d1440781d984105c6103fc5b7d097d5e0c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 17:38:02 +0200 Subject: [PATCH 05/11] chore(gates): track standalone provider plugin packaging and boundaries --- .fallowrc.json | 2 + package.json | 1 - packages/provider-doublespeed/package.json | 41 ++++++++++++++++----- pnpm-lock.yaml | 8 ++-- scripts/layering/package-boundaries.test.ts | 21 ++++++----- scripts/layering/package-boundaries.ts | 7 ++-- 6 files changed, 53 insertions(+), 27 deletions(-) diff --git a/.fallowrc.json b/.fallowrc.json index bd9b3ad99a..471934a5d5 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -2,6 +2,8 @@ "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", "entry": [ "tsdown.config.ts", + "packages/provider-doublespeed/tsdown.config.ts", + "packages/provider-doublespeed/src/plugin.ts", "vitest.mutation.config.ts", "src/sdk/index.ts", "src/sdk/io.ts", diff --git a/package.json b/package.json index 9902888a91..1b8e67d7d4 100644 --- a/package.json +++ b/package.json @@ -303,7 +303,6 @@ "@agent-device/platform-linux": "workspace:*", "@agent-device/platform-vega": "workspace:*", "@agent-device/platform-web": "workspace:*", - "@agent-device/provider-doublespeed": "workspace:*", "@agent-device/provider-limrun": "workspace:*", "@agent-device/provider-webdriver": "workspace:*", "@agent-device/provision-kit": "workspace:*", diff --git a/packages/provider-doublespeed/package.json b/packages/provider-doublespeed/package.json index 1a3ed54a6a..b73ac7cd17 100644 --- a/packages/provider-doublespeed/package.json +++ b/packages/provider-doublespeed/package.json @@ -1,18 +1,39 @@ { - "name": "@agent-device/provider-doublespeed", - "version": "0.0.0", - "private": true, + "name": "@agent-device/doublespeed", + "version": "0.1.0", + "private": false, "type": "module", - "description": "Doublespeed provider runtime for agent-device. Internal workspace package bundled into the published artifact.", - "dependencies": { + "description": "Doublespeed iOS simulator plugin for agent-device.", + "exports": { + ".": { + "import": "./dist/plugin.mjs" + } + }, + "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/kernel": "workspace:*", + "agent-device": "workspace:*" }, - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" + "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 } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eee6e69ed..2625385e4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,9 +65,6 @@ importers: '@agent-device/platform-web': specifier: workspace:* version: link:packages/platform-web - '@agent-device/provider-doublespeed': - specifier: workspace:* - version: link:packages/provider-doublespeed '@agent-device/provider-limrun': specifier: workspace:* version: link:packages/provider-limrun @@ -455,7 +452,7 @@ importers: version: link:../selectors packages/provider-doublespeed: - dependencies: + devDependencies: '@agent-device/capture-kit': specifier: workspace:* version: link:../capture-kit @@ -465,6 +462,9 @@ importers: '@agent-device/kernel': specifier: workspace:* version: link:../kernel + agent-device: + specifier: workspace:* + version: link:../.. packages/provider-limrun: dependencies: diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 551d3e6410..3d7b5ebd7c 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -755,6 +755,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(), [ @@ -779,17 +780,18 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/platform-android', ]); const providerDoublespeedPackage = packages.find( - (pkg) => pkg.name === '@agent-device/provider-doublespeed', + (pkg) => pkg.name === '@agent-device/doublespeed', ); assert.ok(providerDoublespeedPackage, 'provider-doublespeed package must exist'); assert.deepEqual( [...providerDoublespeedPackage.exportTargets.keys()], - ['@agent-device/provider-doublespeed'], + ['@agent-device/doublespeed'], ); 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); @@ -842,9 +844,10 @@ 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.ok( - rootWorkspaceDependencyNames(repoRoot).has('@agent-device/provider-doublespeed'), - 'root must declare the provider-doublespeed workspace dependency', + assert.equal( + rootWorkspaceDependencyNames(repoRoot).has('@agent-device/doublespeed'), + false, + 'optional plugin is absent from the root dependency graph', ); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/xml'), @@ -913,10 +916,10 @@ test('Node resolution enforces the exports map at runtime', () => { providerLimrunResolved.endsWith('packages/provider-limrun/src/index.ts'), providerLimrunResolved, ); - const providerDoublespeedResolved = import.meta.resolve('@agent-device/provider-doublespeed'); - assert.ok( - providerDoublespeedResolved.endsWith('packages/provider-doublespeed/src/index.ts'), - providerDoublespeedResolved, + assert.throws( + () => import.meta.resolve('@agent-device/doublespeed'), + /ERR_MODULE_NOT_FOUND/, + 'core does not resolve optional providers before installation', ); const xmlResolved = import.meta.resolve('@agent-device/xml'); assert.ok(xmlResolved.endsWith('packages/xml/src/index.ts'), xmlResolved); diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index cb8c462c4d..5b9d288401 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -100,13 +100,14 @@ export function workspacePackagesFromManifests( const manifest = JSON.parse(manifests.get(manifestFile)!) as { name?: string; private?: boolean; - exports?: Record; + exports?: Record; + devDependencies?: Record; dependencies?: 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), @@ -114,7 +115,7 @@ export function workspacePackagesFromManifests( ); } const workspaceDependencies = new Set( - Object.entries(manifest.dependencies ?? {}) + Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }) .filter(([, range]) => range.startsWith('workspace:')) .map(([name]) => name), ); From af17f6600f7675973c04b4803fd86b7c0b9359d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 17:41:25 +0200 Subject: [PATCH 06/11] fix: keep plugin contracts below CLI policy and use scoped test scratch --- packages/contracts/src/facades/remote.ts | 1 + packages/contracts/src/provider-connection.ts | 10 +++++++++ .../src/api-client.test.ts | 4 ++-- packages/provider-doublespeed/src/ios.test.ts | 10 +++------ .../src/tmp-dir.fixtures.ts | 21 +++++++++++++++++++ src/__tests__/cloud-connect-profile.test.ts | 4 ++-- src/cli/connection/provider-policy.ts | 11 +--------- src/plugins/connection.ts | 2 +- src/plugins/load.ts | 11 ++++++---- src/plugins/manifest.ts | 2 +- 10 files changed, 49 insertions(+), 27 deletions(-) create mode 100644 packages/provider-doublespeed/src/tmp-dir.fixtures.ts diff --git a/packages/contracts/src/facades/remote.ts b/packages/contracts/src/facades/remote.ts index 852c8272cb..d6e0eacd56 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/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/provider-doublespeed/src/api-client.test.ts b/packages/provider-doublespeed/src/api-client.test.ts index ca18cdf103..4f230543c0 100644 --- a/packages/provider-doublespeed/src/api-client.test.ts +++ b/packages/provider-doublespeed/src/api-client.test.ts @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import os from 'node:os'; +import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; import path from 'node:path'; import { expect, test } from 'vitest'; import { DoublespeedApiClient } from './api-client.ts'; @@ -109,7 +109,7 @@ test('lists simulators by label selector and deletes by id', async () => { }); test('registers, uploads and completes an asset with the signed URL as the only capability', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doublespeed-asset-')); + const tempDir = mkdtempForTestSync('doublespeed-asset-'); const filePath = path.join(tempDir, 'app.zip'); fs.writeFileSync(filePath, 'zip-bytes'); const { fetch, calls } = scriptedFetch([ diff --git a/packages/provider-doublespeed/src/ios.test.ts b/packages/provider-doublespeed/src/ios.test.ts index 7d5309d7a9..21de1f76ed 100644 --- a/packages/provider-doublespeed/src/ios.test.ts +++ b/packages/provider-doublespeed/src/ios.test.ts @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import os from 'node:os'; +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'; @@ -119,7 +119,7 @@ test('remote install infers the single new user app when the session reports no }); test('packages a .app directory, publishes it once, and installs through the signed download', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doublespeed-ios-app-')); + const tempDir = mkdtempForTestSync('doublespeed-ios-app-'); const appPath = path.join(tempDir, 'Example.app'); fs.mkdirSync(appPath); const archiveDirectory = vi.fn(async ({ archivePath }: { archivePath: string }) => { @@ -178,11 +178,7 @@ test('packages a .app directory, publishes it once, and installs through the sig sha256: sha, launchMode: 'ForegroundIfRunning', }); - expect( - fs - .readdirSync(os.tmpdir()) - .filter((name) => name.startsWith('agent-device-doublespeed-ios-app-')), - ).toEqual([]); + expect(fs.existsSync(path.dirname(archiveDirectory.mock.calls[0]![0].archivePath))).toBe(false); fs.rmSync(tempDir, { recursive: true, force: true }); }); 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/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 6c21f039c8..898f6514a3 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -53,11 +53,11 @@ vi.mock('../plugins/load.ts', () => ({ withPluginConnection: async ( _provider: string, env: NodeJS.ProcessEnv, - use: (connection: PluginConnection) => Promise, + runConnection: (connection: PluginConnection) => Promise, ) => { const registration = doublespeedPlugin(createPluginHost(env, undefined)); try { - return await use(registration.connection); + return await runConnection(registration.connection); } finally { await registration.runtime.shutdown(); } diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 94d935a4a2..c2b89edf55 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -1,3 +1,4 @@ +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; import { CLOUD_WEBDRIVER_PROVIDERS, isCloudWebDriverProviderName, @@ -11,16 +12,6 @@ import { RESERVED_PLUGIN_PROVIDERS as BUILTIN_CONNECT_PROVIDERS } from '../../pl 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, env: NodeJS.ProcessEnv = process.env, diff --git a/src/plugins/connection.ts b/src/plugins/connection.ts index 734b14dc2d..329effd3b1 100644 --- a/src/plugins/connection.ts +++ b/src/plugins/connection.ts @@ -2,7 +2,7 @@ import type { CliFlags } from '@agent-device/contracts/command'; import type { 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 type { ConnectionProviderCapabilities } from '../cli/connection/provider-policy.ts'; +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; import { installedPlugins } from './store.ts'; export type PluginConnection = Readonly<{ diff --git a/src/plugins/load.ts b/src/plugins/load.ts index 76c30b693b..a5f48e6690 100644 --- a/src/plugins/load.ts +++ b/src/plugins/load.ts @@ -4,10 +4,13 @@ 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 { RESERVED_PLUGIN_PROVIDERS } from './manifest.ts'; import type { WebDriverPluginOptions } from '../sdk/plugin-webdriver.ts'; type ProviderPluginRegistration = Readonly<{ @@ -101,7 +104,7 @@ export async function loadProviderPlugins( export async function withPluginConnection( provider: string, env: NodeJS.ProcessEnv, - use: (connection: PluginConnection) => Promise, + runConnection: (connection: PluginConnection) => Promise, ): Promise { const registrations = await loadProviderPlugins(env, RESERVED_PLUGIN_PROVIDERS, provider); try { @@ -110,7 +113,7 @@ export async function withPluginConnection( )?.connection; if (!connection) throw new AppError('INVALID_ARGS', `Plugin does not register connect: ${provider}`); - return await use(connection); + return await runConnection(connection); } finally { await Promise.allSettled(registrations.map(async ({ runtime }) => await runtime.shutdown())); } diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index ff89459b65..93fd645c12 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import type { ConnectionProviderCapabilities } from '../cli/connection/provider-policy.ts'; +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; import { CLOUD_WEBDRIVER_PROVIDERS } from '@agent-device/provider-webdriver/providers'; From 1096a829ec17e7675d0ad48de7899df6d7ad08e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 17:57:14 +0200 Subject: [PATCH 07/11] fix: preserve provider tests through development-only package exports --- package.json | 3 +- packages/provider-doublespeed/package.json | 17 ++++++- packages/provider-doublespeed/src/index.ts | 1 + pnpm-lock.yaml | 3 ++ scripts/check-provider-plugin.mjs | 11 +++++ scripts/layering/package-boundaries.test.ts | 49 +++++++++++++++++---- src/__tests__/cloud-connect-profile.test.ts | 19 +++----- src/plugins/connection.ts | 6 ++- src/plugins/manifest.test.ts | 32 ++++++++++++++ 9 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 packages/provider-doublespeed/src/index.ts diff --git a/package.json b/package.json index 1b8e67d7d4..ee35688e0c 100644 --- a/package.json +++ b/package.json @@ -343,6 +343,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/provider-doublespeed/package.json b/packages/provider-doublespeed/package.json index b73ac7cd17..f12e2f9cfa 100644 --- a/packages/provider-doublespeed/package.json +++ b/packages/provider-doublespeed/package.json @@ -6,8 +6,14 @@ "description": "Doublespeed iOS simulator plugin for agent-device.", "exports": { ".": { - "import": "./dist/plugin.mjs" - } + "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" @@ -35,5 +41,12 @@ "supportsDirectPortReverse": false, "usesCloudWebDriverLease": false } + }, + "publishConfig": { + "exports": { + ".": { + "import": "./dist/plugin.mjs" + } + } } } 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/pnpm-lock.yaml b/pnpm-lock.yaml index 2625385e4f..3b90d1ed60 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 diff --git a/scripts/check-provider-plugin.mjs b/scripts/check-provider-plugin.mjs index 4ab7e5d21d..bc713e7332 100644 --- a/scripts/check-provider-plugin.mjs +++ b/scripts/check-provider-plugin.mjs @@ -31,6 +31,11 @@ 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], @@ -57,6 +62,12 @@ try { 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', }); diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 3d7b5ebd7c..61dfaf734b 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -16,6 +16,7 @@ import { checkPackageInternalSites, checkRootSites, readWorkspacePackages, + workspacePackagesFromManifests, rootExternalDependencyRanges, rootWorkspaceDependencyNames, specifierSites, @@ -148,6 +149,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 @@ -785,7 +818,11 @@ test('the real tree parses, declares, and passes R11', () => { assert.ok(providerDoublespeedPackage, 'provider-doublespeed package must exist'); assert.deepEqual( [...providerDoublespeedPackage.exportTargets.keys()], - ['@agent-device/doublespeed'], + [ + '@agent-device/doublespeed', + '@agent-device/doublespeed/connection-verification', + '@agent-device/doublespeed/package.json', + ], ); assert.deepEqual([...providerDoublespeedPackage.workspaceDependencies].sort(), [ '@agent-device/capture-kit', @@ -846,8 +883,8 @@ test('the real tree parses, declares, and passes R11', () => { ); assert.equal( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/doublespeed'), - false, - 'optional plugin is absent from the root dependency graph', + true, + 'workspace tests resolve the plugin through its declared development dependency', ); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/xml'), @@ -916,11 +953,7 @@ test('Node resolution enforces the exports map at runtime', () => { providerLimrunResolved.endsWith('packages/provider-limrun/src/index.ts'), providerLimrunResolved, ); - assert.throws( - () => import.meta.resolve('@agent-device/doublespeed'), - /ERR_MODULE_NOT_FOUND/, - 'core does not resolve optional providers before installation', - ); + 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/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 898f6514a3..4b2214a8f1 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -14,13 +14,13 @@ import { import type { AgentDeviceClient } from '../agent-device-client.ts'; import { resolveCloudWebDriverConnectProfile } from '../cli/connection/cloud-webdriver-profile.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { verifyDoublespeedConnection } from '../../packages/provider-doublespeed/src/connection-verification.ts'; +import { verifyDoublespeedConnection } from '@agent-device/doublespeed/connection-verification'; import { verifyLimrunConnection } from '@agent-device/provider-limrun'; -import doublespeedPlugin from '../../packages/provider-doublespeed/src/plugin.ts'; +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 '../../packages/provider-doublespeed/package.json' with { type: 'json' }; +import manifest from '@agent-device/doublespeed/package.json' with { type: 'json' }; import type { PluginConnection } from '../plugins/connection.ts'; import { providerWebDriver } from '../provider-webdriver.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; @@ -35,15 +35,10 @@ vi.mock('@agent-device/provider-limrun', async (importOriginal) => ({ verifyLimrunConnection: vi.fn(), })); -vi.mock( - '../../packages/provider-doublespeed/src/connection-verification.ts', - async (importOriginal) => ({ - ...(await importOriginal< - typeof import('../../packages/provider-doublespeed/src/connection-verification.ts') - >()), - verifyDoublespeedConnection: vi.fn(), - }), -); +vi.mock('@agent-device/doublespeed/connection-verification', async (importOriginal) => ({ + ...(await importOriginal()), + verifyDoublespeedConnection: vi.fn(), +})); vi.mock('../provider-webdriver.ts', () => ({ providerWebDriver: { verifyConnection: vi.fn() }, diff --git a/src/plugins/connection.ts b/src/plugins/connection.ts index 329effd3b1..295f8f8cbe 100644 --- a/src/plugins/connection.ts +++ b/src/plugins/connection.ts @@ -1,8 +1,10 @@ import type { CliFlags } from '@agent-device/contracts/command'; -import type { ProviderConnectionVerification } from '@agent-device/contracts/remote'; +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 type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; import { installedPlugins } from './store.ts'; export type PluginConnection = Readonly<{ 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' }); + } +}); From 2126cbcb8d2861b1463e332ca98be399a91aecff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 18:05:43 +0200 Subject: [PATCH 08/11] chore(gates): validate plugin lifecycle and declare packaging fixture ownership --- .fallowrc.json | 9 +- .../provider-webdriver/src/webdriver-utils.ts | 18 --- src/cli/connection/provider-policy.ts | 1 - src/plugins/load.ts | 149 +++++++++++------- 4 files changed, 96 insertions(+), 81 deletions(-) diff --git a/.fallowrc.json b/.fallowrc.json index 471934a5d5..b96395dbc0 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -2,6 +2,8 @@ "$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", @@ -69,6 +71,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 +297,7 @@ }, { "comment": "Tool config default exports, loaded by the tool rather than imported.", - "file": "{oxlint.config.ts,tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts}", + "file": "{oxlint.config.ts,tsdown.config.ts,packages/provider-*/tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts}", "exports": ["default"] }, { diff --git a/packages/provider-webdriver/src/webdriver-utils.ts b/packages/provider-webdriver/src/webdriver-utils.ts index d75fd560ba..bbf3020ad6 100644 --- a/packages/provider-webdriver/src/webdriver-utils.ts +++ b/packages/provider-webdriver/src/webdriver-utils.ts @@ -6,10 +6,6 @@ import type { ProviderDeviceInstallOptions, ProviderDeviceInstallResult, } from '@agent-device/contracts/device'; -import { - PROVIDER_DEVICE_ORIENTATIONS, - type ProviderDeviceOrientation, -} from '@agent-device/contracts/remote'; import { AppError, errorMessage } from '@agent-device/kernel/errors'; import { agentDeviceRequestHeaders } from './request-headers.ts'; @@ -319,17 +315,3 @@ export function sameOsVersion(left: string, right: string): boolean { const normalize = (value: string) => value.replace(/(?:\.0)+$/, ''); return normalize(left) === normalize(right); } - -/** Validates a device-orientation flag against the shared enum before it reaches a hub that would ignore it. */ -export function requireProviderDeviceOrientation( - spec: { flag: string; capability: string }, - value: string, -): ProviderDeviceOrientation { - const match = PROVIDER_DEVICE_ORIENTATIONS.find((orientation) => orientation === value); - if (match) return match; - throw new AppError('INVALID_ARGS', `Invalid ${spec.flag} value: ${value}.`, { - hint: `Use ${PROVIDER_DEVICE_ORIENTATIONS.join('|')}.`, - flag: spec.flag, - capability: spec.capability, - }); -} diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index c2b89edf55..db4f872186 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -7,7 +7,6 @@ import { import { pluginConnectionCapabilities, pluginConnectionNames } from '../../plugins/connection.ts'; export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; -export { RESERVED_PLUGIN_PROVIDERS as BUILTIN_CONNECT_PROVIDERS } from '../../plugins/manifest.ts'; 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 & {}); diff --git a/src/plugins/load.ts b/src/plugins/load.ts index a5f48e6690..cf5dbfa845 100644 --- a/src/plugins/load.ts +++ b/src/plugins/load.ts @@ -31,68 +31,9 @@ export async function loadProviderPlugins( for (const plugin of plugins.filter( (plugin) => onlyProvider === undefined || plugin.agentDevicePlugin.provider === onlyProvider, )) { - 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 = 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}`); - } + 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' || - (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}`, - ); - } + validateProviderPlugin(registration, plugin); } return registrations; } catch (error) { @@ -118,3 +59,89 @@ export async function withPluginConnection( 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 = 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', + ) + ); +} From 6560af2c31566cba841a745afdd9a4a34e5c603c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 18:10:50 +0200 Subject: [PATCH 09/11] chore(gates): smoke the optional packed WebDriver plugin SDK --- test/integration/installed-package-metro.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index afe2aa0bb1..0fa4f350c6 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -327,6 +327,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('||') && typeof mod.parseSelectorChain === 'function', @@ -402,6 +403,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', From fda055e865035eb9f5b9acf5bdfe1e0d59e3cc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 18:25:22 +0200 Subject: [PATCH 10/11] perf: keep plugin engines and connection metadata out of eager imports --- packages/provider-doublespeed/src/plugin.ts | 4 +- .../provider-webdriver/src/capabilities.ts | 12 ++ packages/provider-webdriver/src/plugin.ts | 9 +- .../provider-webdriver/src/runtime-session.ts | 14 +- packages/provider-webdriver/src/runtime.ts | 2 +- .../cloud-connect-doublespeed.test.ts | 154 ++++++++++++++++++ src/__tests__/cloud-connect-profile.test.ts | 118 -------------- src/cli.ts | 5 +- src/plugins/load.ts | 2 +- website/docs/docs/plugins.md | 3 +- 10 files changed, 183 insertions(+), 140 deletions(-) create mode 100644 src/__tests__/cloud-connect-doublespeed.test.ts diff --git a/packages/provider-doublespeed/src/plugin.ts b/packages/provider-doublespeed/src/plugin.ts index c72b2eb9e7..e19c501bef 100644 --- a/packages/provider-doublespeed/src/plugin.ts +++ b/packages/provider-doublespeed/src/plugin.ts @@ -1,9 +1,9 @@ import type { ProviderPluginHost } from 'agent-device/plugins'; -import { createDoublespeedRuntime } from './runtime.ts'; import { verifyDoublespeedConnection } from './connection-verification.ts'; import type { CliFlags } from '@agent-device/contracts/command'; -export default function doublespeedPlugin(host: ProviderPluginHost) { +export default async function doublespeedPlugin(host: ProviderPluginHost) { + const { createDoublespeedRuntime } = await import('./runtime.ts'); const apiKey = host.env.DOUBLESPEED_API_KEY?.trim() ?? ''; const registration = createDoublespeedRuntime( { diff --git a/packages/provider-webdriver/src/capabilities.ts b/packages/provider-webdriver/src/capabilities.ts index c390be3355..f2b1ab345d 100644 --- a/packages/provider-webdriver/src/capabilities.ts +++ b/packages/provider-webdriver/src/capabilities.ts @@ -166,3 +166,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 index 735b7ed23e..1a877023ab 100644 --- a/packages/provider-webdriver/src/plugin.ts +++ b/packages/provider-webdriver/src/plugin.ts @@ -1,2 +1,9 @@ -export { createCloudWebDriverRuntime } from './runtime.ts'; +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 cce3f7ca86..5b8be7d69f 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'; @@ -288,18 +288,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 3ee5aab125..3327d067c9 100644 --- a/packages/provider-webdriver/src/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -108,7 +108,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/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 4b2214a8f1..c945bde3cc 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -14,14 +14,7 @@ import { import type { AgentDeviceClient } from '../agent-device-client.ts'; import { resolveCloudWebDriverConnectProfile } from '../cli/connection/cloud-webdriver-profile.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { verifyDoublespeedConnection } from '@agent-device/doublespeed/connection-verification'; import { verifyLimrunConnection } from '@agent-device/provider-limrun'; -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'; import { providerWebDriver } from '../provider-webdriver.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; @@ -35,29 +28,10 @@ vi.mock('@agent-device/provider-limrun', async (importOriginal) => ({ verifyLimrunConnection: vi.fn(), })); -vi.mock('@agent-device/doublespeed/connection-verification', async (importOriginal) => ({ - ...(await importOriginal()), - verifyDoublespeedConnection: vi.fn(), -})); - vi.mock('../provider-webdriver.ts', () => ({ providerWebDriver: { verifyConnection: vi.fn() }, })); -vi.mock('../plugins/load.ts', () => ({ - withPluginConnection: async ( - _provider: string, - env: NodeJS.ProcessEnv, - runConnection: (connection: PluginConnection) => Promise, - ) => { - const registration = doublespeedPlugin(createPluginHost(env, undefined)); - try { - return await runConnection(registration.connection); - } finally { - await registration.runtime.shutdown(); - } - }, -})); afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); @@ -66,32 +40,9 @@ afterEach(() => { const mockedResolveCloudAccessForConnect = vi.mocked(resolveCloudAccessForConnect); const mockedVerifyLimrunConnection = vi.mocked(verifyLimrunConnection); -const mockedVerifyDoublespeedConnection = vi.mocked(verifyDoublespeedConnection); const mockedVerifyWebDriverConnection = vi.mocked(providerWebDriver.verifyConnection); 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.', - }, - }); mockedVerifyLimrunConnection.mockResolvedValue({ provider: 'limrun', service: 'Limrun', @@ -248,75 +199,6 @@ test('connect limrun generates a local daemon remote profile', async () => { } }); -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 }); - } -}); - test('connect limrun persists deferred Metro bridge settings', async () => { const tempRoot = mkdtempForTestSync('agent-device-connect-limrun-metro-'); const stateDir = path.join(tempRoot, '.state'); 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/plugins/load.ts b/src/plugins/load.ts index cf5dbfa845..b9e93d471b 100644 --- a/src/plugins/load.ts +++ b/src/plugins/load.ts @@ -90,7 +90,7 @@ async function instantiateProviderPlugin( ); } const { createCloudWebDriverRuntime } = await import('@agent-device/provider-webdriver/plugin'); - const runtime = createCloudWebDriverRuntime({ + const runtime = await createCloudWebDriverRuntime({ ...result.webDriver, clientVersion: host.clientVersion, }); diff --git a/website/docs/docs/plugins.md b/website/docs/docs/plugins.md index 2cb55a16d5..62024ec3bc 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -31,8 +31,7 @@ Use `agent-device` as a development dependency. The default factory accepts `Pro 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. From ca1a7c722e77d896f5e6b18c28e075696c40262e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 18:26:44 +0200 Subject: [PATCH 11/11] chore(gates): bound the optional plugin SDK declarations --- test/integration/installed-package-metro.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index 0fa4f350c6..ecffb46576 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 < 4096); assert.match(pluginTypes.toString(), /export \{ ProviderPluginHost \}/); metroPort = await listenOnLoopback(metroServer); t.after(async () => {