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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,11 @@ Run integration tests when behavior crosses platform boundaries:
- Unit tests: `pnpm test:unit`
- Smoke tests: `pnpm test:smoke`
- Integration tests: `pnpm test:integration`

## Pull Requests
- Before opening PR: ensure no conflict markers and no unmerged paths.
- Run required checks for touched scope (at minimum `pnpm typecheck`; plus test commands from **Testing** above).
- PR body must be short and include:
- `## Summary` with key behavior changes
- `## Validation` with exact commands run
- Call out known gaps or follow-ups explicitly; do not hide failing checks.
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ agent-device swipe 540 1500 540 500 120 --count 8 --pause-ms 30 --pattern ping-p
| `ax` | Fast | Medium | Accessibility permission for the terminal app, not recommended |

Notes:
- Default backend is `xctest` on iOS simulators and iOS devices.
- Default backend is `xctest`.
- Scope snapshots with `-s "<label>"` or `-s @ref`.
- If XCTest returns 0 nodes (e.g., foreground app changed), agent-device fails explicitly.
- `ax` backend is simulator-only.
Expand Down Expand Up @@ -153,19 +153,23 @@ Sessions:
Navigation helpers:
- `boot --platform ios|android` ensures the target is ready without launching an app.
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
- `open [app|url]` already boots/activates the selected target when needed.
- `open [app|url] [url]` already boots/activates the selected target when needed.
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator).
- `reinstall` accepts package/bundle id style app names and supports `~` in paths.

Deep links:
- `open <url>` supports deep links with `scheme://...`.
- `open <app> <url>` opens a deep link on iOS.
- Android opens deep links via `VIEW` intent.
- iOS deep link open is simulator-only.
- iOS simulator opens deep links via `simctl openurl`.
- iOS device opens deep links via `devicectl --payload-url`.
- On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs (`myapp://`) require an active app in the session.
- `--activity` cannot be combined with URL opens.

```bash
agent-device open "myapp://home" --platform android
agent-device open "https://example.com" --platform ios
agent-device open "https://example.com" --platform ios # open link in web browser
agent-device open MyApp "myapp://screen/to" --platform ios # open deep link to MyApp
```

Find (semantic):
Expand Down Expand Up @@ -220,7 +224,6 @@ Note: iOS supports these only on simulators. iOS wifi/airplane toggles status ba
App state:
- `appstate` shows the foreground app/activity (Android).
- On iOS, `appstate` returns the currently tracked session app (`source: session`) and requires an active session on the selected device.
- `apps` supports Android, iOS simulators, and iOS devices.
- `apps` includes default/system apps by default (use `--user-installed` to filter).

## Debug
Expand All @@ -245,10 +248,8 @@ Boot diagnostics:
- Built-in aliases include `Settings` for both platforms.

## iOS notes
- Core runner commands (`snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`) support iOS simulators and iOS devices.
- `apps` is supported on both iOS simulators and iOS devices.
- Core runner commands: `snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`.
- Simulator-only commands: `alert`, `pinch`, `record`, `reinstall`, `settings`.
- iOS deep link open (`open <url>`) is simulator-only.
- iOS device runs require valid signing/provisioning (Automatic Signing recommended). Optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.

## Testing
Expand Down
15 changes: 8 additions & 7 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ npx -y agent-device

## Core workflow

1. Open app or deep link: `open [app|url]` (`open` handles target selection + boot/activation in the normal flow)
1. Open app or deep link: `open [app|url] [url]` (`open` handles target selection + boot/activation in the normal flow)
2. Snapshot: `snapshot` to get refs from accessibility tree
3. Interact using refs (`click @ref`, `fill @ref "text"`)
4. Re-snapshot after navigation/UI changes
Expand All @@ -39,13 +39,14 @@ npx -y agent-device

```bash
agent-device boot # Ensure target is booted/ready without opening app
agent-device boot --platform ios # Boot iOS simulator/device target
agent-device boot --platform ios # Boot iOS target
agent-device boot --platform android # Boot Android emulator/device target
agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
agent-device open [app|url] [url] # Boot device/simulator; optionally launch app or deep link URL
agent-device open [app] --relaunch # Terminate app process first, then launch (fresh runtime)
agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
agent-device open "myapp://home" --platform android # Android deep link
agent-device open "https://example.com" --platform ios # iOS simulator deep link (device unsupported)
agent-device open "https://example.com" --platform ios # iOS deep link (opens in browser)
agent-device open MyApp "myapp://screen/to" --platform ios # iOS deep link in app context
agent-device close [app] # Close app or just end session
agent-device reinstall <app> <path> # Uninstall + install app in one command
agent-device session list # List active sessions
Expand Down Expand Up @@ -188,13 +189,13 @@ agent-device apps --platform android --user-installed
- Prefer `snapshot -i` to reduce output size.
- On iOS, `xctest` is the default and does not require Accessibility permission.
- If XCTest returns 0 nodes (foreground app changed), treat it as an explicit failure and retry the flow/app state.
- `open <app|url>` can be used within an existing session to switch apps or open deep links.
- `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
- `open <app|url> [url]` can be used within an existing session to switch apps or open deep links.
- `open <app>` updates session app bundle context; `open <app> <url>` opens a deep link on iOS.
- Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
- Use `--session <name>` for parallel sessions; avoid device contention.
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
- iOS deep-link opens are simulator-only.
- On iOS devices, `http(s)://` URLs fall back to Safari automatically; custom scheme URLs require an active app in the session.
- iOS physical-device runner requires Xcode signing/provisioning; optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.
- Default daemon request timeout is `45000`ms. For slow physical-device setup/build, increase `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `120000`).
- For daemon startup troubleshooting, follow stale metadata hints for `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock`.
Expand Down
3 changes: 2 additions & 1 deletion skills/agent-device/references/session-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Sessions isolate device context. A device can only be held by one session at a t
- Name sessions semantically.
- Close sessions when done.
- Use separate sessions for parallel work.
- In iOS sessions, use `open <app>` for simulator/device. `open <url>` is simulator-only.
- In iOS sessions, use `open <app>`. `open <url>` opens deep links; on devices `http(s)://` opens Safari when no app is active, and custom schemes require an active app in the session.
- In iOS sessions, `open <app> <url>` opens a deep link.
- On iOS, `appstate` is session-scoped and requires a matching active session on the target device.
- For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
Expand Down
25 changes: 25 additions & 0 deletions src/core/__tests__/dispatch-open.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { dispatchCommand } from '../dispatch.ts';
import { AppError } from '../../utils/errors.ts';
import type { DeviceInfo } from '../../utils/device.ts';

