Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,15 @@ 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

`agent-device` keeps device state in sessions. It sends commands to XCTest on iOS and tvOS, ADB and the snapshot helper on Android, HDC and ArkUI `uitest` on HarmonyOS, Vega CLI/VDA on the Vega Virtual Device, a local helper on macOS, and AT-SPI on Linux.

Support depth varies by target. Newer backends such as HarmonyOS and Vega OS cover a subset of commands; run `agent-device capabilities --platform <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.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:*",
Expand Down
4 changes: 4 additions & 0 deletions packages/kernel/src/snapshot-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
7 changes: 5 additions & 2 deletions packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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' }
Expand Down
18 changes: 18 additions & 0 deletions packages/provider-doublespeed/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
147 changes: 147 additions & 0 deletions packages/provider-doublespeed/src/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading
Loading