visor-ai 0.2.8 → 0.2.9

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/dist/adapters.js CHANGED
@@ -10,6 +10,7 @@ export const ELEMENT_ID = 'id';
10
10
  export const ANDROID_UIAUTOMATOR = '-android uiautomator';
11
11
  export const IOS_PREDICATE = '-ios predicate string';
12
12
  export const IOS_CLASS_CHAIN = '-ios class chain';
13
+ const TEXT_ATTRIBUTES = ['text', 'content-desc', 'label', 'name', 'value'];
13
14
  function env(preferred, legacy, defaultValue) {
14
15
  return process.env[preferred] ?? process.env[legacy] ?? defaultValue;
15
16
  }
@@ -48,6 +49,38 @@ function pngDimensions(filePath) {
48
49
  return { width: null, height: null };
49
50
  }
50
51
  }
52
+ function validateRect(rect) {
53
+ const candidate = rect && typeof rect === 'object' && !Array.isArray(rect)
54
+ ? rect
55
+ : {};
56
+ const x = Number(candidate.x);
57
+ const y = Number(candidate.y);
58
+ const width = Number(candidate.width);
59
+ const height = Number(candidate.height);
60
+ if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
61
+ throw new Error('Element rectangle is invalid');
62
+ }
63
+ return { x, y, width, height };
64
+ }
65
+ function xpathLiteral(value) {
66
+ if (!value.includes("'")) {
67
+ return `'${value}'`;
68
+ }
69
+ if (!value.includes('"')) {
70
+ return `"${value}"`;
71
+ }
72
+ return `concat(${value
73
+ .split("'")
74
+ .map((part) => `'${part}'`)
75
+ .join(', "\"\'\"", ')})`;
76
+ }
77
+ function textXPath(value, mode) {
78
+ const literal = xpathLiteral(value);
79
+ const clauses = TEXT_ATTRIBUTES.map((attribute) => mode === 'exact'
80
+ ? `@${attribute} = ${literal}`
81
+ : `contains(@${attribute}, ${literal})`);
82
+ return `//*[${clauses.join(' or ')}]`;
83
+ }
51
84
  export function formatDriverCreationError(platform, appId, attachToRunning, error) {
52
85
  const message = errorMessage(error);
53
86
  if (platform === 'ios' && message.includes('bundle identifier') && message.includes('unknown')) {
@@ -63,28 +96,30 @@ export function formatDriverCreationError(platform, appId, attachToRunning, erro
63
96
  return `Failed to create WebdriverIO Appium session: ${message}`;
64
97
  }
65
98
  export function parseTarget(target) {
99
+ const valueAfterPrefix = () => target.slice(target.indexOf('=') + 1);
100
+ if (target.startsWith('text~=')) {
101
+ return [XPATH, textXPath(valueAfterPrefix(), 'contains')];
102
+ }
66
103
  if (target.startsWith('text=')) {
67
- const value = target.split('=', 2)[1] ?? '';
68
- const xpath = `//*[contains(@text, '${value}') or contains(@content-desc, '${value}') or contains(@label, '${value}') or contains(@name, '${value}') or contains(@value, '${value}')]`;
69
- return [XPATH, xpath];
104
+ return [XPATH, textXPath(valueAfterPrefix(), 'exact')];
70
105
  }
71
106
  if (target.startsWith('id=')) {
72
- return [ELEMENT_ID, target.split('=', 2)[1] ?? ''];
107
+ return [ELEMENT_ID, valueAfterPrefix()];
73
108
  }
74
109
  if (target.startsWith('xpath=')) {
75
- return [XPATH, target.split('=', 2)[1] ?? ''];
110
+ return [XPATH, valueAfterPrefix()];
76
111
  }
77
112
  if (target.startsWith('uiautomator=')) {
78
- return [ANDROID_UIAUTOMATOR, target.split('=', 2)[1] ?? ''];
113
+ return [ANDROID_UIAUTOMATOR, valueAfterPrefix()];
79
114
  }
80
115
  if (target.startsWith('predicate=')) {
81
- return [IOS_PREDICATE, target.split('=', 2)[1] ?? ''];
116
+ return [IOS_PREDICATE, valueAfterPrefix()];
82
117
  }
83
118
  if (target.startsWith('classchain=')) {
84
- return [IOS_CLASS_CHAIN, target.split('=', 2)[1] ?? ''];
119
+ return [IOS_CLASS_CHAIN, valueAfterPrefix()];
85
120
  }
86
121
  if (target.startsWith('accessibility=')) {
87
- return [ACCESSIBILITY_ID, target.split('=', 2)[1] ?? ''];
122
+ return [ACCESSIBILITY_ID, valueAfterPrefix()];
88
123
  }
89
124
  return [ACCESSIBILITY_ID, target];
90
125
  }
@@ -194,7 +229,7 @@ export class RealAppiumAdapter {
194
229
  const target = String(args.target);
195
230
  const selector = selectorForTarget(target);
196
231
  const element = await this.requireDriver().$(selector.selector);
197
- await element.click();
232
+ await this.tapElementCenter(element);
198
233
  return {
199
234
  action: 'tap',
200
235
  platform: this.platform,
@@ -224,7 +259,52 @@ export class RealAppiumAdapter {
224
259
  args: { name }
225
260
  };
226
261
  }
227
- throw new Error('Unsupported act operation; use --name type --target <selector> --value <text> or --name back');
262
+ if (name === 'drag') {
263
+ const gesture = await this.resolveDragGesture(args);
264
+ await this.dragPointer(gesture.start, gesture.end);
265
+ return {
266
+ action: 'act',
267
+ platform: this.platform,
268
+ args: {
269
+ name,
270
+ startX: gesture.start.x,
271
+ startY: gesture.start.y,
272
+ endX: gesture.end.x,
273
+ endY: gesture.end.y
274
+ }
275
+ };
276
+ }
277
+ if (name === 'slider') {
278
+ const gesture = await this.resolveSliderGesture(args);
279
+ await this.dragPointer(gesture.start, gesture.end);
280
+ return {
281
+ action: 'act',
282
+ platform: this.platform,
283
+ args: {
284
+ name,
285
+ target,
286
+ value: gesture.value,
287
+ startValue: gesture.startValue
288
+ }
289
+ };
290
+ }
291
+ if (name === 'home') {
292
+ await this.pressHome();
293
+ return {
294
+ action: 'act',
295
+ platform: this.platform,
296
+ args: { name }
297
+ };
298
+ }
299
+ if (name === 'reset') {
300
+ await this.resetApp();
301
+ return {
302
+ action: 'act',
303
+ platform: this.platform,
304
+ args: { name, app_id: this.appId }
305
+ };
306
+ }
307
+ throw new Error('Unsupported act operation; use --name type --target <selector> --value <text>, --name drag, --name slider, --name back, --name home, or --name reset');
228
308
  }
229
309
  async scroll(args) {
230
310
  const { direction, percent, gesturePercent } = resolveScrollOptions(args);
@@ -382,6 +462,98 @@ export class RealAppiumAdapter {
382
462
  }
383
463
  throw new Error(`Coordinate tap is unsupported for platform: ${this.platform}`);
384
464
  }
465
+ async tapElementCenter(element) {
466
+ try {
467
+ const rect = await this.elementRect(element);
468
+ const x = Math.round(rect.x + rect.width / 2);
469
+ const y = Math.round(rect.y + rect.height / 2);
470
+ await this.tapPoint(x, y);
471
+ }
472
+ catch {
473
+ await element.click();
474
+ }
475
+ }
476
+ async elementRect(element) {
477
+ if (typeof element.getRect === 'function') {
478
+ const rect = await element.getRect();
479
+ return validateRect(rect);
480
+ }
481
+ if (typeof element.getLocation === 'function' && typeof element.getSize === 'function') {
482
+ const location = await element.getLocation();
483
+ const size = await element.getSize();
484
+ return validateRect({
485
+ x: location.x,
486
+ y: location.y,
487
+ width: size.width,
488
+ height: size.height
489
+ });
490
+ }
491
+ throw new Error('Element rectangle is unavailable');
492
+ }
493
+ async pressHome() {
494
+ const driver = this.requireDriver();
495
+ if (this.platform === 'ios') {
496
+ await driver.execute('mobile: pressButton', { name: 'home' });
497
+ return;
498
+ }
499
+ if (typeof driver.pressKeyCode === 'function') {
500
+ await driver.pressKeyCode(3);
501
+ return;
502
+ }
503
+ await driver.execute('mobile: pressKey', { keycode: 3 });
504
+ }
505
+ async resetApp() {
506
+ if (!this.appId) {
507
+ throw new Error('act reset requires --app-id so Visor can restart the target app');
508
+ }
509
+ const driver = this.requireDriver();
510
+ if (typeof driver.terminateApp === 'function') {
511
+ await driver.terminateApp(this.appId);
512
+ }
513
+ else {
514
+ await driver.execute('mobile: terminateApp', this.platform === 'ios' ? { bundleId: this.appId } : { appId: this.appId });
515
+ }
516
+ await sleep(500);
517
+ if (typeof driver.activateApp === 'function') {
518
+ await driver.activateApp(this.appId);
519
+ return;
520
+ }
521
+ await driver.execute('mobile: activateApp', this.platform === 'ios' ? { bundleId: this.appId } : { appId: this.appId });
522
+ }
523
+ async resolveDragGesture(args) {
524
+ const normalized = Boolean(args.normalized);
525
+ const size = normalized ? await this.requireDriver().getWindowSize() : null;
526
+ const start = {
527
+ x: gestureNumber(args, ['startX', 'start-x', 'fromX', 'from-x'], 'act drag requires startX/startY and endX/endY'),
528
+ y: gestureNumber(args, ['startY', 'start-y', 'fromY', 'from-y'], 'act drag requires startX/startY and endX/endY')
529
+ };
530
+ const end = {
531
+ x: gestureNumber(args, ['endX', 'end-x', 'toX', 'to-x'], 'act drag requires startX/startY and endX/endY'),
532
+ y: gestureNumber(args, ['endY', 'end-y', 'toY', 'to-y'], 'act drag requires startX/startY and endX/endY')
533
+ };
534
+ return {
535
+ start: normalized && size ? scalePoint(start, size.width, size.height) : roundPoint(start),
536
+ end: normalized && size ? scalePoint(end, size.width, size.height) : roundPoint(end)
537
+ };
538
+ }
539
+ async resolveSliderGesture(args) {
540
+ const target = typeof args.target === 'string' ? args.target : '';
541
+ if (!target) {
542
+ throw new Error('act slider requires args.target');
543
+ }
544
+ const value = unitIntervalNumber(args.value, 'act slider args.value must be a number between 0 and 1');
545
+ const startValue = unitIntervalNumber(args.startValue ?? args['start-value'] ?? 0.5, 'act slider args.startValue must be a number between 0 and 1');
546
+ const selector = selectorForTarget(target);
547
+ const element = await this.requireDriver().$(selector.selector);
548
+ const rect = await this.elementRect(element);
549
+ const y = Math.round(rect.y + rect.height / 2);
550
+ return {
551
+ start: { x: Math.round(rect.x + rect.width * startValue), y },
552
+ end: { x: Math.round(rect.x + rect.width * value), y },
553
+ value,
554
+ startValue
555
+ };
556
+ }
385
557
  async scrollViewport(direction, gesturePercent) {
386
558
  const driver = this.requireDriver();
387
559
  const size = await driver.getWindowSize();
@@ -419,31 +591,61 @@ export class RealAppiumAdapter {
419
591
  }
420
592
  throw new Error(`Scroll is unsupported for platform: ${this.platform}`);
421
593
  }
422
- async swipeViewport(direction, gesturePercent, viewportWidth, viewportHeight) {
594
+ async dragPointer(start, end) {
423
595
  const driver = this.requireDriver();
424
- const x = Math.round(viewportWidth / 2);
425
- const lowY = Math.round(viewportHeight * 0.75);
426
- const highY = Math.round(viewportHeight * 0.25);
427
- const travel = Math.max(1, Math.round(viewportHeight * gesturePercent));
428
- const startY = direction === 'down' ? lowY : highY;
429
- const unclampedEndY = direction === 'down' ? startY - travel : startY + travel;
430
- const endY = Math.max(1, Math.min(viewportHeight - 1, unclampedEndY));
431
596
  await driver.performActions([
432
597
  {
433
598
  type: 'pointer',
434
599
  id: 'finger1',
435
600
  parameters: { pointerType: 'touch' },
436
601
  actions: [
437
- { type: 'pointerMove', duration: 0, x, y: startY },
602
+ { type: 'pointerMove', duration: 0, x: start.x, y: start.y },
438
603
  { type: 'pointerDown', button: 0 },
439
- { type: 'pause', duration: 150 },
440
- { type: 'pointerMove', duration: 400, x, y: endY },
604
+ { type: 'pause', duration: 100 },
605
+ { type: 'pointerMove', duration: 400, x: end.x, y: end.y },
441
606
  { type: 'pointerUp', button: 0 }
442
607
  ]
443
608
  }
444
609
  ]);
445
610
  await driver.releaseActions();
446
611
  }
612
+ async swipeViewport(direction, gesturePercent, viewportWidth, viewportHeight) {
613
+ const x = Math.round(viewportWidth / 2);
614
+ const lowY = Math.round(viewportHeight * 0.75);
615
+ const highY = Math.round(viewportHeight * 0.25);
616
+ const travel = Math.max(1, Math.round(viewportHeight * gesturePercent));
617
+ const startY = direction === 'down' ? lowY : highY;
618
+ const unclampedEndY = direction === 'down' ? startY - travel : startY + travel;
619
+ const endY = Math.max(1, Math.min(viewportHeight - 1, unclampedEndY));
620
+ await this.dragPointer({ x, y: startY }, { x, y: endY });
621
+ }
622
+ }
623
+ function gestureNumber(args, keys, message) {
624
+ const raw = keys.map((key) => args[key]).find((value) => value !== undefined);
625
+ const parsed = Number(raw);
626
+ if (!Number.isFinite(parsed)) {
627
+ throw new Error(message);
628
+ }
629
+ return parsed;
630
+ }
631
+ function unitIntervalNumber(value, message) {
632
+ const parsed = Number(value);
633
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
634
+ throw new Error(message);
635
+ }
636
+ return parsed;
637
+ }
638
+ function scalePoint(point, width, height) {
639
+ return {
640
+ x: Math.round(point.x * width),
641
+ y: Math.round(point.y * height)
642
+ };
643
+ }
644
+ function roundPoint(point) {
645
+ return {
646
+ x: Math.round(point.x),
647
+ y: Math.round(point.y)
648
+ };
447
649
  }
448
650
  export async function getAdapter(platform, serverUrl = DEFAULT_SERVER_URL, device, appId, attachToRunning = false) {
449
651
  const normalized = platform.toLowerCase();