test('dispatch open rejects URL as first argument when second URL is provided', async () => {
const device: DeviceInfo = {
platform: 'ios',
id: 'sim-1',
name: 'iPhone 15',
kind: 'simulator',
booted: true,
};

await assert.rejects(
() => dispatchCommand(device, 'open', ['myapp://first', 'myapp://second']),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'INVALID_ARGS');
assert.match((error as AppError).message, /requires an app target as the first argument/i);
return true;
},
);
});
41 changes: 40 additions & 1 deletion src/core/__tests__/open-target.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isDeepLinkTarget } from '../open-target.ts';
import {
IOS_SAFARI_BUNDLE_ID,
isDeepLinkTarget,
isWebUrl,
resolveIosDeviceDeepLinkBundleId,
} from '../open-target.ts';

test('isDeepLinkTarget accepts URL-style deep links', () => {
assert.equal(isDeepLinkTarget('myapp://home'), true);
Expand All @@ -14,3 +19,37 @@ test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
assert.equal(isDeepLinkTarget('settings'), false);
assert.equal(isDeepLinkTarget('http:/x'), false);
});

test('isWebUrl accepts http and https URLs', () => {
assert.equal(isWebUrl('https://example.com'), true);
assert.equal(isWebUrl('http://example.com/path'), true);
assert.equal(isWebUrl('https://example.com/path?q=1'), true);
});

test('isWebUrl rejects custom schemes and non-URLs', () => {
assert.equal(isWebUrl('myapp://home'), false);
assert.equal(isWebUrl('tel:123456789'), false);
assert.equal(isWebUrl('com.example.app'), false);
assert.equal(isWebUrl('settings'), false);
});

test('resolveIosDeviceDeepLinkBundleId prefers active app context', () => {
assert.equal(
resolveIosDeviceDeepLinkBundleId('com.example.app', 'myapp://home'),
'com.example.app',
);
});

test('resolveIosDeviceDeepLinkBundleId falls back to Safari for web URLs', () => {
assert.equal(
resolveIosDeviceDeepLinkBundleId(undefined, 'https://example.com/path'),
IOS_SAFARI_BUNDLE_ID,
);
});

test('resolveIosDeviceDeepLinkBundleId returns undefined for custom scheme without app context', () => {
assert.equal(
resolveIosDeviceDeepLinkBundleId(undefined, 'myapp://home'),
undefined,
);
});
22 changes: 22 additions & 0 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
import { setIosSetting } from '../platforms/ios/index.ts';
import { isDeepLinkTarget } from './open-target.ts';
import type { RawSnapshotNode } from '../utils/snapshot.ts';
import type { CliFlags } from '../utils/command-schema.ts';

Expand Down Expand Up @@ -89,10 +90,31 @@ export async function dispatchCommand(
switch (command) {
case 'open': {
const app = positionals[0];
const url = positionals[1];
if (positionals.length > 2) {
throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: <app|url> [url]');
}
if (!app) {
await interactor.openDevice();
return { app: null };
}
if (url !== undefined) {
if (device.platform !== 'ios') {
throw new AppError('INVALID_ARGS', 'open <app> <url> is supported only on iOS');
}
if (isDeepLinkTarget(app)) {
throw new AppError('INVALID_ARGS', 'open <app> <url> requires an app target as the first argument');
}
if (!isDeepLinkTarget(url)) {
throw new AppError('INVALID_ARGS', 'open <app> <url> requires a valid URL target');
}
await interactor.open(app, {
activity: context?.activity,
appBundleId: context?.appBundleId,
url,
});
return { app, url };
}
await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId });
return { app };
}
Expand Down
14 changes: 14 additions & 0 deletions src/core/open-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,17 @@ export function isDeepLinkTarget(input: string): boolean {
}
return true;
}

export function isWebUrl(input: string): boolean {
const scheme = input.trim().split(':')[0]?.toLowerCase();
return scheme === 'http' || scheme === 'https';
}

export const IOS_SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';

export function resolveIosDeviceDeepLinkBundleId(appBundleId: string | undefined, url: string): string | undefined {
const bundleId = appBundleId?.trim();
if (bundleId) return bundleId;
if (isWebUrl(url)) return IOS_SAFARI_BUNDLE_ID;
return undefined;
}
Loading
Loading