From 8dad6500a31178e4f11ec32fe981ce069aa75041 Mon Sep 17 00:00:00 2001 From: Hassan Syed Date: Thu, 27 Aug 2026 18:50:58 -0700 Subject: [PATCH] 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).