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.
Files changed (46) hide show
  1. package/README.md +41 -4
  2. package/dist/src/bin.js +15 -11
  3. package/dist/src/daemon.js +9 -8
  4. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +2 -0
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +15 -0
  6. package/package.json +3 -2
  7. package/skills/agent-device/SKILL.md +23 -7
  8. package/skills/agent-device/references/session-management.md +9 -0
  9. package/skills/agent-device/references/snapshot-refs.md +18 -5
  10. package/skills/agent-device/references/video-recording.md +2 -2
  11. package/src/cli.ts +6 -0
  12. package/src/core/__tests__/capabilities.test.ts +67 -0
  13. package/src/core/capabilities.ts +49 -0
  14. package/src/core/dispatch.ts +30 -118
  15. package/src/daemon/__tests__/is-predicates.test.ts +68 -0
  16. package/src/daemon/__tests__/selectors.test.ts +128 -0
  17. package/src/daemon/__tests__/session-routing.test.ts +108 -0
  18. package/src/daemon/__tests__/session-selector.test.ts +64 -0
  19. package/src/daemon/__tests__/session-store.test.ts +95 -0
  20. package/src/daemon/__tests__/snapshot-processing.test.ts +47 -0
  21. package/src/daemon/action-utils.ts +29 -0
  22. package/src/daemon/app-state.ts +66 -0
  23. package/src/daemon/context.ts +36 -0
  24. package/src/daemon/device-ready.ts +13 -0
  25. package/src/daemon/handlers/__tests__/find.test.ts +99 -0
  26. package/src/daemon/handlers/__tests__/replay-heal.test.ts +364 -0
  27. package/src/daemon/handlers/__tests__/snapshot.test.ts +128 -0
  28. package/src/daemon/handlers/find.ts +304 -0
  29. package/src/daemon/handlers/interaction.ts +510 -0
  30. package/src/daemon/handlers/parse-utils.ts +8 -0
  31. package/src/daemon/handlers/record-trace.ts +154 -0
  32. package/src/daemon/handlers/session.ts +732 -0
  33. package/src/daemon/handlers/snapshot.ts +396 -0
  34. package/src/daemon/is-predicates.ts +46 -0
  35. package/src/daemon/selectors.ts +423 -0
  36. package/src/daemon/session-routing.ts +22 -0
  37. package/src/daemon/session-selector.ts +39 -0
  38. package/src/daemon/session-store.ts +275 -0
  39. package/src/daemon/snapshot-processing.ts +127 -0
  40. package/src/daemon/types.ts +55 -0
  41. package/src/daemon.ts +66 -1564
  42. package/src/platforms/ios/index.ts +0 -62
  43. package/src/platforms/ios/runner-client.ts +2 -0
  44. package/src/utils/args.ts +20 -12
  45. package/src/utils/interactors.ts +102 -16
  46. package/src/utils/snapshot.ts +1 -0
@@ -1,8 +1,8 @@
1
- # Snapshot + Refs Workflow (Mobile)
1
+ # Snapshot Refs and Selectors (Mobile)
2
2
 
3
3
  ## Purpose
4
4
 
5
- Refs let agents interact without repeating full UI trees. Snapshot -> refs -> click/fill.
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
- Always re-snapshot after any transition.
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
+ }
@@ -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 interactor = getInteractor(device);
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
- if (device.platform === 'ios' && device.kind === 'simulator') {
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
- if (device.platform === 'ios' && device.kind === 'simulator') {
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
- if (device.platform === 'ios' && device.kind === 'simulator') {
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
- if (device.platform === 'ios' && device.kind === 'simulator') {
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
- if (device.platform === 'ios' && device.kind === 'simulator') {
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
- if (device.platform === 'ios' && device.kind === 'simulator') {
208
- const maxAttempts = 8;
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
- if (device.platform === 'ios' && device.kind === 'simulator') {
236
- await runIosRunnerCommand(
237
- device,
238
- { command: 'pinch', scale, x, y, appBundleId: context?.appBundleId },
239
- { verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
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 path = outPath ?? `./screenshot-${Date.now()}.png`;
248
- await interactor.screenshot(path);
249
- return { path };
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
+ });