agent-device 0.2.3 → 0.2.5
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 +41 -4
- package/dist/src/bin.js +15 -11
- package/dist/src/daemon.js +9 -8
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +2 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +15 -0
- package/package.json +3 -2
- package/skills/agent-device/SKILL.md +23 -7
- package/skills/agent-device/references/session-management.md +9 -0
- package/skills/agent-device/references/snapshot-refs.md +18 -5
- package/skills/agent-device/references/video-recording.md +2 -2
- package/src/cli.ts +6 -0
- package/src/core/__tests__/capabilities.test.ts +67 -0
- package/src/core/capabilities.ts +49 -0
- package/src/core/dispatch.ts +30 -118
- package/src/daemon/__tests__/is-predicates.test.ts +68 -0
- package/src/daemon/__tests__/selectors.test.ts +128 -0
- package/src/daemon/__tests__/session-routing.test.ts +108 -0
- package/src/daemon/__tests__/session-selector.test.ts +64 -0
- package/src/daemon/__tests__/session-store.test.ts +95 -0
- package/src/daemon/__tests__/snapshot-processing.test.ts +47 -0
- package/src/daemon/action-utils.ts +29 -0
- package/src/daemon/app-state.ts +66 -0
- package/src/daemon/context.ts +36 -0
- package/src/daemon/device-ready.ts +13 -0
- package/src/daemon/handlers/__tests__/find.test.ts +99 -0
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +364 -0
- package/src/daemon/handlers/__tests__/snapshot.test.ts +128 -0
- package/src/daemon/handlers/find.ts +304 -0
- package/src/daemon/handlers/interaction.ts +510 -0
- package/src/daemon/handlers/parse-utils.ts +8 -0
- package/src/daemon/handlers/record-trace.ts +154 -0
- package/src/daemon/handlers/session.ts +732 -0
- package/src/daemon/handlers/snapshot.ts +396 -0
- package/src/daemon/is-predicates.ts +46 -0
- package/src/daemon/selectors.ts +423 -0
- package/src/daemon/session-routing.ts +22 -0
- package/src/daemon/session-selector.ts +39 -0
- package/src/daemon/session-store.ts +275 -0
- package/src/daemon/snapshot-processing.ts +127 -0
- package/src/daemon/types.ts +55 -0
- package/src/daemon.ts +66 -1564
- package/src/platforms/ios/index.ts +0 -62
- package/src/platforms/ios/runner-client.ts +2 -0
- package/src/utils/args.ts +20 -12
- package/src/utils/interactors.ts +102 -16
- package/src/utils/snapshot.ts +1 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Snapshot
|
|
1
|
+
# Snapshot Refs and Selectors (Mobile)
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
Refs
|
|
5
|
+
Refs are useful for discovery/debugging. For deterministic scripts, use selectors.
|
|
6
6
|
|
|
7
7
|
## Snapshot
|
|
8
8
|
|
|
@@ -21,17 +21,25 @@ App: com.apple.Preferences
|
|
|
21
21
|
@e3 [button] "Privacy & Security"
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
## Using refs
|
|
24
|
+
## Using refs (discovery/debug)
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
agent-device click @e2
|
|
28
28
|
agent-device fill @e5 "test"
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
## Using selectors (deterministic)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
agent-device click 'id="camera_row" || label="Camera" role=button'
|
|
35
|
+
agent-device fill 'id="search_input" editable=true' "test"
|
|
36
|
+
agent-device is visible 'id="camera_settings_anchor"'
|
|
37
|
+
```
|
|
38
|
+
|
|
31
39
|
## Ref lifecycle
|
|
32
40
|
|
|
33
|
-
Refs become invalid when UI changes (navigation, modal, dynamic list updates).
|
|
34
|
-
|
|
41
|
+
Refs can become invalid when UI changes (navigation, modal, dynamic list updates).
|
|
42
|
+
Re-snapshot after transitions if you keep using refs.
|
|
35
43
|
|
|
36
44
|
## Scope snapshots
|
|
37
45
|
|
|
@@ -47,3 +55,8 @@ agent-device snapshot -i -s @e3
|
|
|
47
55
|
- Ref not found: re-snapshot.
|
|
48
56
|
- AX returns Simulator window: restart Simulator and re-run.
|
|
49
57
|
- AX empty: verify Accessibility permission or use `--backend xctest` (XCTest is more complete).
|
|
58
|
+
|
|
59
|
+
## Replay note
|
|
60
|
+
|
|
61
|
+
- Prefer selector-based actions in recorded `.ad` replays.
|
|
62
|
+
- Use `agent-device replay -u <path>` to update selector drift and rewrite replay scripts in place.
|
|
@@ -12,7 +12,7 @@ agent-device record start ./recordings/ios.mov
|
|
|
12
12
|
|
|
13
13
|
# Perform actions
|
|
14
14
|
agent-device open App
|
|
15
|
-
agent-device snapshot
|
|
15
|
+
agent-device snapshot -i
|
|
16
16
|
agent-device click @e3
|
|
17
17
|
agent-device close
|
|
18
18
|
|
|
@@ -30,7 +30,7 @@ agent-device record start ./recordings/android.mp4
|
|
|
30
30
|
|
|
31
31
|
# Perform actions
|
|
32
32
|
agent-device open App
|
|
33
|
-
agent-device snapshot
|
|
33
|
+
agent-device snapshot -i
|
|
34
34
|
agent-device click @e3
|
|
35
35
|
agent-device close
|
|
36
36
|
|
package/src/cli.ts
CHANGED
|
@@ -93,6 +93,12 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
+
if (command === 'is') {
|
|
97
|
+
const predicate = (response.data as any)?.predicate ?? 'assertion';
|
|
98
|
+
process.stdout.write(`Passed: is ${predicate}\n`);
|
|
99
|
+
if (logTailStopper) logTailStopper();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
96
102
|
if (command === 'click') {
|
|
97
103
|
const ref = (response.data as any)?.ref ?? '';
|
|
98
104
|
const x = (response.data as any)?.x;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { isCommandSupportedOnDevice } from '../capabilities.ts';
|
|
4
|
+
import type { DeviceInfo } from '../../utils/device.ts';
|
|
5
|
+
|
|
6
|
+
const iosSimulator: DeviceInfo = {
|
|
7
|
+
platform: 'ios',
|
|
8
|
+
id: 'sim-1',
|
|
9
|
+
name: 'iPhone',
|
|
10
|
+
kind: 'simulator',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const iosDevice: DeviceInfo = {
|
|
14
|
+
platform: 'ios',
|
|
15
|
+
id: 'dev-1',
|
|
16
|
+
name: 'iPhone',
|
|
17
|
+
kind: 'device',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const androidDevice: DeviceInfo = {
|
|
21
|
+
platform: 'android',
|
|
22
|
+
id: 'and-1',
|
|
23
|
+
name: 'Pixel',
|
|
24
|
+
kind: 'device',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
test('iOS simulator-only commands reject iOS devices and Android', () => {
|
|
28
|
+
for (const cmd of ['alert', 'pinch']) {
|
|
29
|
+
assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
|
|
30
|
+
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
|
|
31
|
+
assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), false, `${cmd} on Android`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('iOS simulator + Android commands reject iOS devices', () => {
|
|
36
|
+
for (const cmd of [
|
|
37
|
+
'app-switcher',
|
|
38
|
+
'apps',
|
|
39
|
+
'back',
|
|
40
|
+
'click',
|
|
41
|
+
'close',
|
|
42
|
+
'fill',
|
|
43
|
+
'find',
|
|
44
|
+
'focus',
|
|
45
|
+
'get',
|
|
46
|
+
'home',
|
|
47
|
+
'long-press',
|
|
48
|
+
'open',
|
|
49
|
+
'press',
|
|
50
|
+
'record',
|
|
51
|
+
'screenshot',
|
|
52
|
+
'scroll',
|
|
53
|
+
'settings',
|
|
54
|
+
'snapshot',
|
|
55
|
+
'type',
|
|
56
|
+
'wait',
|
|
57
|
+
]) {
|
|
58
|
+
assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
|
|
59
|
+
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
|
|
60
|
+
assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('unknown commands default to supported', () => {
|
|
65
|
+
assert.equal(isCommandSupportedOnDevice('some-future-cmd', iosSimulator), true);
|
|
66
|
+
assert.equal(isCommandSupportedOnDevice('some-future-cmd', androidDevice), true);
|
|
67
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DeviceInfo } from '../utils/device.ts';
|
|
2
|
+
|
|
3
|
+
type KindMatrix = {
|
|
4
|
+
simulator?: boolean;
|
|
5
|
+
device?: boolean;
|
|
6
|
+
emulator?: boolean;
|
|
7
|
+
unknown?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type CommandCapability = {
|
|
11
|
+
ios?: KindMatrix;
|
|
12
|
+
android?: KindMatrix;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
|
|
16
|
+
// iOS simulator-only in v1.
|
|
17
|
+
alert: { ios: { simulator: true }, android: {} },
|
|
18
|
+
pinch: { ios: { simulator: true }, android: {} },
|
|
19
|
+
'app-switcher': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
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
|
+
click: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
23
|
+
close: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
24
|
+
fill: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
25
|
+
find: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
26
|
+
focus: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
27
|
+
get: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
28
|
+
is: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
29
|
+
home: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
30
|
+
'long-press': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
31
|
+
open: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
32
|
+
press: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
33
|
+
record: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
34
|
+
screenshot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
35
|
+
scroll: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
36
|
+
settings: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
37
|
+
snapshot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
38
|
+
type: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
39
|
+
wait: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean {
|
|
43
|
+
const capability = COMMAND_CAPABILITY_MATRIX[command];
|
|
44
|
+
if (!capability) return true;
|
|
45
|
+
const byPlatform = capability[device.platform];
|
|
46
|
+
if (!byPlatform) return false;
|
|
47
|
+
const kind = (device.kind ?? 'unknown') as keyof KindMatrix;
|
|
48
|
+
return byPlatform[kind] === true;
|
|
49
|
+
}
|
package/src/core/dispatch.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import pathModule from 'node:path';
|
|
1
3
|
import { AppError } from '../utils/errors.ts';
|
|
2
4
|
import { selectDevice, type DeviceInfo } from '../utils/device.ts';
|
|
3
5
|
import { listAndroidDevices } from '../platforms/android/devices.ts';
|
|
@@ -10,13 +12,14 @@ import {
|
|
|
10
12
|
snapshotAndroid,
|
|
11
13
|
} from '../platforms/android/index.ts';
|
|
12
14
|
import { listIosDevices } from '../platforms/ios/devices.ts';
|
|
13
|
-
import { getInteractor } from '../utils/interactors.ts';
|
|
15
|
+
import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
|
|
14
16
|
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
|
|
15
17
|
import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
|
|
16
18
|
import { setIosSetting } from '../platforms/ios/index.ts';
|
|
17
19
|
import type { RawSnapshotNode } from '../utils/snapshot.ts';
|
|
18
20
|
|
|
19
21
|
export type CommandFlags = {
|
|
22
|
+
session?: string;
|
|
20
23
|
platform?: 'ios' | 'android';
|
|
21
24
|
device?: string;
|
|
22
25
|
udid?: string;
|
|
@@ -30,10 +33,11 @@ export type CommandFlags = {
|
|
|
30
33
|
snapshotScope?: string;
|
|
31
34
|
snapshotRaw?: boolean;
|
|
32
35
|
snapshotBackend?: 'ax' | 'xctest';
|
|
36
|
+
saveScript?: boolean;
|
|
33
37
|
noRecord?: boolean;
|
|
34
|
-
recordJson?: boolean;
|
|
35
38
|
appsFilter?: 'launchable' | 'user-installed' | 'all';
|
|
36
39
|
appsMetadata?: boolean;
|
|
40
|
+
replayUpdate?: boolean;
|
|
37
41
|
};
|
|
38
42
|
|
|
39
43
|
export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceInfo> {
|
|
@@ -88,7 +92,13 @@ export async function dispatchCommand(
|
|
|
88
92
|
snapshotBackend?: 'ax' | 'xctest';
|
|
89
93
|
},
|
|
90
94
|
): Promise<Record<string, unknown> | void> {
|
|
91
|
-
const
|
|
95
|
+
const runnerCtx: RunnerContext = {
|
|
96
|
+
appBundleId: context?.appBundleId,
|
|
97
|
+
verbose: context?.verbose,
|
|
98
|
+
logPath: context?.logPath,
|
|
99
|
+
traceLogPath: context?.traceLogPath,
|
|
100
|
+
};
|
|
101
|
+
const interactor = getInteractor(device, runnerCtx);
|
|
92
102
|
switch (command) {
|
|
93
103
|
case 'open': {
|
|
94
104
|
const app = positionals[0];
|
|
@@ -110,15 +120,7 @@ export async function dispatchCommand(
|
|
|
110
120
|
case 'press': {
|
|
111
121
|
const [x, y] = positionals.map(Number);
|
|
112
122
|
if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'press requires x y');
|
|
113
|
-
|
|
114
|
-
await runIosRunnerCommand(
|
|
115
|
-
device,
|
|
116
|
-
{ command: 'tap', x, y, appBundleId: context?.appBundleId },
|
|
117
|
-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
|
|
118
|
-
);
|
|
119
|
-
} else {
|
|
120
|
-
await interactor.tap(x, y);
|
|
121
|
-
}
|
|
123
|
+
await interactor.tap(x, y);
|
|
122
124
|
return { x, y };
|
|
123
125
|
}
|
|
124
126
|
case 'long-press': {
|
|
@@ -134,29 +136,13 @@ export async function dispatchCommand(
|
|
|
134
136
|
case 'focus': {
|
|
135
137
|
const [x, y] = positionals.map(Number);
|
|
136
138
|
if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'focus requires x y');
|
|
137
|
-
|
|
138
|
-
await runIosRunnerCommand(
|
|
139
|
-
device,
|
|
140
|
-
{ command: 'tap', x, y, appBundleId: context?.appBundleId },
|
|
141
|
-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
|
|
142
|
-
);
|
|
143
|
-
} else {
|
|
144
|
-
await interactor.focus(x, y);
|
|
145
|
-
}
|
|
139
|
+
await interactor.focus(x, y);
|
|
146
140
|
return { x, y };
|
|
147
141
|
}
|
|
148
142
|
case 'type': {
|
|
149
143
|
const text = positionals.join(' ');
|
|
150
144
|
if (!text) throw new AppError('INVALID_ARGS', 'type requires text');
|
|
151
|
-
|
|
152
|
-
await runIosRunnerCommand(
|
|
153
|
-
device,
|
|
154
|
-
{ command: 'type', text, appBundleId: context?.appBundleId },
|
|
155
|
-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
|
|
156
|
-
);
|
|
157
|
-
} else {
|
|
158
|
-
await interactor.type(text);
|
|
159
|
-
}
|
|
145
|
+
await interactor.type(text);
|
|
160
146
|
return { text };
|
|
161
147
|
}
|
|
162
148
|
case 'fill': {
|
|
@@ -166,63 +152,21 @@ export async function dispatchCommand(
|
|
|
166
152
|
if (Number.isNaN(x) || Number.isNaN(y) || !text) {
|
|
167
153
|
throw new AppError('INVALID_ARGS', 'fill requires x y text');
|
|
168
154
|
}
|
|
169
|
-
|
|
170
|
-
await runIosRunnerCommand(
|
|
171
|
-
device,
|
|
172
|
-
{ command: 'tap', x, y, appBundleId: context?.appBundleId },
|
|
173
|
-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
|
|
174
|
-
);
|
|
175
|
-
await runIosRunnerCommand(
|
|
176
|
-
device,
|
|
177
|
-
{ command: 'type', text, clearFirst: true, appBundleId: context?.appBundleId },
|
|
178
|
-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
|
|
179
|
-
);
|
|
180
|
-
} else {
|
|
181
|
-
await interactor.fill(x, y, text);
|
|
182
|
-
}
|
|
155
|
+
await interactor.fill(x, y, text);
|
|
183
156
|
return { x, y, text };
|
|
184
157
|
}
|
|
185
158
|
case 'scroll': {
|
|
186
159
|
const direction = positionals[0];
|
|
187
160
|
const amount = positionals[1] ? Number(positionals[1]) : undefined;
|
|
188
161
|
if (!direction) throw new AppError('INVALID_ARGS', 'scroll requires direction');
|
|
189
|
-
|
|
190
|
-
if (!['up', 'down', 'left', 'right'].includes(direction)) {
|
|
191
|
-
throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`);
|
|
192
|
-
}
|
|
193
|
-
const inverted = invertScrollDirection(direction as 'up' | 'down' | 'left' | 'right');
|
|
194
|
-
await runIosRunnerCommand(
|
|
195
|
-
device,
|
|
196
|
-
{ command: 'swipe', direction: inverted, appBundleId: context?.appBundleId },
|
|
197
|
-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
|
|
198
|
-
);
|
|
199
|
-
} else {
|
|
200
|
-
await interactor.scroll(direction, amount);
|
|
201
|
-
}
|
|
162
|
+
await interactor.scroll(direction, amount);
|
|
202
163
|
return { direction, amount };
|
|
203
164
|
}
|
|
204
165
|
case 'scrollintoview': {
|
|
205
166
|
const text = positionals.join(' ').trim();
|
|
206
167
|
if (!text) throw new AppError('INVALID_ARGS', 'scrollintoview requires text');
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
210
|
-
const found = (await runIosRunnerCommand(
|
|
211
|
-
device,
|
|
212
|
-
{ command: 'findText', text, appBundleId: context?.appBundleId },
|
|
213
|
-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
|
|
214
|
-
)) as { found?: boolean };
|
|
215
|
-
if (found?.found) return { text, attempts: attempt + 1 };
|
|
216
|
-
await runIosRunnerCommand(
|
|
217
|
-
device,
|
|
218
|
-
{ command: 'swipe', direction: 'up', appBundleId: context?.appBundleId },
|
|
219
|
-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
|
|
220
|
-
);
|
|
221
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
222
|
-
}
|
|
223
|
-
throw new AppError('COMMAND_FAILED', `scrollintoview could not find text: ${text}`);
|
|
224
|
-
}
|
|
225
|
-
await interactor.scrollIntoView(text);
|
|
168
|
+
const result = await interactor.scrollIntoView(text);
|
|
169
|
+
if (result?.attempts) return { text, attempts: result.attempts };
|
|
226
170
|
return { text };
|
|
227
171
|
}
|
|
228
172
|
case 'pinch': {
|
|
@@ -232,27 +176,22 @@ export async function dispatchCommand(
|
|
|
232
176
|
if (Number.isNaN(scale) || scale <= 0) {
|
|
233
177
|
throw new AppError('INVALID_ARGS', 'pinch requires scale > 0');
|
|
234
178
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
);
|
|
241
|
-
} else {
|
|
242
|
-
throw new AppError('UNSUPPORTED_OPERATION', 'pinch is only supported on iOS simulators');
|
|
243
|
-
}
|
|
179
|
+
await runIosRunnerCommand(
|
|
180
|
+
device,
|
|
181
|
+
{ command: 'pinch', scale, x, y, appBundleId: context?.appBundleId },
|
|
182
|
+
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
|
|
183
|
+
);
|
|
244
184
|
return { scale, x, y };
|
|
245
185
|
}
|
|
246
186
|
case 'screenshot': {
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
187
|
+
const positionalPath = positionals[0];
|
|
188
|
+
const screenshotPath = positionalPath ?? outPath ?? `./screenshot-${Date.now()}.png`;
|
|
189
|
+
await fs.mkdir(pathModule.dirname(screenshotPath), { recursive: true });
|
|
190
|
+
await interactor.screenshot(screenshotPath);
|
|
191
|
+
return { path: screenshotPath };
|
|
250
192
|
}
|
|
251
193
|
case 'back': {
|
|
252
194
|
if (device.platform === 'ios') {
|
|
253
|
-
if (device.kind !== 'simulator') {
|
|
254
|
-
throw new AppError('UNSUPPORTED_OPERATION', 'back is only supported on iOS simulators in v1');
|
|
255
|
-
}
|
|
256
195
|
await runIosRunnerCommand(
|
|
257
196
|
device,
|
|
258
197
|
{ command: 'back', appBundleId: context?.appBundleId },
|
|
@@ -265,9 +204,6 @@ export async function dispatchCommand(
|
|
|
265
204
|
}
|
|
266
205
|
case 'home': {
|
|
267
206
|
if (device.platform === 'ios') {
|
|
268
|
-
if (device.kind !== 'simulator') {
|
|
269
|
-
throw new AppError('UNSUPPORTED_OPERATION', 'home is only supported on iOS simulators in v1');
|
|
270
|
-
}
|
|
271
207
|
await runIosRunnerCommand(
|
|
272
208
|
device,
|
|
273
209
|
{ command: 'home', appBundleId: context?.appBundleId },
|
|
@@ -280,9 +216,6 @@ export async function dispatchCommand(
|
|
|
280
216
|
}
|
|
281
217
|
case 'app-switcher': {
|
|
282
218
|
if (device.platform === 'ios') {
|
|
283
|
-
if (device.kind !== 'simulator') {
|
|
284
|
-
throw new AppError('UNSUPPORTED_OPERATION', 'app-switcher is only supported on iOS simulators in v1');
|
|
285
|
-
}
|
|
286
219
|
await runIosRunnerCommand(
|
|
287
220
|
device,
|
|
288
221
|
{ command: 'appSwitcher', appBundleId: context?.appBundleId },
|
|
@@ -305,12 +238,6 @@ export async function dispatchCommand(
|
|
|
305
238
|
case 'snapshot': {
|
|
306
239
|
const backend = context?.snapshotBackend ?? 'xctest';
|
|
307
240
|
if (device.platform === 'ios') {
|
|
308
|
-
if (device.kind !== 'simulator') {
|
|
309
|
-
throw new AppError(
|
|
310
|
-
'UNSUPPORTED_OPERATION',
|
|
311
|
-
'snapshot is only supported on iOS simulators in v1',
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
241
|
if (backend === 'ax') {
|
|
315
242
|
const ax = await snapshotAx(device, { traceLogPath: context?.traceLogPath });
|
|
316
243
|
return { nodes: ax.nodes ?? [], truncated: false, backend: 'ax' };
|
|
@@ -352,18 +279,3 @@ export async function dispatchCommand(
|
|
|
352
279
|
throw new AppError('INVALID_ARGS', `Unknown command: ${command}`);
|
|
353
280
|
}
|
|
354
281
|
}
|
|
355
|
-
|
|
356
|
-
function invertScrollDirection(direction: 'up' | 'down' | 'left' | 'right'): 'up' | 'down' | 'left' | 'right' {
|
|
357
|
-
switch (direction) {
|
|
358
|
-
case 'up':
|
|
359
|
-
return 'down';
|
|
360
|
-
case 'down':
|
|
361
|
-
return 'up';
|
|
362
|
-
case 'left':
|
|
363
|
-
return 'right';
|
|
364
|
-
case 'right':
|
|
365
|
-
return 'left';
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Runner-only input on iOS simulators (simctl io input is not supported).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { evaluateIsPredicate, isSupportedPredicate } from '../is-predicates.ts';
|
|
4
|
+
|
|
5
|
+
const baseNode = {
|
|
6
|
+
ref: 'e1',
|
|
7
|
+
index: 0,
|
|
8
|
+
type: 'XCUIElementTypeTextField',
|
|
9
|
+
label: 'Email',
|
|
10
|
+
value: '',
|
|
11
|
+
identifier: 'login_email',
|
|
12
|
+
rect: { x: 0, y: 0, width: 100, height: 40 },
|
|
13
|
+
enabled: true,
|
|
14
|
+
hittable: true,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
test('isSupportedPredicate validates supported predicates', () => {
|
|
18
|
+
assert.equal(isSupportedPredicate('visible'), true);
|
|
19
|
+
assert.equal(isSupportedPredicate('text'), true);
|
|
20
|
+
assert.equal(isSupportedPredicate('checked'), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('evaluateIsPredicate visible and hidden', () => {
|
|
24
|
+
const visible = evaluateIsPredicate({
|
|
25
|
+
predicate: 'visible',
|
|
26
|
+
node: baseNode,
|
|
27
|
+
platform: 'ios',
|
|
28
|
+
});
|
|
29
|
+
const hidden = evaluateIsPredicate({
|
|
30
|
+
predicate: 'hidden',
|
|
31
|
+
node: { ...baseNode, rect: { ...baseNode.rect, width: 0 }, hittable: false },
|
|
32
|
+
platform: 'ios',
|
|
33
|
+
});
|
|
34
|
+
assert.equal(visible.pass, true);
|
|
35
|
+
assert.equal(hidden.pass, true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('evaluateIsPredicate editable and selected', () => {
|
|
39
|
+
const editable = evaluateIsPredicate({
|
|
40
|
+
predicate: 'editable',
|
|
41
|
+
node: baseNode,
|
|
42
|
+
platform: 'ios',
|
|
43
|
+
});
|
|
44
|
+
const selected = evaluateIsPredicate({
|
|
45
|
+
predicate: 'selected',
|
|
46
|
+
node: { ...baseNode, selected: true },
|
|
47
|
+
platform: 'ios',
|
|
48
|
+
});
|
|
49
|
+
assert.equal(editable.pass, true);
|
|
50
|
+
assert.equal(selected.pass, true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('evaluateIsPredicate text uses equality', () => {
|
|
54
|
+
const match = evaluateIsPredicate({
|
|
55
|
+
predicate: 'text',
|
|
56
|
+
node: baseNode,
|
|
57
|
+
expectedText: 'Email',
|
|
58
|
+
platform: 'ios',
|
|
59
|
+
});
|
|
60
|
+
const mismatch = evaluateIsPredicate({
|
|
61
|
+
predicate: 'text',
|
|
62
|
+
node: baseNode,
|
|
63
|
+
expectedText: 'email',
|
|
64
|
+
platform: 'ios',
|
|
65
|
+
});
|
|
66
|
+
assert.equal(match.pass, true);
|
|
67
|
+
assert.equal(mismatch.pass, false);
|
|
68
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import type { SnapshotState } from '../../utils/snapshot.ts';
|
|
4
|
+
import {
|
|
5
|
+
buildSelectorChainForNode,
|
|
6
|
+
findSelectorChainMatch,
|
|
7
|
+
isSelectorToken,
|
|
8
|
+
parseSelectorChain,
|
|
9
|
+
resolveSelectorChain,
|
|
10
|
+
splitSelectorFromArgs,
|
|
11
|
+
} from '../selectors.ts';
|
|
12
|
+
|
|
13
|
+
const nodes: SnapshotState['nodes'] = [
|
|
14
|
+
{
|
|
15
|
+
ref: 'e1',
|
|
16
|
+
index: 0,
|
|
17
|
+
type: 'XCUIElementTypeTextField',
|
|
18
|
+
label: 'Email',
|
|
19
|
+
value: '',
|
|
20
|
+
identifier: 'login_email',
|
|
21
|
+
rect: { x: 0, y: 0, width: 200, height: 44 },
|
|
22
|
+
enabled: true,
|
|
23
|
+
hittable: true,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
ref: 'e2',
|
|
27
|
+
index: 1,
|
|
28
|
+
type: 'XCUIElementTypeButton',
|
|
29
|
+
label: 'Continue',
|
|
30
|
+
identifier: 'auth_continue',
|
|
31
|
+
rect: { x: 0, y: 80, width: 200, height: 44 },
|
|
32
|
+
enabled: true,
|
|
33
|
+
hittable: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
ref: 'e3',
|
|
37
|
+
index: 2,
|
|
38
|
+
type: 'XCUIElementTypeButton',
|
|
39
|
+
label: 'Continue',
|
|
40
|
+
identifier: 'secondary_continue',
|
|
41
|
+
rect: { x: 0, y: 140, width: 200, height: 44 },
|
|
42
|
+
enabled: true,
|
|
43
|
+
hittable: true,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
test('parseSelectorChain parses fallback and boolean terms', () => {
|
|
48
|
+
const chain = parseSelectorChain('id=auth_continue || role=button label="Continue" visible=true');
|
|
49
|
+
assert.equal(chain.selectors.length, 2);
|
|
50
|
+
assert.equal(chain.selectors[0].terms[0].key, 'id');
|
|
51
|
+
assert.equal(chain.selectors[1].terms[2].key, 'visible');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('resolveSelectorChain resolves unique match', () => {
|
|
55
|
+
const chain = parseSelectorChain('id=login_email');
|
|
56
|
+
const resolved = resolveSelectorChain(nodes, chain, {
|
|
57
|
+
platform: 'ios',
|
|
58
|
+
requireRect: true,
|
|
59
|
+
requireUnique: true,
|
|
60
|
+
});
|
|
61
|
+
assert.ok(resolved);
|
|
62
|
+
assert.equal(resolved.node.ref, 'e1');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('resolveSelectorChain falls back when first selector is ambiguous', () => {
|
|
66
|
+
const chain = parseSelectorChain('label="Continue" || id=auth_continue');
|
|
67
|
+
const resolved = resolveSelectorChain(nodes, chain, {
|
|
68
|
+
platform: 'ios',
|
|
69
|
+
requireRect: true,
|
|
70
|
+
requireUnique: true,
|
|
71
|
+
});
|
|
72
|
+
assert.ok(resolved);
|
|
73
|
+
assert.equal(resolved.selectorIndex, 1);
|
|
74
|
+
assert.equal(resolved.node.ref, 'e2');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('findSelectorChainMatch returns first matching selector for existence checks', () => {
|
|
78
|
+
const chain = parseSelectorChain('label="Continue" || id=auth_continue');
|
|
79
|
+
const match = findSelectorChainMatch(nodes, chain, {
|
|
80
|
+
platform: 'ios',
|
|
81
|
+
});
|
|
82
|
+
assert.ok(match);
|
|
83
|
+
assert.equal(match.selectorIndex, 0);
|
|
84
|
+
assert.equal(match.matches, 2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('splitSelectorFromArgs extracts selector prefix and trailing value', () => {
|
|
88
|
+
const split = splitSelectorFromArgs(['id=login_email', 'editable=true', 'qa@example.com']);
|
|
89
|
+
assert.ok(split);
|
|
90
|
+
assert.equal(split.selectorExpression, 'id=login_email editable=true');
|
|
91
|
+
assert.deepEqual(split.rest, ['qa@example.com']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('parseSelectorChain rejects unknown keys and malformed quotes', () => {
|
|
95
|
+
assert.throws(() => parseSelectorChain('foo=bar'), /Unknown selector key/i);
|
|
96
|
+
assert.throws(() => parseSelectorChain('label="unclosed'), /Unclosed quote/i);
|
|
97
|
+
assert.throws(() => parseSelectorChain(''), /cannot be empty/i);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('isSelectorToken only accepts known keys for key=value tokens', () => {
|
|
101
|
+
assert.equal(isSelectorToken('id=foo'), true);
|
|
102
|
+
assert.equal(isSelectorToken('editable=true'), true);
|
|
103
|
+
assert.equal(isSelectorToken('foo=bar'), false);
|
|
104
|
+
assert.equal(isSelectorToken('a=b'), false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('text selector matches extractNodeText semantics (first non-empty field)', () => {
|
|
108
|
+
const chainByLabel = parseSelectorChain('text=Email');
|
|
109
|
+
const chainById = parseSelectorChain('text=login_email');
|
|
110
|
+
const resolvedLabel = resolveSelectorChain(nodes, chainByLabel, {
|
|
111
|
+
platform: 'ios',
|
|
112
|
+
requireUnique: true,
|
|
113
|
+
});
|
|
114
|
+
const resolvedId = resolveSelectorChain(nodes, chainById, {
|
|
115
|
+
platform: 'ios',
|
|
116
|
+
requireUnique: true,
|
|
117
|
+
});
|
|
118
|
+
assert.ok(resolvedLabel);
|
|
119
|
+
assert.equal(resolvedLabel.node.ref, 'e1');
|
|
120
|
+
assert.equal(resolvedId, null);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('buildSelectorChainForNode prefers id and adds editable for fill action', () => {
|
|
124
|
+
const target = nodes[0];
|
|
125
|
+
const chain = buildSelectorChainForNode(target, 'ios', { action: 'fill' });
|
|
126
|
+
assert.ok(chain.some((entry) => entry.includes('id=')));
|
|
127
|
+
assert.ok(chain.some((entry) => entry.includes('editable=true')));
|
|
128
|
+
});
|