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
12 changes: 11 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
"entry": [
"tsdown.config.ts",
"scripts/check-provider-plugin.mjs",
"packages/provider-doublespeed/test/package-smoke.mjs",
"packages/provider-doublespeed/tsdown.config.ts",
"packages/provider-doublespeed/src/plugin.ts",
"vitest.mutation.config.ts",
"src/sdk/index.ts",
"src/sdk/io.ts",
Expand All @@ -10,6 +14,7 @@
"src/sdk/remote-config.ts",
"src/sdk/install-source.ts",
"src/sdk/plugins.ts",
"src/sdk/plugin-webdriver.ts",
"src/sdk/android-adb.ts",
"src/sdk/contracts.ts",
"src/sdk/selectors.ts",
Expand Down Expand Up @@ -66,6 +71,11 @@
"@arethetypeswrong/cli"
],
"ignoreExports": [
{
"comment": "The standalone packed-install harness loads this fixture from the selected package path and reads its request scenarios dynamically.",
"file": "packages/provider-*/test/package-smoke.mjs",
"exports": ["*"]
},
{
"comment": "Android perf mechanics are selected through the lazy platform host so importing agent-device does not eagerly load adb mechanics. Fallow cannot follow the dynamic property read in src/platform-runtime-perf-host.ts.",
"file": "packages/platform-android/src/perf.ts",
Expand Down Expand Up @@ -287,7 +297,7 @@
},
{
"comment": "Tool config default exports, loaded by the tool rather than imported.",
"file": "{oxlint.config.ts,tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts}",
"file": "{oxlint.config.ts,tsdown.config.ts,packages/provider-*/tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts}",
"exports": ["default"]
},
{
Expand Down
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 uses a local accessibility bridge for iOS Simulator snapshots and XCTest for iOS interactions, physical 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. Inspect ownership without a daemon via `agent-device device status`, and settle provably dead owners with `agent-device device release --stale`. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds).
Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Inspect ownership without a daemon via `agent-device device status`, and settle provably dead owners with `agent-device device release --stale`. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, Limrun, and Doublespeed](https://oss.callstack.com/agent-device/docs/device-clouds).

`agent-device` uses the inspect-act-verify process from Vercel's [agent-browser](https://github.com/vercel-labs/agent-browser) for mobile, TV, and desktop apps. Basic `--platform web` support runs `agent-browser` in the same session and replay system.

Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
"./plugins": {
"types": "./dist/src/plugins.d.ts",
"import": "./dist/src/plugins.js"
},
"./plugins/webdriver": {
"types": "./dist/src/plugins/webdriver.d.ts",
"import": "./dist/src/plugins/webdriver.js"
}
},
"engines": {
Expand Down Expand Up @@ -175,7 +179,7 @@
"check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/device-selection packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/session-journal packages/ad-replay packages/maestro packages/replay-port packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/device-selection packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/session-journal packages/ad-replay packages/maestro packages/replay-port packages/replay-test packages/provider-webdriver packages/provider-limrun packages/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 @@ -339,6 +343,7 @@
"vite": "^8.2.1",
"vitest": "^4.1.11",
"yaml": "^2.9.0",
"yauzl": "^3.4.0"
"yauzl": "^3.4.0",
"@agent-device/doublespeed": "workspace:*"
}
}
9 changes: 9 additions & 0 deletions packages/capture-kit/src/ios-snapshot-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ const IOS_PROVIDER_ACQUISITION_CAPABILITY_VALUES = {
},
hittabilityEvidence: 'unavailable',
},
'doublespeed-ios-tree': {
producer: 'doublespeed-ios-tree',
acquisitionDepth: {
rawTraversal: { kind: 'incomplete' },
regularPresented: { kind: 'incomplete' },
},
hittabilityEvidence: 'unavailable',
},
} as const satisfies Record<IosProviderAcquisitionProducer, IosProviderAcquisitionCapabilities>;

