Skip to content
Merged
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: 4 additions & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"scripts/patch-xcuitest-runner-icon.ts",
"scripts/runner-request-count/run.ts",
"src/utils/update-check-entry.ts",
"examples/sdk/client-session.ts",
"examples/sdk/metro-runtime.ts",
"examples/sdk/contracts-result.ts",
"examples/sdk/batch-orchestration.ts",
"test/scripts/metro-prepare-packaged-smoke.mjs",
"test/integration/*.test.ts",
"website/docs/404.mdx",
Expand Down
38 changes: 38 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Examples

Runnable, typechecked Node.js examples for the `agent-device` SDK surface exposed to Node
consumers. Source of truth for the API itself is
[Typed Client](../website/docs/docs/client-api.md). Two guards keep these files in sync with that
doc: `src/__tests__/client-api-examples-drift.test.ts` checks the doc's subpath API manifest against
what these examples import, and `test/integration/client-api-doc-snippets.test.ts` compiles every
fenced TypeScript code block in the doc itself against the real `agent-device/*` sources.

## sdk/

Standalone scripts under [`sdk/`](./sdk) exercise the published `agent-device` export map —
`agent-device`, `agent-device/metro`, `agent-device/contracts`, and `agent-device/batch` — the same
way a Node consumer or the agent-device-cloud bridge would. Each file:

- has a top comment stating what it demonstrates and its prerequisites (daemon running,
device/simulator available);
- typechecks without live hardware — `pnpm typecheck` resolves the `agent-device/...` imports
against `src/sdk/` via [`examples/sdk/tsconfig.json`](./sdk/tsconfig.json)'s `paths`, so CI checks
them without a build or publish step;
- imports the package by name (`agent-device/...`), not a relative `src/` path, so it exercises the
same surface a real consumer sees;
- is runnable on its own with `node --experimental-strip-types` (repo Node is >=22.12) once the
package is built (`pnpm build`) — running for real resolves `agent-device` as a self-referencing
package, the same way an installed consumer would.

| Example | Subpath | Demonstrates |
| --- | --- | --- |
| [`client-session.ts`](./sdk/client-session.ts) | `agent-device` | `createAgentDeviceClient` → open → snapshot/tap → close, with typed error handling |
| [`metro-runtime.ts`](./sdk/metro-runtime.ts) | `agent-device/metro` | `normalizeBaseUrl`, `resolveRuntimeTransport` |
| [`contracts-result.ts`](./sdk/contracts-result.ts) | `agent-device/contracts` | typed result consumption via `centerOfRect` |
| [`batch-orchestration.ts`](./sdk/batch-orchestration.ts) | `agent-device/batch` | `runBatch` for a custom transport |

## test-app/

[`test-app/`](./test-app) is the Expo dogfood fixture used for `agent-device` and `skillgym`
experiments (see its own [README](./test-app/README.md)) — it is a test fixture, not an SDK usage
example.
61 changes: 61 additions & 0 deletions examples/sdk/batch-orchestration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Batch orchestration for a custom transport: `runBatch` keeps step
* validation, inherited flags, serial execution, partial results, and
* daemon-shaped error envelopes aligned with the CLI's `batch` command, so a
* bridge that owns command dispatch itself does not have to reimplement them.
*
* Demonstrates: `runBatch` from `agent-device/batch`, consumed against the
* `DaemonResponse` result type from `agent-device/contracts`.
*
* Prerequisites: none — `dispatch` below is a stub; a real integration would
* replace it with a call into the bridge's own command dispatcher.
*
* Run: node --experimental-strip-types examples/sdk/batch-orchestration.ts
*/
import { runBatch } from 'agent-device/batch';
import type { DaemonResponse } from 'agent-device/contracts';

type BatchRequest = Parameters<typeof runBatch>[0];

async function dispatch(stepReq: unknown): Promise<Record<string, unknown>> {
console.log('dispatching step', stepReq);
return { handled: true };
}

function bridgeErrorToDaemonResponse(error: unknown): Extract<DaemonResponse, { ok: false }> {
return {
ok: false,
error: {
code: 'COMMAND_FAILED',
message: error instanceof Error ? error.message : 'Unknown bridge error',
},
};
}

async function handleBatch(req: BatchRequest): Promise<DaemonResponse> {
return await runBatch(req, req.session ?? 'default', async (stepReq) => {
try {
return { ok: true, data: await dispatch(stepReq) };
} catch (error) {
return bridgeErrorToDaemonResponse(error);
}
});
}

const result = await handleBatch({
command: 'batch',
positionals: [],
flags: {
batchSteps: [
{ command: 'wait', input: { text: 'Welcome' } },
{ command: 'back', input: {} },
],
},
});

if (result.ok) {
console.log(`batch completed: ${JSON.stringify(result.data)}`);
} else {
console.error(`batch failed [${result.error.code}]: ${result.error.message}`);
process.exitCode = 1;
}
75 changes: 75 additions & 0 deletions examples/sdk/client-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Root client session: create a client, open an app, capture a snapshot, tap
* a node, then close the session — with typed error handling via the
* exported error helpers.
*
* Demonstrates: `createAgentDeviceClient`, `AppError`, `isAgentDeviceError`,
* and `normalizeAgentDeviceError` from the `agent-device` root export.
*
* Prerequisites: an `agent-device` daemon target (a booted iOS simulator).
* This file typechecks without one; running it for real also requires
* `pnpm build` first, so the package resolves at runtime.
*
* Run: node --experimental-strip-types examples/sdk/client-session.ts
*/
import {
AppError,
createAgentDeviceClient,
isAgentDeviceError,
normalizeAgentDeviceError,
} from 'agent-device';

