agent-device 0.3.2 → 0.3.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Callstack",
@@ -74,6 +74,97 @@ test('resolveSelectorChain falls back when first selector is ambiguous', () => {
74
74
  assert.equal(resolved.node.ref, 'e2');
75
75
  });
76
76
 
77
+ test('resolveSelectorChain keeps strict ambiguity behavior by default', () => {
78
+ const chain = parseSelectorChain('label="Continue"');
79
+ const resolved = resolveSelectorChain(nodes, chain, {
80
+ platform: 'ios',
81
+ requireRect: true,
82
+ requireUnique: true,
83
+ });
84
+ assert.equal(resolved, null);
85
+ });
86
+
87
+ test('resolveSelectorChain disambiguates to deeper/smaller matching node when enabled', () => {
88
+ const disambiguationNodes: SnapshotState['nodes'] = [
89
+ {
90
+ ref: 'e1',
91
+ index: 0,
92
+ type: 'Other',
93
+ label: 'Press me',
94
+ rect: { x: 0, y: 0, width: 300, height: 300 },
95
+ depth: 1,
96
+ enabled: true,
97
+ hittable: true,
98
+ },
99
+ {
100
+ ref: 'e2',
101
+ index: 1,
102
+ type: 'Other',
103
+ label: 'Press me',
104
+ rect: { x: 10, y: 10, width: 100, height: 20 },
105
+ depth: 2,
106
+ enabled: true,
107
+ hittable: true,
108
+ },
109
+ ];
110
+ const chain = parseSelectorChain('role="other" label="Press me" || label="Press me"');
111
+ const resolved = resolveSelectorChain(disambiguationNodes, chain, {
112
+ platform: 'ios',
113
+ requireRect: true,
114
+ requireUnique: true,
115
+ disambiguateAmbiguous: true,
116
+ });
117
+ assert.ok(resolved);
118
+ assert.equal(resolved.node.ref, 'e2');
119
+ assert.equal(resolved.matches, 2);
120
+ });
121
+
122
+ test('resolveSelectorChain disambiguation tie falls back to next selector', () => {
123
+ const tieNodes: SnapshotState['nodes'] = [
124
+ {
125
+ ref: 'e1',
126
+ index: 0,
127
+ type: 'Other',
128
+ label: 'Press me',
129
+ rect: { x: 0, y: 0, width: 100, height: 20 },
130
+ depth: 2,
131
+ enabled: true,
132
+ hittable: true,
133
+ },
134
+ {
135
+ ref: 'e2',
136
+ index: 1,
137
+ type: 'Other',
138
+ label: 'Press me',
139
+ rect: { x: 0, y: 40, width: 100, height: 20 },
140
+ depth: 2,
141
+ enabled: true,
142
+ hittable: true,
143
+ },
144
+ {
145
+ ref: 'e3',
146
+ index: 2,
147
+ type: 'Other',
148
+ label: 'Press me',
149
+ identifier: 'press_me_unique',
150
+ rect: { x: 0, y: 80, width: 100, height: 20 },
151
+ depth: 2,
152
+ enabled: true,
153
+ hittable: true,
154
+ },
155
+ ];
156
+ const chain = parseSelectorChain('label="Press me" || id="press_me_unique"');
157
+ const resolved = resolveSelectorChain(tieNodes, chain, {
158
+ platform: 'ios',
159
+ requireRect: true,
160
+ requireUnique: true,
161
+ disambiguateAmbiguous: true,
162
+ });
163
+ assert.ok(resolved);
164
+ assert.equal(resolved.selectorIndex, 1);
165
+ assert.equal(resolved.node.ref, 'e3');
166
+ });
167
+
77
168
  test('findSelectorChainMatch returns first matching selector for existence checks', () => {
78
169
  const chain = parseSelectorChain('label="Continue" || id=auth_continue');
79
170
  const match = findSelectorChainMatch(nodes, chain, {
@@ -91,12 +182,31 @@ test('splitSelectorFromArgs extracts selector prefix and trailing value', () =>
91
182
  assert.deepEqual(split.rest, ['qa@example.com']);
92
183
  });
93
184
 
185
+ test('splitSelectorFromArgs prefers trailing token for value when requested', () => {
186
+ const split = splitSelectorFromArgs(['label="Filter"', 'visible=true'], { preferTrailingValue: true });
187
+ assert.ok(split);
188
+ assert.equal(split.selectorExpression, 'label="Filter"');
189
+ assert.deepEqual(split.rest, ['visible=true']);
190
+ });
191
+
192
+ test('splitSelectorFromArgs keeps full selector when trailing value preference is disabled', () => {
193
+ const split = splitSelectorFromArgs(['label="Filter"', 'visible=true']);
194
+ assert.ok(split);
195
+ assert.equal(split.selectorExpression, 'label="Filter" visible=true');
196
+ assert.deepEqual(split.rest, []);
197
+ });
198
+
94
199
  test('parseSelectorChain rejects unknown keys and malformed quotes', () => {
95
200
  assert.throws(() => parseSelectorChain('foo=bar'), /Unknown selector key/i);
96
201
  assert.throws(() => parseSelectorChain('label="unclosed'), /Unclosed quote/i);
97
202
  assert.throws(() => parseSelectorChain(''), /cannot be empty/i);
98
203
  });
99
204
 
205
+ test('parseSelectorChain handles quoted values ending in escaped backslashes', () => {
206
+ const chain = parseSelectorChain('label="path\\\\" || id=auth_continue');
207
+ assert.equal(chain.selectors.length, 2);
208
+ });
209
+
100
210
  test('isSelectorToken only accepts known keys for key=value tokens', () => {
101
211
  assert.equal(isSelectorToken('id=foo'), true);
102
212
  assert.equal(isSelectorToken('editable=true'), true);
@@ -126,3 +236,26 @@ test('buildSelectorChainForNode prefers id and adds editable for fill action', (
126
236
  assert.ok(chain.some((entry) => entry.includes('id=')));
127
237
  assert.ok(chain.some((entry) => entry.includes('editable=true')));
128
238
  });
239
+
240
+ test('role selector normalization matches Android class names by leaf type', () => {
241
+ const androidNodes: SnapshotState['nodes'] = [
242
+ {
243
+ ref: 'a1',
244
+ index: 0,
245
+ type: 'android.widget.Button',
246
+ label: 'Continue',
247
+ identifier: 'auth_continue',
248
+ rect: { x: 0, y: 0, width: 120, height: 44 },
249
+ enabled: true,
250
+ hittable: true,
251
+ },
252
+ ];
253
+ const chain = parseSelectorChain('role=button label="Continue"');
254
+ const resolved = resolveSelectorChain(androidNodes, chain, {
255
+ platform: 'android',
256
+ requireRect: true,
257
+ requireUnique: true,
258
+ });
259
+ assert.ok(resolved);
260
+ assert.equal(resolved.node.ref, 'a1');
261
+ });
@@ -234,6 +234,65 @@ test('replay without --update does not heal or rewrite', async () => {
234
234
  assert.equal(fs.readFileSync(replayPath, 'utf8'), originalPayload);
235
235
  });
236
236
 
237
+ test('replay --update skips malformed selector candidates and preserves replay error context', async () => {
238
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-malformed-candidate-'));
239
+ const sessionsDir = path.join(tempRoot, 'sessions');
240
+ const replayPath = path.join(tempRoot, 'replay.ad');
241
+ const sessionStore = new SessionStore(sessionsDir);
242
+ const sessionName = 'malformed-candidate-session';
243
+ sessionStore.set(sessionName, makeSession(sessionName));
244
+
245
+ writeReplayFile(replayPath, {
246
+ ts: Date.now(),
247
+ command: 'click',
248
+ positionals: ['id="old_continue" ||'],
249
+ flags: {},
250
+ result: {},
251
+ });
252
+
253
+ const dispatch = async (): Promise<Record<string, unknown> | void> => {
254
+ return {
255
+ nodes: [
256
+ {
257
+ index: 0,
258
+ type: 'XCUIElementTypeButton',
259
+ label: 'Continue',
260
+ identifier: 'auth_continue',
261
+ rect: { x: 10, y: 10, width: 100, height: 44 },
262
+ enabled: true,
263
+ hittable: true,
264
+ },
265
+ ],
266
+ truncated: false,
267
+ backend: 'xctest',
268
+ };
269
+ };
270
+
271
+ const response = await handleSessionCommands({
272
+ req: {
273
+ token: 't',
274
+ session: sessionName,
275
+ command: 'replay',
276
+ positionals: [replayPath],
277
+ flags: { replayUpdate: true },
278
+ },
279
+ sessionName,
280
+ logPath: path.join(tempRoot, 'daemon.log'),
281
+ sessionStore,
282
+ invoke: async () => ({ ok: false, error: { code: 'COMMAND_FAILED', message: 'selector stale' } }),
283
+ dispatch,
284
+ });
285
+
286
+ assert.ok(response);
287
+ assert.equal(response.ok, false);
288
+ if (!response.ok) {
289
+ assert.equal(response.error.code, 'COMMAND_FAILED');
290
+ assert.match(response.error.message, /Replay failed at step 1/);
291
+ assert.equal(response.error.details?.step, 1);
292
+ assert.equal(response.error.details?.action, 'click');
293
+ }
294
+ });
295
+
237
296
  test('replay --update heals selector in is command', async () => {
238
297
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-is-'));
239
298
  const sessionsDir = path.join(tempRoot, 'sessions');
@@ -306,6 +365,87 @@ test('replay --update heals selector in is command', async () => {
306
365
  assert.ok(rewrittenSelector.includes('auth_continue'));
307
366
  });
308
367
 
368
+ test('replay --update heals numeric get text drift when numeric candidate value is unique', async () => {
369
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-get-numeric-'));
370
+ const sessionsDir = path.join(tempRoot, 'sessions');
371
+ const replayPath = path.join(tempRoot, 'replay.ad');
372
+ const sessionStore = new SessionStore(sessionsDir);
373
+ const sessionName = 'heal-get-numeric-session';
374
+ sessionStore.set(sessionName, makeSession(sessionName));
375
+
376
+ writeReplayFile(replayPath, {
377
+ ts: Date.now(),
378
+ command: 'get',
379
+ positionals: ['text', 'role="statictext" label="2" || label="2"'],
380
+ flags: {},
381
+ result: {},
382
+ });
383
+
384
+ const invokeCalls: string[] = [];
385
+ const invoke = async (request: DaemonRequest): Promise<DaemonResponse> => {
386
+ if (request.command !== 'get') {
387
+ return { ok: false, error: { code: 'INVALID_ARGS', message: `unexpected command ${request.command}` } };
388
+ }
389
+ const selector = request.positionals?.[1] ?? '';
390
+ invokeCalls.push(selector);
391
+ if (selector.includes('label="2"')) {
392
+ return { ok: false, error: { code: 'COMMAND_FAILED', message: 'selector stale' } };
393
+ }
394
+ if (selector.includes('label="20"')) {
395
+ return { ok: true, data: { text: '20' } };
396
+ }
397
+ return { ok: false, error: { code: 'COMMAND_FAILED', message: 'unexpected selector' } };
398
+ };
399
+
400
+ const dispatch = async (): Promise<Record<string, unknown> | void> => {
401
+ return {
402
+ nodes: [
403
+ {
404
+ index: 0,
405
+ type: 'XCUIElementTypeStaticText',
406
+ label: '20',
407
+ rect: { x: 0, y: 100, width: 100, height: 24 },
408
+ enabled: true,
409
+ hittable: true,
410
+ },
411
+ {
412
+ index: 1,
413
+ type: 'XCUIElementTypeStaticText',
414
+ label: 'Version: 0.84.0',
415
+ rect: { x: 0, y: 200, width: 220, height: 17 },
416
+ enabled: true,
417
+ hittable: true,
418
+ },
419
+ ],
420
+ truncated: false,
421
+ backend: 'xctest',
422
+ };
423
+ };
424
+
425
+ const response = await handleSessionCommands({
426
+ req: {
427
+ token: 't',
428
+ session: sessionName,
429
+ command: 'replay',
430
+ positionals: [replayPath],
431
+ flags: { replayUpdate: true },
432
+ },
433
+ sessionName,
434
+ logPath: path.join(tempRoot, 'daemon.log'),
435
+ sessionStore,
436
+ invoke,
437
+ dispatch,
438
+ });
439
+
440
+ assert.ok(response);
441
+ assert.equal(response.ok, true, JSON.stringify(response));
442
+ if (response.ok) {
443
+ assert.equal(response.data?.healed, 1);
444
+ assert.equal(response.data?.replayed, 1);
445
+ }
446
+ assert.equal(invokeCalls.length, 2);
447
+ });
448
+
309
449
  test('replay rejects legacy JSON payload files', async () => {
310
450
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-json-rejected-'));
311
451
  const sessionsDir = path.join(tempRoot, 'sessions');
@@ -12,6 +12,7 @@ import {
12
12
  formatSelectorFailure,
13
13
  parseSelectorChain,
14
14
  resolveSelectorChain,
15
+ splitIsSelectorArgs,
15
16
  splitSelectorFromArgs,
16
17
  } from '../selectors.ts';
17
18
 
@@ -90,6 +91,7 @@ export async function handleInteractionCommands(params: {
90
91
  platform: session.device.platform,
91
92
  requireRect: true,
92
93
  requireUnique: true,
94
+ disambiguateAmbiguous: true,
93
95
  });
94
96
  if (!resolved || !resolved.node.rect) {
95
97
  return {
@@ -180,7 +182,7 @@ export async function handleInteractionCommands(params: {
180
182
  error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
181
183
  };
182
184
  }
183
- const selectorArgs = splitSelectorFromArgs(req.positionals ?? []);
185
+ const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], { preferTrailingValue: true });
184
186
  if (selectorArgs) {
185
187
  if (selectorArgs.rest.length === 0) {
186
188
  return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' } };
@@ -197,6 +199,7 @@ export async function handleInteractionCommands(params: {
197
199
  platform: session.device.platform,
198
200
  requireRect: true,
199
201
  requireUnique: true,
202
+ disambiguateAmbiguous: true,
200
203
  });
201
204
  if (!resolved || !resolved.node.rect) {
202
205
  return {
@@ -307,6 +310,7 @@ export async function handleInteractionCommands(params: {
307
310
  platform: session.device.platform,
308
311
  requireRect: false,
309
312
  requireUnique: true,
313
+ disambiguateAmbiguous: sub === 'text',
310
314
  });
311
315
  if (!resolved) {
312
316
  return {
@@ -367,8 +371,7 @@ export async function handleInteractionCommands(params: {
367
371
  error: { code: 'UNSUPPORTED_OPERATION', message: 'is is not supported on this device' },
368
372
  };
369
373
  }
370
- const selectorArgs = req.positionals.slice(1);
371
- const split = splitSelectorFromArgs(selectorArgs);
374
+ const { split } = splitIsSelectorArgs(req.positionals);
372
375
  if (!split) {
373
376
  return {
374
377
  ok: false,
@@ -10,8 +10,14 @@ import { ensureDeviceReady } from '../device-ready.ts';
10
10
  import { resolveIosAppStateFromSnapshots } from '../app-state.ts';
11
11
  import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
12
12
  import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
13
- import { pruneGroupNodes } from '../snapshot-processing.ts';
14
- import { buildSelectorChainForNode, parseSelectorChain, resolveSelectorChain, splitSelectorFromArgs } from '../selectors.ts';
13
+ import { extractNodeText, normalizeType, pruneGroupNodes } from '../snapshot-processing.ts';
14
+ import {
15
+ buildSelectorChainForNode,
16
+ resolveSelectorChain,
17
+ splitIsSelectorArgs,
18
+ splitSelectorFromArgs,
19
+ tryParseSelectorChain,
20
+ } from '../selectors.ts';
15
21
  import { inferFillText, uniqueStrings } from '../action-utils.ts';
16
22
 
17
23
  type ReinstallOps = {
@@ -511,14 +517,20 @@ async function healReplayAction(params: {
511
517
  const session = sessionStore.get(sessionName);
512
518
  if (!session) return null;
513
519
  const requiresRect = action.command === 'click' || action.command === 'fill';
520
+ const allowDisambiguation =
521
+ action.command === 'click' ||
522
+ action.command === 'fill' ||
523
+ (action.command === 'get' && action.positionals?.[0] === 'text');
514
524
  const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
515
525
  const selectorCandidates = collectReplaySelectorCandidates(action);
516
526
  for (const candidate of selectorCandidates) {
517
- const chain = parseSelectorChain(candidate);
527
+ const chain = tryParseSelectorChain(candidate);
528
+ if (!chain) continue;
518
529
  const resolved = resolveSelectorChain(snapshot.nodes, chain, {
519
530
  platform: session.device.platform,
520
531
  requireRect: requiresRect,
521
532
  requireUnique: true,
533
+ disambiguateAmbiguous: allowDisambiguation,
522
534
  });
523
535
  if (!resolved) continue;
524
536
  const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
@@ -548,9 +560,8 @@ async function healReplayAction(params: {
548
560
  };
549
561
  }
550
562
  if (action.command === 'is') {
551
- const predicate = action.positionals?.[0];
563
+ const { predicate, split } = splitIsSelectorArgs(action.positionals);
552
564
  if (!predicate) continue;
553
- const split = splitSelectorFromArgs(action.positionals.slice(1));
554
565
  const expectedText = split?.rest.join(' ').trim() ?? '';
555
566
  const nextPositionals = [predicate, selectorExpression];
556
567
  if (predicate === 'text' && expectedText.length > 0) {
@@ -573,6 +584,10 @@ async function healReplayAction(params: {
573
584
  };
574
585
  }
575
586
  }
587
+ const numericDriftHeal = healNumericGetTextDrift(action, snapshot, session);
588
+ if (numericDriftHeal) {
589
+ return numericDriftHeal;
590
+ }
576
591
  return null;
577
592
  }
578
593
 
@@ -641,7 +656,7 @@ function collectReplaySelectorCandidates(action: SessionAction): string[] {
641
656
  }
642
657
  }
643
658
  if (action.command === 'is') {
644
- const split = splitSelectorFromArgs(action.positionals.slice(1));
659
+ const { split } = splitIsSelectorArgs(action.positionals);
645
660
  if (split) {
646
661
  result.push(split.selectorExpression);
647
662
  }
@@ -690,6 +705,56 @@ function parseSelectorWaitPositionals(positionals: string[]): {
690
705
  };
691
706
  }
692
707
 
708
+ function healNumericGetTextDrift(
709
+ action: SessionAction,
710
+ snapshot: SnapshotState,
711
+ session: SessionState,
712
+ ): SessionAction | null {
713
+ if (action.command !== 'get') return null;
714
+ if (action.positionals?.[0] !== 'text') return null;
715
+ const selectorExpression = action.positionals?.[1];
716
+ if (!selectorExpression) return null;
717
+ const chain = tryParseSelectorChain(selectorExpression);
718
+ if (!chain) return null;
719
+
720
+ const roleFilters = new Set<string>();
721
+ let hasNumericTerm = false;
722
+ for (const selector of chain.selectors) {
723
+ for (const term of selector.terms) {
724
+ if (term.key === 'role' && typeof term.value === 'string') {
725
+ roleFilters.add(normalizeType(term.value));
726
+ }
727
+ if (
728
+ (term.key === 'text' || term.key === 'label' || term.key === 'value') &&
729
+ typeof term.value === 'string' &&
730
+ /^\d+$/.test(term.value.trim())
731
+ ) {
732
+ hasNumericTerm = true;
733
+ }
734
+ }
735
+ }
736
+ if (!hasNumericTerm) return null;
737
+
738
+ const numericNodes = snapshot.nodes.filter((node) => {
739
+ const text = extractNodeText(node).trim();
740
+ if (!/^\d+$/.test(text)) return false;
741
+ if (roleFilters.size === 0) return true;
742
+ return roleFilters.has(normalizeType(node.type ?? ''));
743
+ });
744
+ if (numericNodes.length === 0) return null;
745
+ const numericValues = uniqueStrings(numericNodes.map((node) => extractNodeText(node).trim()));
746
+ if (numericValues.length !== 1) return null;
747
+
748
+ const targetNode = numericNodes[0];
749
+ if (!targetNode) return null;
750
+ const selectorChain = buildSelectorChainForNode(targetNode, session.device.platform, { action: 'get' });
751
+ if (selectorChain.length === 0) return null;
752
+ return {
753
+ ...action,
754
+ positionals: ['text', selectorChain.join(' || ')],
755
+ };
756
+ }
757
+
693
758
  function parseReplayScript(script: string): SessionAction[] {
694
759
  const actions: SessionAction[] = [];
695
760
  const lines = script.split(/\r?\n/);