agent-device 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +58 -16
  2. package/dist/src/bin.js +35 -96
  3. package/dist/src/daemon.js +16 -15
  4. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
  5. package/ios-runner/README.md +1 -1
  6. package/package.json +1 -1
  7. package/skills/agent-device/SKILL.md +32 -14
  8. package/skills/agent-device/references/permissions.md +15 -1
  9. package/skills/agent-device/references/session-management.md +2 -0
  10. package/skills/agent-device/references/snapshot-refs.md +2 -0
  11. package/skills/agent-device/references/video-recording.md +2 -0
  12. package/src/cli.ts +7 -3
  13. package/src/core/__tests__/capabilities.test.ts +11 -6
  14. package/src/core/__tests__/open-target.test.ts +16 -0
  15. package/src/core/capabilities.ts +26 -20
  16. package/src/core/dispatch.ts +110 -31
  17. package/src/core/open-target.ts +13 -0
  18. package/src/daemon/__tests__/app-state.test.ts +138 -0
  19. package/src/daemon/__tests__/session-store.test.ts +24 -0
  20. package/src/daemon/app-state.ts +37 -38
  21. package/src/daemon/context.ts +12 -0
  22. package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
  23. package/src/daemon/handlers/__tests__/session.test.ts +226 -5
  24. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
  25. package/src/daemon/handlers/interaction.ts +37 -0
  26. package/src/daemon/handlers/record-trace.ts +1 -1
  27. package/src/daemon/handlers/session.ts +96 -26
  28. package/src/daemon/handlers/snapshot.ts +21 -3
  29. package/src/daemon/session-store.ts +11 -0
  30. package/src/daemon-client.ts +14 -6
  31. package/src/daemon.ts +1 -1
  32. package/src/platforms/android/__tests__/index.test.ts +67 -1
  33. package/src/platforms/android/index.ts +41 -0
  34. package/src/platforms/ios/__tests__/index.test.ts +24 -0
  35. package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
  36. package/src/platforms/ios/devices.ts +40 -18
  37. package/src/platforms/ios/index.ts +70 -5
  38. package/src/platforms/ios/runner-client.ts +329 -42
  39. package/src/utils/__tests__/args.test.ts +175 -0
  40. package/src/utils/args.ts +174 -212
  41. package/src/utils/command-schema.ts +591 -0
  42. package/src/utils/interactors.ts +13 -3
@@ -251,6 +251,13 @@ final class RunnerTests: XCTestCase {
251
251
  let duration = (command.durationMs ?? 800) / 1000.0
252
252
  longPressAt(app: activeApp, x: x, y: y, duration: duration)
253
253
  return Response(ok: true, data: DataPayload(message: "long pressed"))
254
+ case .drag:
255
+ guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
256
+ return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
257
+ }
258
+ let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
259
+ dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
260
+ return Response(ok: true, data: DataPayload(message: "dragged"))
254
261
  case .type:
