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.
- package/README.md +58 -16
- package/dist/src/bin.js +35 -96
- package/dist/src/daemon.js +16 -15
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
- package/ios-runner/README.md +1 -1
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +32 -14
- package/skills/agent-device/references/permissions.md +15 -1
- package/skills/agent-device/references/session-management.md +2 -0
- package/skills/agent-device/references/snapshot-refs.md +2 -0
- package/skills/agent-device/references/video-recording.md +2 -0
- package/src/cli.ts +7 -3
- package/src/core/__tests__/capabilities.test.ts +11 -6
- package/src/core/__tests__/open-target.test.ts +16 -0
- package/src/core/capabilities.ts +26 -20
- package/src/core/dispatch.ts +110 -31
- package/src/core/open-target.ts +13 -0
- package/src/daemon/__tests__/app-state.test.ts +138 -0
- package/src/daemon/__tests__/session-store.test.ts +24 -0
- package/src/daemon/app-state.ts +37 -38
- package/src/daemon/context.ts +12 -0
- package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
- package/src/daemon/handlers/__tests__/session.test.ts +226 -5
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
- package/src/daemon/handlers/interaction.ts +37 -0
- package/src/daemon/handlers/record-trace.ts +1 -1
- package/src/daemon/handlers/session.ts +96 -26
- package/src/daemon/handlers/snapshot.ts +21 -3
- package/src/daemon/session-store.ts +11 -0
- package/src/daemon-client.ts +14 -6
- package/src/daemon.ts +1 -1
- package/src/platforms/android/__tests__/index.test.ts +67 -1
- package/src/platforms/android/index.ts +41 -0
- package/src/platforms/ios/__tests__/index.test.ts +24 -0
- package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
- package/src/platforms/ios/devices.ts +40 -18
- package/src/platforms/ios/index.ts +70 -5
- package/src/platforms/ios/runner-client.ts +329 -42
- package/src/utils/__tests__/args.test.ts +175 -0
- package/src/utils/args.ts +174 -212
- package/src/utils/command-schema.ts +591 -0
- 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?
|
package/ios-runner/README.md
CHANGED
|
@@ -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
|
|
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
|
-
description: Automates
|
|
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]
|
|
45
|
-
agent-device open [app] --
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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),
|
|
171
|
-
- `open <app>` can be used within an existing session to switch apps
|
|
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
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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),
|
|
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
|
+
});
|
package/src/core/capabilities.ts
CHANGED
|
@@ -13,32 +13,34 @@ type CommandCapability = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
|
|
16
|
-
// iOS simulator-only
|
|
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
|
+
}
|
package/src/core/dispatch.ts
CHANGED
|
@@ -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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
+
}
|