async function resolveSnapshotCapableIosDevice(client: ReturnType<typeof createAgentDeviceClient>) {
const devices = await client.devices.list({ platform: 'ios' });
const device = devices[0];
if (!device) {
throw new AppError('DEVICE_NOT_FOUND', 'No iOS device available');
}

const capabilities = await client.devices.capabilities({ platform: 'ios' });
if (!capabilities.availableCommands.includes('snapshot')) {
throw new AppError('UNSUPPORTED_OPERATION', 'Selected target does not support snapshots');
}

return device;
}

function reportAgentDeviceError(error: unknown): void {
const normalized = normalizeAgentDeviceError(error);
console.error(`agent-device error [${normalized.code}]: ${normalized.message}`);
if (normalized.hint) {
console.error(`hint: ${normalized.hint}`);
}
process.exitCode = 1;
}

async function main(): Promise<void> {
const client = createAgentDeviceClient({
session: 'sdk-example',
lockPolicy: 'reject',
lockPlatform: 'ios',
});

try {
const device = await resolveSnapshotCapableIosDevice(client);

await client.apps.open({
app: 'com.apple.Preferences',
platform: 'ios',
udid: device.id,
});

const snapshot = await client.capture.snapshot({ interactiveOnly: true });
const target = snapshot.nodes.find((node) => node.role === 'button');
if (target) {
await client.interactions.press({ ref: target.ref });
}
} catch (error) {
if (!isAgentDeviceError(error)) throw error;
reportAgentDeviceError(error);
} finally {
await client.sessions.close();
}
}

await main();
42 changes: 42 additions & 0 deletions examples/sdk/contracts-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Typed result consumption from `agent-device/contracts`: compute the center
* point of a snapshot node's rect, using the same `centerOfRect` helper the
* daemon and CLI use internally.
*
* Demonstrates: `centerOfRect` from `agent-device/contracts`, consumed
* against the `CaptureSnapshotResult` shape returned by the root client.
*
* Prerequisites: an `agent-device` daemon target with an app already open.
* This file typechecks without one.
*
* Run: node --experimental-strip-types examples/sdk/contracts-result.ts
*/
import { createAgentDeviceClient } from 'agent-device';
import { centerOfRect } from 'agent-device/contracts';

function assertHasRect<T extends { rect?: unknown }>(
node: T | undefined,
): asserts node is T & { rect: NonNullable<T['rect']> } {
if (!node?.rect) {
throw new Error('No visible node with a rect in the snapshot');
}
}

async function main(): Promise<void> {
const client = createAgentDeviceClient({ session: 'sdk-example' });

try {
const snapshot = await client.capture.snapshot({ interactiveOnly: true });
const node = snapshot.nodes.find((candidate) => candidate.rect !== undefined);
assertHasRect(node);

const center = centerOfRect(node.rect);
console.log(
`center of ${node.role ?? 'node'} "${node.label ?? node.ref}": (${center.x}, ${center.y})`,
);
} finally {
await client.sessions.close();
}
}

await main();
31 changes: 31 additions & 0 deletions examples/sdk/metro-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Metro runtime helpers: normalize a Metro base URL and resolve the
* host/port/scheme a client should connect through, given the runtime hints
* a daemon response (or `AppOpenOptions.runtime`) carries.
*
* Demonstrates: `normalizeBaseUrl` and `resolveRuntimeTransport` from
* `agent-device/metro`.
*
* Prerequisites: none — both functions are pure and need no daemon, device,
* or running Metro bundler.
*
* Run: node --experimental-strip-types examples/sdk/metro-runtime.ts
*/
import { normalizeBaseUrl, resolveRuntimeTransport } from 'agent-device/metro';
import type { SessionRuntimeHints } from 'agent-device/contracts';

const baseUrl = normalizeBaseUrl('https://metro.example.dev///');
console.log(`normalized base URL: ${baseUrl}`);

const hints: SessionRuntimeHints = {
platform: 'ios',
metroHost: 'metro.example.dev',
metroPort: 443,
};

const transport = resolveRuntimeTransport(hints);
if (!transport) {
throw new Error('Unable to resolve a Metro runtime transport from the provided hints');
}

console.log(`resolved transport: ${transport.scheme}://${transport.host}:${transport.port}`);
25 changes: 25 additions & 0 deletions examples/sdk/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"agent-device": ["../../src/sdk/index.ts"],
"agent-device/io": ["../../src/sdk/io.ts"],
"agent-device/artifacts": ["../../src/sdk/artifacts.ts"],
"agent-device/metro": ["../../src/sdk/metro.ts"],
"agent-device/batch": ["../../src/sdk/batch.ts"],
"agent-device/remote-config": ["../../src/sdk/remote-config.ts"],
"agent-device/install-source": ["../../src/sdk/install-source.ts"],
"agent-device/android-adb": ["../../src/sdk/android-adb.ts"],
"agent-device/contracts": ["../../src/sdk/contracts.ts"],
"agent-device/selectors": ["../../src/sdk/selectors.ts"],
"agent-device/finders": ["../../src/sdk/finders.ts"]
}
},
// Scoped to this directory only: these examples import `agent-device/...`
// by package name (the published export map), and `paths` above resolves
// those specifiers straight to `src/sdk/` so CI typechecks them without a
// prior build, workspace link, or publish step. Running an example for real
// still resolves `agent-device` as a self-referencing package (needs
// `pnpm build`), matching how a real consumer would import it.
"include": [".", "../../src/global.d.ts"]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
"check:unit": "pnpm test:unit && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm build:all && pnpm package:apple-runner:npm && pnpm package:android-snapshot-helper:npm && pnpm package:android-ime-helper:npm",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "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
Loading
Loading