255
262
  guard let text = command.text else {
256
263
  return Response(ok: false, error: ErrorPayload(message: "type requires text"))
@@ -436,6 +443,20 @@ final class RunnerTests: XCTestCase {
436
443
  coordinate.press(forDuration: duration)
437
444
  }
438
445
 
446
+ private func dragAt(
447
+ app: XCUIApplication,
448
+ x: Double,
449
+ y: Double,
450
+ x2: Double,
451
+ y2: Double,
452
+ holdDuration: TimeInterval
453
+ ) {
454
+ let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
455
+ let start = origin.withOffset(CGVector(dx: x, dy: y))
456
+ let end = origin.withOffset(CGVector(dx: x2, dy: y2))
457
+ start.press(forDuration: holdDuration, thenDragTo: end)
458
+ }
459
+
439
460
  private func swipe(app: XCUIApplication, direction: SwipeDirection) {
440
461
  let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
441
462
  let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
@@ -956,6 +977,7 @@ private func resolveRunnerPort() -> UInt16 {
956
977
  enum CommandType: String, Codable {
957
978
  case tap
958
979
  case longPress
980
+ case drag
959
981
  case type
960
982
  case swipe
961
983
  case findText
@@ -984,6 +1006,8 @@ struct Command: Codable {
984
1006
  let action: String?
985
1007
  let x: Double?
986
1008
  let y: Double?
1009
+ let x2: Double?
1010
+ let y2: Double?
987
1011
  let durationMs: Double?
988
1012
  let direction: SwipeDirection?
989
1013
  let scale: Double?
@@ -8,4 +8,4 @@ This folder is reserved for the lightweight XCUITest runner used to provide elem
8
8
  - Support simulator prebuilds where compatible.
9
9
 
10
10
  ## Status
11
- Planned for v1 automation layer. See `docs/ios-automation.md` and `docs/ios-runner-protocol.md`.
11
+ Planned for the automation layer. See `docs/ios-automation.md` and `docs/ios-runner-protocol.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Callstack",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agent-device
3
- description: Automates mobile and simulator interactions for iOS and Android devices. Use when navigating apps, taking snapshots/screenshots, tapping, typing, scrolling, pinching, or extracting UI info on mobile devices or simulators.
3
+ description: Automates interactions for iOS simulators/devices and Android emulators/devices. Use when navigating apps, taking snapshots/screenshots, tapping, typing, scrolling, or extracting UI info on mobile targets.
4
4
  ---
5
5
 
6
6
  # Mobile Automation with agent-device
@@ -27,7 +27,7 @@ npx -y agent-device
27
27
 
28
28
  ## Core workflow
29
29
 
30
- 1. Open app: `open [app]` (`open` handles target selection + boot/activation in the normal flow)
30
+ 1. Open app or deep link: `open [app|url]` (`open` handles target selection + boot/activation in the normal flow)
31
31
  2. Snapshot: `snapshot` to get refs from accessibility tree
32
32
  3. Interact using refs (`click @ref`, `fill @ref "text"`)
33
33
  4. Re-snapshot after navigation/UI changes
@@ -39,10 +39,13 @@ npx -y agent-device
39
39
 
40
40
  ```bash
41
41
  agent-device boot # Ensure target is booted/ready without opening app
42
- agent-device boot --platform ios # Boot iOS simulator
42
+ agent-device boot --platform ios # Boot iOS simulator/device target
43
43
  agent-device boot --platform android # Boot Android emulator/device target
44
- agent-device open [app] # Boot device/simulator; optionally launch app
45
- agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity
44
+ agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
45
+ agent-device open [app] --relaunch # Terminate app process first, then launch (fresh runtime)
46
+ agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
47
+ agent-device open "myapp://home" --platform android # Android deep link
48
+ agent-device open "https://example.com" --platform ios # iOS simulator deep link (device unsupported)
46
49
  agent-device close [app] # Close app or just end session
47
50
  agent-device reinstall <app> <path> # Uninstall + install app in one command
48
51
  agent-device session list # List active sessions
@@ -61,10 +64,10 @@ agent-device snapshot -d 3 # Limit depth
61
64
  agent-device snapshot -s "Camera" # Scope to label/identifier
62
65
  agent-device snapshot --raw # Raw node output
63
66
  agent-device snapshot --backend xctest # default: XCTest snapshot (fast, complete, no permissions)
64
- agent-device snapshot --backend ax # macOS Accessibility tree (fast, needs permissions, less fidelity, optional)
67
+ agent-device snapshot --backend ax # macOS Accessibility tree (manual diagnostics only; no automatic fallback)
65
68
  ```
66
69
 
67
- XCTest is the default: fast and complete and does not require permissions. Use it in most cases and only fall back to AX when something breaks.
70
+ XCTest is the default: fast and complete and does not require permissions. Use AX only for manual diagnostics, and prefer XCTest for normal automation flows. agent-device does not automatically fall back to AX.
68
71
 
69
72
  ### Find (semantic)
70
73
 
@@ -79,7 +82,7 @@ agent-device find "Settings" wait 10000
79
82
  agent-device find "Settings" exists
80
83
  ```
81
84
 
82
- ### Settings helpers (simulators)
85
+ ### Settings helpers
83
86
 
84
87
  ```bash
85
88
  agent-device settings wifi on
@@ -92,6 +95,7 @@ agent-device settings location off
92
95
 
93
96
  Note: iOS wifi/airplane toggles status bar indicators, not actual network state.
94
97
  Airplane off clears status bar overrides.
98
+ iOS settings helpers are simulator-only.
95
99
 
96
100
  ### App state
97
101
 
@@ -109,10 +113,14 @@ agent-device focus @e2
109
113
  agent-device fill @e2 "text" # Clear then type (Android: verifies value and retries once on mismatch)
110
114
  agent-device type "text" # Type into focused field without clearing
111
115
  agent-device press 300 500 # Tap by coordinates
116
+ agent-device press 300 500 --count 12 --interval-ms 45
117
+ agent-device press 300 500 --count 6 --hold-ms 120 --interval-ms 30 --jitter-px 2
118
+ agent-device swipe 540 1500 540 500 120
119
+ agent-device swipe 540 1500 540 500 120 --count 8 --pause-ms 30 --pattern ping-pong
112
120
  agent-device long-press 300 500 800 # Long press (where supported)
113
121
  agent-device scroll down 0.5
114
- agent-device pinch 2.0 # Zoom in 2x (iOS simulator + Android)
115
- agent-device pinch 0.5 200 400 # Zoom out at coordinates
122
+ agent-device pinch 2.0 # Zoom in 2x (iOS simulator only)
123
+ agent-device pinch 0.5 200 400 # Zoom out at coordinates (iOS simulator only)
116
124
  agent-device back
117
125
  agent-device home
118
126
  agent-device app-switcher
@@ -134,12 +142,14 @@ agent-device screenshot out.png
134
142
  ### Deterministic replay and updating
135
143
 
136
144
  ```bash
145
+ agent-device open App --relaunch # Fresh app process restart in the current session
137
146
  agent-device open App --save-script # Save session script (.ad) on close
138
147
  agent-device replay ./session.ad # Run deterministic replay from .ad script
139
148
  agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place
140
149
  ```
141
150
 
142
151
  `replay` reads `.ad` recordings.
152
+ `--relaunch` controls launch semantics; `--save-script` controls recording. Combine only when both are needed.
143
153
 
144
154
  ### Trace logs (AX/XCTest)
145
155
 
@@ -162,16 +172,24 @@ agent-device apps --platform android --user-installed
162
172
 
163
173
  ## Best practices
164
174
 
165
- - Pinch (`pinch <scale> [x y]`) is supported on iOS simulators and Android; scale > 1 zooms in, < 1 zooms out. On Android, pinch uses multi-touch `sendevent` injection.
175
+ - `press` supports gesture series controls: `--count`, `--interval-ms`, `--hold-ms`, `--jitter-px`.
176
+ - `swipe` supports coordinate + timing controls and repeat patterns: `swipe x1 y1 x2 y2 [durationMs] --count --pause-ms --pattern`.
177
+ - `swipe` timing is platform-safe: Android uses requested duration; iOS uses normalized safe timing to avoid long-press side effects.
178
+ - Pinch (`pinch <scale> [x y]`) is iOS simulator-only; scale > 1 zooms in, < 1 zooms out.
166
179
  - Snapshot refs are the core mechanism for interactive agent flows.
167
180
  - Use selectors for deterministic replay artifacts and assertions (e.g. in e2e test workflows).
168
181
  - Prefer `snapshot -i` to reduce output size.
169
182
  - On iOS, `xctest` is the default and does not require Accessibility permission.
170
- - If XCTest returns 0 nodes (foreground app changed), agent-device falls back to AX when available.
171
- - `open <app>` can be used within an existing session to switch apps and update the session bundle id.
183
+ - If XCTest returns 0 nodes (foreground app changed), treat it as an explicit failure and retry the flow/app state.
184
+ - `open <app|url>` can be used within an existing session to switch apps or open deep links.
185
+ - `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
186
+ - Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
172
187
  - If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
173
188
  - Use `--session <name>` for parallel sessions; avoid device contention.
174
- - Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK).
189
+ - Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
190
+ - iOS deep-link opens are simulator-only.
191
+ - 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`.
192
+ - For long first-run physical-device setup/build, increase daemon timeout: `AGENT_DEVICE_DAEMON_TIMEOUT_MS=180000` (or higher).
175
193
  - Use `fill` when you want clear-then-type semantics.
176
194
  - Use `type` when you want to append/enter text without clearing.
177
195
  - On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## iOS AX snapshot
4
4
 
5
- AX snapshot is an alternative to XCTest for when it fails (which shouldn't happen usually); it uses macOS Accessibility APIs and requires permission:
5
+ AX snapshot is available for manual diagnostics when needed; it is not used as an automatic fallback. It uses macOS Accessibility APIs and requires permission:
6
6
 
7
7
  System Settings > Privacy & Security > Accessibility
8
8
 
@@ -13,6 +13,20 @@ agent-device snapshot --backend xctest --platform ios
13
13
  ```
14
14
 
15
15
  Hybrid/AX is fast; XCTest is equally fast but does not require permissions.
16
+ AX backend is simulator-only.
17
+
18
+ ## iOS physical device runner
19
+
20
+ For iOS physical devices, XCTest runner setup requires valid signing/provisioning.
21
+ Use Automatic Signing in Xcode, or provide optional overrides:
22
+
23
+ - `AGENT_DEVICE_IOS_TEAM_ID`
24
+ - `AGENT_DEVICE_IOS_SIGNING_IDENTITY`
25
+ - `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`
26
+
27
+ If first-run setup/build takes long, increase:
28
+
29
+ - `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `180000`)
16
30
 
17
31
  ## Simulator troubleshooting
18
32
 
@@ -14,6 +14,8 @@ Sessions isolate device context. A device can only be held by one session at a t
14
14
  - Name sessions semantically.
15
15
  - Close sessions when done.
16
16
  - Use separate sessions for parallel work.
17
+ - In iOS sessions, use `open <app>` for simulator/device. `open <url>` is simulator-only.
18
+ - 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.
17
19
  - For deterministic replay scripts, prefer selector-based actions and assertions.
18
20
  - Use `replay -u` to update selector drift during maintenance.
19
21
 
@@ -55,6 +55,8 @@ agent-device snapshot -i -s @e3
55
55
  - Ref not found: re-snapshot.
56
56
  - AX returns Simulator window: restart Simulator and re-run.
57
57
  - AX empty: verify Accessibility permission or use `--backend xctest` (XCTest is more complete).
58
+ - AX backend is simulator-only; use `--backend xctest` on iOS devices.
59
+ - agent-device does not automatically fall back to AX when XCTest fails.
58
60
 
59
61
  ## Replay note
60
62
 
@@ -20,6 +20,8 @@ agent-device close
20
20
  agent-device record stop
21
21
  ```
22
22
 
23
+ `record` is iOS simulator-only.
24
+
23
25
  ## Android Emulator/Device
24
26
 
25
27
  Use `agent-device record` commands (wrapper around adb):
package/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { parseArgs, usage } from './utils/args.ts';
1
+ import { parseArgs, toDaemonFlags, usage } from './utils/args.ts';
2
2
  import { asAppError, AppError } from './utils/errors.ts';
3
3
  import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
4
4
  import { readVersion } from './utils/version.ts';
@@ -10,6 +10,9 @@ import path from 'node:path';
10
10
 
11
11
  export async function runCli(argv: string[]): Promise<void> {
12
12
  const parsed = parseArgs(argv);
13
+ for (const warning of parsed.warnings) {
14
+ process.stderr.write(`Warning: ${warning}\n`);
15
+ }
13
16
 
14
17
  if (parsed.flags.version) {
15
18
  process.stdout.write(`${readVersion()}\n`);
@@ -22,6 +25,7 @@ export async function runCli(argv: string[]): Promise<void> {
22
25
  }
23
26
 
24
27
  const { command, positionals, flags } = parsed;
28
+ const daemonFlags = toDaemonFlags(flags);
25
29
  const sessionName = flags.session ?? process.env.AGENT_DEVICE_SESSION ?? 'default';
26
30
  const logTailStopper = flags.verbose && !flags.json ? startDaemonLogTail() : null;
27
31
  try {
@@ -34,7 +38,7 @@ export async function runCli(argv: string[]): Promise<void> {
34
38
  session: sessionName,
35
39
  command: 'session_list',
36
40
  positionals: [],
37
- flags: {},
41
+ flags: daemonFlags,
38
42
  });
39
43
  if (!response.ok) throw new AppError(response.error.code as any, response.error.message);
40
44
  if (flags.json) printJson({ success: true, data: response.data ?? {} });
@@ -47,7 +51,7 @@ export async function runCli(argv: string[]): Promise<void> {
47
51
  session: sessionName,
48
52
  command: command!,
49
53
  positionals,
50
- flags,
54
+ flags: daemonFlags,
51
55
  });
52
56
 
53
57
  if (response.ok) {
@@ -32,10 +32,17 @@ test('iOS simulator-only commands reject iOS devices and Android', () => {
32
32
  }
33
33
  });
34
34
 
35
- test('iOS simulator + Android commands reject iOS devices', () => {
35
+ test('simulator-only iOS commands with Android support reject iOS devices', () => {
36
+ for (const cmd of ['apps', 'reinstall', 'record', 'settings', 'swipe']) {
37
+ assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
38
+ assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
39
+ assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
40
+ }
41
+ });
42
+
43
+ test('core commands support iOS simulator, iOS device, and Android', () => {
36
44
  for (const cmd of [
37
45
  'app-switcher',
38
- 'apps',
39
46
  'back',
40
47
  'boot',
41
48
  'click',
@@ -47,18 +54,16 @@ test('iOS simulator + Android commands reject iOS devices', () => {
47
54
  'home',
48
55
  'long-press',
49
56
  'open',
50
- 'reinstall',
51
57
  'press',
52
- 'record',
53
58
  'screenshot',
54
59
  'scroll',
55
- 'settings',
60
+ 'scrollintoview',
56
61
  'snapshot',
57
62
  'type',
58
63
  'wait',
59
64
  ]) {
60
65
  assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
61
- assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
66
+ assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), true, `${cmd} on iOS device`);
62
67
  assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
63
68
  }
64
69
  });
@@ -0,0 +1,16 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { isDeepLinkTarget } from '../open-target.ts';
4
+
5
+ test('isDeepLinkTarget accepts URL-style deep links', () => {
6
+ assert.equal(isDeepLinkTarget('myapp://home'), true);
7
+ assert.equal(isDeepLinkTarget('https://example.com'), true);
8
+ assert.equal(isDeepLinkTarget('tel:123456789'), true);
9
+ assert.equal(isDeepLinkTarget('mailto:test@example.com'), true);
10
+ });
11
+
12
+ test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
13
+ assert.equal(isDeepLinkTarget('com.example.app'), false);
14
+ assert.equal(isDeepLinkTarget('settings'), false);
15
+ assert.equal(isDeepLinkTarget('http:/x'), false);
16
+ });
@@ -13,32 +13,34 @@ type CommandCapability = {
13
13
  };
14
14
 
15
15
  const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
16
- // iOS simulator-only in v1.
16
+ // iOS simulator-only.
17
17
  alert: { ios: { simulator: true }, android: {} },
18
18
  pinch: { ios: { simulator: true }, android: {} },
19
- 'app-switcher': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
19
+ 'app-switcher': { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
20
20
  apps: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
21
- back: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
22
- boot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
23
- click: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
24
- close: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
25
- fill: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
26
- find: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
27
- focus: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
28
- get: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
29
- is: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
30
- home: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
31
- 'long-press': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
32
- open: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
21
+ back: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
22
+ boot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
23
+ click: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
24
+ close: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
25
+ fill: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
26
+ find: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
27
+ focus: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
28
+ get: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
29
+ is: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
30
+ home: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
31
+ 'long-press': { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
32
+ open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
33
33
  reinstall: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
34
- press: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
34
+ press: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
35
35
  record: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
36
- screenshot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
37
- scroll: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
36
+ screenshot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
37
+ scroll: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
38
+ scrollintoview: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
39
+ swipe: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
38
40
  settings: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
39
- snapshot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
40
- type: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
41
- wait: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
41
+ snapshot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
42
+ type: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
43
+ wait: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
42
44
  };
43
45
 
44
46
  export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean {
@@ -49,3 +51,7 @@ export function isCommandSupportedOnDevice(command: string, device: DeviceInfo):
49
51
  const kind = (device.kind ?? 'unknown') as keyof KindMatrix;
50
52
  return byPlatform[kind] === true;
51
53
  }
54
+
55
+ export function listCapabilityCommands(): string[] {
56
+ return Object.keys(COMMAND_CAPABILITY_MATRIX).sort();
57
+ }
@@ -17,28 +17,9 @@ import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
17
17
  import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
18
18
  import { setIosSetting } from '../platforms/ios/index.ts';
19
19
  import type { RawSnapshotNode } from '../utils/snapshot.ts';
20
+ import type { CliFlags } from '../utils/command-schema.ts';
20
21
 
21
- export type CommandFlags = {
22
- session?: string;
23
- platform?: 'ios' | 'android';
24
- device?: string;
25
- udid?: string;
26
- serial?: string;
27
- out?: string;
28
- activity?: string;
29
- verbose?: boolean;
30
- snapshotInteractiveOnly?: boolean;
31
- snapshotCompact?: boolean;
32
- snapshotDepth?: number;
33
- snapshotScope?: string;
34
- snapshotRaw?: boolean;
35
- snapshotBackend?: 'ax' | 'xctest';
36
- saveScript?: boolean;
37
- noRecord?: boolean;
38
- appsFilter?: 'launchable' | 'user-installed' | 'all';
39
- appsMetadata?: boolean;
40
- replayUpdate?: boolean;
41
- };
22
+ export type CommandFlags = Omit<CliFlags, 'json' | 'help' | 'version'>;
42
23
 
43
24
  export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceInfo> {
44
25
  const selector = {
@@ -90,6 +71,12 @@ export async function dispatchCommand(
90
71
  snapshotScope?: string;
91
72
  snapshotRaw?: boolean;
92
73
  snapshotBackend?: 'ax' | 'xctest';
74
+ count?: number;
75
+ intervalMs?: number;
76
+ holdMs?: number;
77
+ jitterPx?: number;
78
+ pauseMs?: number;
79
+ pattern?: 'one-way' | 'ping-pong';
93
80
  },
94
81
  ): Promise<Record<string, unknown> | void> {
95
82
  const runnerCtx: RunnerContext = {
@@ -106,7 +93,7 @@ export async function dispatchCommand(
106
93
  await interactor.openDevice();
107
94
  return { app: null };
108
95
  }
109
- await interactor.open(app, { activity: context?.activity });
96
+ await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId });
110
97
  return { app };
111
98
  }
112
99
  case 'close': {
@@ -120,8 +107,60 @@ export async function dispatchCommand(
120
107
  case 'press': {
121
108
  const [x, y] = positionals.map(Number);
122
109
  if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'press requires x y');
123
- await interactor.tap(x, y);
124
- return { x, y };
110
+ const count = requireIntInRange(context?.count ?? 1, 'count', 1, 200);
111
+ const intervalMs = requireIntInRange(context?.intervalMs ?? 0, 'interval-ms', 0, 10_000);
112
+ const holdMs = requireIntInRange(context?.holdMs ?? 0, 'hold-ms', 0, 10_000);
113
+ const jitterPx = requireIntInRange(context?.jitterPx ?? 0, 'jitter-px', 0, 100);
114
+
115
+ for (let index = 0; index < count; index += 1) {
116
+ const [dx, dy] = computeDeterministicJitter(index, jitterPx);
117
+ const targetX = x + dx;
118
+ const targetY = y + dy;
119
+ if (holdMs > 0) await interactor.longPress(targetX, targetY, holdMs);
120
+ else await interactor.tap(targetX, targetY);
121
+ if (index < count - 1 && intervalMs > 0) await sleep(intervalMs);
122
+ }
123
+
124
+ return { x, y, count, intervalMs, holdMs, jitterPx };
125
+ }
126
+ case 'swipe': {
127
+ const x1 = Number(positionals[0]);
128
+ const y1 = Number(positionals[1]);
129
+ const x2 = Number(positionals[2]);
130
+ const y2 = Number(positionals[3]);
131
+ if ([x1, y1, x2, y2].some(Number.isNaN)) {
132
+ throw new AppError('INVALID_ARGS', 'swipe requires x1 y1 x2 y2 [durationMs]');
133
+ }
134
+
135
+ const requestedDurationMs = positionals[4] ? Number(positionals[4]) : 250;
136
+ const durationMs = requireIntInRange(requestedDurationMs, 'durationMs', 16, 10_000);
137
+ const effectiveDurationMs = device.platform === 'ios' ? 60 : durationMs;
138
+ const count = requireIntInRange(context?.count ?? 1, 'count', 1, 200);
139
+ const pauseMs = requireIntInRange(context?.pauseMs ?? 0, 'pause-ms', 0, 10_000);
140
+ const pattern = context?.pattern ?? 'one-way';
141
+ if (pattern !== 'one-way' && pattern !== 'ping-pong') {
142
+ throw new AppError('INVALID_ARGS', `Invalid pattern: ${pattern}`);
143
+ }
144
+
145
+ for (let index = 0; index < count; index += 1) {
146
+ const reverse = pattern === 'ping-pong' && index % 2 === 1;
147
+ if (reverse) await interactor.swipe(x2, y2, x1, y1, effectiveDurationMs);
148
+ else await interactor.swipe(x1, y1, x2, y2, effectiveDurationMs);
149
+ if (index < count - 1 && pauseMs > 0) await sleep(pauseMs);
150
+ }
151
+
152
+ return {
153
+ x1,
154
+ y1,
155
+ x2,
156
+ y2,
157
+ durationMs,
158
+ effectiveDurationMs,
159
+ timingMode: device.platform === 'ios' ? 'safe-normalized' : 'direct',
160
+ count,
161
+ pauseMs,
162
+ pattern,
163
+ };
125
164
  }
126
165
  case 'long-press': {
127
166
  const x = Number(positionals[0]);
@@ -170,6 +209,12 @@ export async function dispatchCommand(
170
209
  return { text };
171
210
  }
172
211
  case 'pinch': {
212
+ if (device.platform === 'android') {
213
+ throw new AppError(
214
+ 'UNSUPPORTED_OPERATION',
215
+ 'Android pinch is not supported in current adb backend; requires instrumentation-based backend.',
216
+ );
217
+ }
173
218
  const scale = Number(positionals[0]);
174
219
  const x = positionals[1] ? Number(positionals[1]) : undefined;
175
220
  const y = positionals[2] ? Number(positionals[2]) : undefined;
@@ -238,6 +283,13 @@ export async function dispatchCommand(
238
283
  case 'snapshot': {
239
284
  const backend = context?.snapshotBackend ?? 'xctest';
240
285
  if (device.platform === 'ios') {
286
+ // Keep this guard for non-daemon callers that invoke dispatch directly.
287
+ if (backend === 'ax' && device.kind !== 'simulator') {
288
+ throw new AppError(
289
+ 'UNSUPPORTED_OPERATION',
290
+ 'AX snapshot backend is not supported on iOS physical devices; use --backend xctest',
291
+ );
292
+ }
241
293
  if (backend === 'ax') {
242
294
  const ax = await snapshotAx(device, { traceLogPath: context?.traceLogPath });
243
295
  return { nodes: ax.nodes ?? [], truncated: false, backend: 'ax' };
@@ -256,13 +308,11 @@ export async function dispatchCommand(
256
308
  { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
257
309
  )) as { nodes?: RawSnapshotNode[]; truncated?: boolean };
258
310
  const nodes = result.nodes ?? [];
259
- if (nodes.length === 0) {
260
- try {
261
- const ax = await snapshotAx(device, { traceLogPath: context?.traceLogPath });
262
- return { nodes: ax.nodes ?? [], truncated: false, backend: 'ax' };
263
- } catch {
264
- // keep the empty XCTest snapshot if AX is unavailable
265
- }
311
+ if (nodes.length === 0 && device.kind === 'simulator') {
312
+ throw new AppError(
313
+ 'COMMAND_FAILED',
314
+ 'XCTest snapshot returned 0 nodes on iOS simulator. You can try --backend ax for diagnostics, but AX snapshots are not recommended.',
315
+ );
266
316
  }
267
317
  return { nodes, truncated: result.truncated ?? false, backend: 'xctest' };
268
318
  }
@@ -279,3 +329,32 @@ export async function dispatchCommand(
279
329
  throw new AppError('INVALID_ARGS', `Unknown command: ${command}`);
280
330
  }
281
331
  }
332
+
333
+ const DETERMINISTIC_JITTER_PATTERN: ReadonlyArray<readonly [number, number]> = [
334
+ [0, 0],
335
+ [1, 0],
336
+ [0, 1],
337
+ [-1, 0],
338
+ [0, -1],
339
+ [1, 1],
340
+ [-1, 1],
341
+ [1, -1],
342
+ [-1, -1],
343
+ ];
344
+
345
+ function requireIntInRange(value: number, name: string, min: number, max: number): number {
346
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max) {
347
+ throw new AppError('INVALID_ARGS', `${name} must be an integer between ${min} and ${max}`);
348
+ }
349
+ return value;
350
+ }
351
+
352
+ function computeDeterministicJitter(index: number, jitterPx: number): [number, number] {
353
+ if (jitterPx <= 0) return [0, 0];
354
+ const [dx, dy] = DETERMINISTIC_JITTER_PATTERN[index % DETERMINISTIC_JITTER_PATTERN.length];
355
+ return [dx * jitterPx, dy * jitterPx];
356
+ }
357
+
358
+ async function sleep(ms: number): Promise<void> {
359
+ await new Promise((resolve) => setTimeout(resolve, ms));
360
+ }
@@ -0,0 +1,13 @@
1
+ export function isDeepLinkTarget(input: string): boolean {
2
+ const value = input.trim();
3
+ if (!value) return false;
4
+ if (/\s/.test(value)) return false;
5
+ const match = /^([A-Za-z][A-Za-z0-9+.-]*):(.+)$/.exec(value);
6
+ if (!match) return false;
7
+ const scheme = match[1]?.toLowerCase();
8
+ const rest = match[2] ?? '';
9
+ if (scheme === 'http' || scheme === 'https' || scheme === 'ws' || scheme === 'wss' || scheme === 'ftp' || scheme === 'ftps') {
10
+ return rest.startsWith('//');
11
+ }
12
+ return true;
13
+ }