/**
Expand All @@ -60,6 +68,7 @@ const IOS_SNAPSHOT_TRUNCATION_EVIDENCE = {
'simulator-ax-bridge': 'available',
'appium-source': 'unavailable',
'limrun-ios-tree': 'unavailable',
'doublespeed-ios-tree': 'unavailable',
} as const satisfies Record<IosSnapshotProducer, IosSnapshotEvidenceAvailability>;

export function iosSnapshotTruncationEvidence(
Expand Down
1 change: 1 addition & 0 deletions packages/capture-kit/src/ios-snapshot-planning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ test('truncation evidence is declared for every iOS producer', () => {
'simulator-ax-bridge': 'available',
'appium-source': 'unavailable',
'limrun-ios-tree': 'unavailable',
'doublespeed-ios-tree': 'unavailable',
} as const satisfies Record<IosSnapshotProducer, IosSnapshotEvidenceAvailability>;

for (const producer of Object.keys(expected) as IosSnapshotProducer[]) {
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/facades/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type {
ResolvedMetroKind,
} from '../metro.ts';
export type {
ConnectionProviderCapabilities,
ProviderConnectionResource,
ProviderConnectionVerification,
} from '../provider-connection.ts';
Expand Down
5 changes: 3 additions & 2 deletions packages/contracts/src/ios-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ export type IosSnapshotProducer =
| 'apple-runner'
| 'simulator-ax-bridge'
| 'appium-source'
| 'limrun-ios-tree';
| 'limrun-ios-tree'
| 'doublespeed-ios-tree';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This addition makes the adjacent contract comment inaccurate: it omits Doublespeed from the provider list and says truncation covers four producers, though the union now has five. Update the comment to include Doublespeed and the new count.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/contracts/src/ios-snapshot.ts, line 8:

<comment>This addition makes the adjacent contract comment inaccurate: it omits Doublespeed from the provider list and says truncation covers four producers, though the union now has five. Update the comment to include Doublespeed and the new count.</comment>

<file context>
@@ -4,12 +4,13 @@ export type IosSnapshotProducer =
   | 'appium-source'
-  | 'limrun-ios-tree';
+  | 'limrun-ios-tree'
+  | 'doublespeed-ios-tree';
 
 export type IosAcquisitionProducer = Exclude<IosSnapshotProducer, 'apple-runner'>;
</file context>


export type IosAcquisitionProducer = Exclude<IosSnapshotProducer, 'apple-runner'>;
export type IosProviderAcquisitionProducer = Extract<
IosAcquisitionProducer,
'appium-source' | 'limrun-ios-tree'
'appium-source' | 'limrun-ios-tree' | 'doublespeed-ios-tree'
>;
export type IosAcquisitionIntent = 'full' | 'surface-observation';
export type IosSnapshotProjection = 'regular' | 'raw';
Expand Down
10 changes: 10 additions & 0 deletions packages/contracts/src/provider-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ export type ProviderConnectionVerification = {
device: ProviderConnectionResource;
app: ProviderConnectionResource;
};

export type ConnectionProviderCapabilities = {
leaseKind: 'proxy' | 'direct-device-provider' | 'remote-provider';
requiresAppAttachment: boolean;
requiresRemoteDaemon: boolean;
supportsArtifacts: boolean;
supportsDeferredAppSelection: boolean;
supportsDirectPortReverse: boolean;
usesCloudWebDriverLease: boolean;
};
17 changes: 17 additions & 0 deletions packages/kernel/src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,20 @@ test('discloseDispatchAfterSteps keeps no only while no step of the series was d
const plain = new Error('socket closed');
assert.equal(discloseDispatchAfterSteps(plain, 4), plain);
});

test('bundled plugin errors preserve codes and details without changing subclass checks', async () => {
const copyPath = './errors.ts?plugin-copy';
const { AppError: PluginError } = await import(copyPath);
assert.notEqual(PluginError, AppError);
const foreign = new PluginError('INVALID_ARGS', 'bad plugin profile', { provider: 'example' });
assert.ok(foreign instanceof AppError);
const normalized = normalizeError(foreign);
assert.equal(normalized.code, 'INVALID_ARGS');
assert.equal(normalized.message, 'bad plugin profile');
assert.deepEqual(normalized.details, { provider: 'example' });
const ordinary = Object.assign(new Error('bad plugin profile'), { code: 'INVALID_ARGS' });
assert.equal(ordinary instanceof AppError, false);
class SpecificError extends AppError {}
assert.ok(new SpecificError('COMMAND_FAILED', 'specific') instanceof SpecificError);
assert.equal(foreign instanceof SpecificError, false);
});
38 changes: 25 additions & 13 deletions packages/kernel/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,31 @@ export type KnownAppErrorCode = (typeof KNOWN_APP_ERROR_CODES)[number];
// include a default branch.
export type AppErrorCode = KnownAppErrorCode | (string & {});

const APP_ERROR_BRAND = Symbol.for('agent-device.AppError');

export class AppError extends Error {
static [Symbol.hasInstance](value: unknown): boolean {
if (this !== AppError) return Function.prototype[Symbol.hasInstance].call(this, value);
return (
typeof value === 'object' &&
value !== null &&
(value as Record<symbol, unknown>)[APP_ERROR_BRAND] === true
);
}

code: AppErrorCode;
details?: AppErrorDetails;
cause?: unknown;

constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) {
super(message);
Object.defineProperty(this, APP_ERROR_BRAND, { value: true });
this.code = code;
this.details = details;
this.cause = cause;
}
}

export function toAppErrorCode(
code: string | undefined,
fallback: AppErrorCode = 'COMMAND_FAILED',
Expand Down Expand Up @@ -219,19 +244,6 @@ export type DaemonError = {
supportedOn?: string;
};

export class AppError extends Error {
code: AppErrorCode;
details?: AppErrorDetails;
cause?: unknown;

constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) {
super(message);
this.code = code;
this.details = details;
this.cause = cause;
}
}

/** Rehydrate a daemon transport error into the error type used by local callers. */
export function throwDaemonError(error: DaemonError): never {
throw new AppError(
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 @@ -30,4 +30,8 @@ test('snapshotStateProvenance extracts exactly the pair', () => {
backend: 'xctest',
producer: 'limrun-ios-tree',
});
expect(snapshotStateProvenance({ backend: 'xctest', producer: 'doublespeed-ios-tree' })).toEqual({
backend: 'xctest',
producer: 'doublespeed-ios-tree',
});
});
9 changes: 7 additions & 2 deletions packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ export function formatRole(type: string): string {
/**
* The channel↔producer pairs that can actually occur. One channel is fed by several producers
* with different guarantees: `xctest` trees come from the local Apple runner, Appium
* page-source XML, or a limrun element tree, and only the runner's output has been through the
* page-source XML, or a Limrun or Doublespeed element tree, and only the runner's output has been through the
* runner's presentation (clip fold, effective geometry, scope). Logic that assumes
* presentation, scope, or geometry guarantees must key on the producer, never on the channel
* alone.
Expand All @@ -458,7 +458,12 @@ export function formatRole(type: string): string {
export type SnapshotProvenance =
| {
backend: 'xctest';
producer: 'apple-runner' | 'simulator-ax-bridge' | 'appium-source' | 'limrun-ios-tree';
producer:
| 'apple-runner'
| 'simulator-ax-bridge'
| 'appium-source'
| 'limrun-ios-tree'
| 'doublespeed-ios-tree';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: R74 still omits doublespeed-ios-tree from IOS_PROVENANCE_LITERALS, so daemon assembly can branch on this newly valid producer without the guard rejecting it. Add the producer to the guarded set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/kernel/src/snapshot.ts, line 466:

<comment>R74 still omits `doublespeed-ios-tree` from `IOS_PROVENANCE_LITERALS`, so daemon assembly can branch on this newly valid producer without the guard rejecting it. Add the producer to the guarded set.</comment>

<file context>
@@ -458,7 +458,12 @@ export function formatRole(type: string): string {
+        | 'simulator-ax-bridge'
+        | 'appium-source'
+        | 'limrun-ios-tree'
+        | 'doublespeed-ios-tree';
     }
   | { backend: 'android'; producer: 'android-uiautomator' | 'appium-source' }
</file context>

}
| { backend: 'android'; producer: 'android-uiautomator' | 'appium-source' }
| { backend: 'harmonyos-arkui'; producer: 'harmonyos-uitest' }
Expand Down
52 changes: 52 additions & 0 deletions packages/provider-doublespeed/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@agent-device/doublespeed",
"version": "0.1.0",
"private": false,
"type": "module",
"description": "Doublespeed iOS simulator plugin for agent-device.",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./connection-verification": {
"types": "./src/connection-verification.ts",
"default": "./src/connection-verification.ts"
},
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"build": "tsdown --config tsdown.config.ts",
"prepack": "pnpm build"
},
"devDependencies": {
"@agent-device/capture-kit": "workspace:*",
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*",
"agent-device": "workspace:*"
},
"agentDevicePlugin": {
"apiVersion": 1,
"provider": "doublespeed",
"entry": "./dist/plugin.mjs",
"connection": {
"leaseKind": "direct-device-provider",
"requiresAppAttachment": false,
"requiresRemoteDaemon": false,
"supportsArtifacts": false,
"supportsDeferredAppSelection": true,
"supportsDirectPortReverse": false,
"usesCloudWebDriverLease": false
}
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/plugin.mjs"
}
}
}
}
Loading