visor-ai 0.2.4 → 0.2.6

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 CHANGED
@@ -29,7 +29,6 @@ Supported today:
29
29
  - Android
30
30
  - iOS
31
31
  - real Appium-backed runs
32
- - mock runs for predictable dry-run behavior
33
32
 
34
33
  ## Install
35
34
 
@@ -42,4 +41,16 @@ visor --help
42
41
 
43
42
  ## Documentation
44
43
 
45
- Comprehensive product documentation lives in [the docs site](https://na-ca6c7a2b.mintlify.app).
44
+ Comprehensive product documentation lives in [the docs site](https://www.visorai.dev/docs).
45
+
46
+ ## Releases
47
+
48
+ Create a release by running one of:
49
+
50
+ ```bash
51
+ npm run release:patch
52
+ npm run release:minor
53
+ npm run release:major
54
+ ```
55
+
56
+ Each command runs `npm run check`, bumps the version, creates the matching `vX.Y.Z` git tag, and pushes the commit and tag upstream with `git push --follow-tags`.
package/dist/adapters.js CHANGED
@@ -1,14 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { remote } from 'webdriverio';
4
3
  import { errorMessage, parseServerUrl, sleep } from './utils.js';
5
4
  export const DEFAULT_SERVER_URL = 'http://127.0.0.1:4723';
6
5
  export const DEFAULT_ANDROID_APP = 'com.example.app';
7
6
  export const DEFAULT_IOS_BUNDLE = 'com.example.app';
8
- export const DEFAULT_ANDROID_DEVICE = 'emulator-5554';
9
- export const DEFAULT_IOS_DEVICE = 'iPhone 17 Pro';
10
- const MINI_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0x8AAAAASUVORK5CYII=';
11
- const MINI_PNG = Buffer.from(MINI_PNG_BASE64, 'base64');
12
7
  export const ACCESSIBILITY_ID = 'accessibility id';
13
8
  export const XPATH = 'xpath';
14
9
  export const ELEMENT_ID = 'id';
@@ -25,6 +20,18 @@ function envBool(preferred, legacy, defaultValue = false) {
25
20
  }
26
21
  return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
27
22
  }
23
+ function envNumber(preferred, defaultValue) {
24
+ const raw = process.env[preferred];
25
+ if (raw === undefined) {
26
+ return defaultValue;
27
+ }
28
+ const parsed = Number(raw);
29
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
30
+ }
31
+ export function resolveWebdriverConnectionTimeout(platform) {
32
+ const defaultTimeout = platform === 'ios' ? 240000 : 60000;
33
+ return envNumber('VISOR_WEBDRIVER_CONNECTION_TIMEOUT_MS', defaultTimeout);
34
+ }
28
35
  function pngDimensions(filePath) {
29
36
  try {
30
37
  const header = fs.readFileSync(filePath).subarray(0, 24);
@@ -50,6 +57,9 @@ export function formatDriverCreationError(platform, appId, attachToRunning, erro
50
57
  : '';
51
58
  return `Failed to create WebdriverIO Appium session: ${message}. On iOS, --app-id must be the exact installed bundle identifier for the target app (${targetApp}). Android package names do not carry over automatically.${attachHint}`;
52
59
  }
60
+ if (platform === 'ios' && (message.includes('CoreSimulatorService') || message.includes('simctl'))) {
61
+ return `Failed to create WebdriverIO Appium session: ${message}. iOS simulator access failed before the command ran; verify \`xcrun simctl list\` works from the same shell and that Appium is not running in a sandboxed process.`;
62
+ }
53
63
  return `Failed to create WebdriverIO Appium session: ${message}`;
54
64
  }
55
65
  export function parseTarget(target) {
@@ -115,6 +125,22 @@ export function resolveTapMode(args) {
115
125
  }
116
126
  throw new Error('tap requires target or x/y coordinates');
117
127
  }
128
+ function resolveScrollOptions(args) {
129
+ const direction = typeof args.direction === 'string' ? args.direction.toLowerCase() : '';
130
+ if (direction !== 'up' && direction !== 'down') {
131
+ throw new Error("scroll requires args.direction to be 'up' or 'down'");
132
+ }
133
+ const rawPercent = args.percent ?? 70;
134
+ const percent = Number(rawPercent);
135
+ if (!Number.isFinite(percent) || percent < 1 || percent > 100) {
136
+ throw new Error('scroll args.percent must be a number between 1 and 100');
137
+ }
138
+ return {
139
+ direction,
140
+ percent,
141
+ gesturePercent: percent / 100
142
+ };
143
+ }
118
144
  export class RealAppiumAdapter {
119
145
  platform;
120
146
  serverUrl;
@@ -137,7 +163,7 @@ export class RealAppiumAdapter {
137
163
  capability() {
138
164
  return {
139
165
  platform: this.platform,
140
- commands: ['navigate', 'tap', 'act', 'screenshot', 'wait', 'source']
166
+ commands: ['navigate', 'tap', 'act', 'scroll', 'screenshot', 'wait', 'source']
141
167
  };
142
168
  }
143
169
  async navigate(args) {
@@ -200,6 +226,15 @@ export class RealAppiumAdapter {
200
226
  }
201
227
  throw new Error('Unsupported act operation; use --name type --target <selector> --value <text> or --name back');
202
228
  }
229
+ async scroll(args) {
230
+ const { direction, percent, gesturePercent } = resolveScrollOptions(args);
231
+ await this.scrollViewport(direction, gesturePercent);
232
+ return {
233
+ action: 'scroll',
234
+ platform: this.platform,
235
+ args: { direction, percent }
236
+ };
237
+ }
203
238
  async screenshot(args) {
204
239
  const label = String(args.label ?? 'capture');
205
240
  const filePath = path.resolve(typeof args.path === 'string' ? args.path : `${label}.png`);
@@ -272,11 +307,13 @@ export class RealAppiumAdapter {
272
307
  envBool('VISOR_ATTACH_TO_RUNNING', 'PATF_ATTACH_TO_RUNNING', false);
273
308
  const server = parseServerUrl(this.serverUrl);
274
309
  const capabilities = {};
310
+ if (!this.device) {
311
+ throw new Error('A running device must be selected before creating an Appium session.');
312
+ }
275
313
  if (this.platform === 'android') {
276
314
  capabilities.platformName = 'Android';
277
315
  capabilities['appium:automationName'] = 'UiAutomator2';
278
- capabilities['appium:udid'] =
279
- this.device ?? env('VISOR_ANDROID_DEVICE', 'PATF_ANDROID_DEVICE', DEFAULT_ANDROID_DEVICE);
316
+ capabilities['appium:udid'] = this.device;
280
317
  capabilities['appium:appPackage'] =
281
318
  this.appId ?? env('VISOR_ANDROID_APP_PACKAGE', 'PATF_ANDROID_APP_PACKAGE', DEFAULT_ANDROID_APP);
282
319
  capabilities['appium:appActivity'] = env('VISOR_ANDROID_APP_ACTIVITY', 'PATF_ANDROID_APP_ACTIVITY', '.MainActivity');
@@ -291,8 +328,7 @@ export class RealAppiumAdapter {
291
328
  else {
292
329
  capabilities.platformName = 'iOS';
293
330
  capabilities['appium:automationName'] = 'XCUITest';
294
- capabilities['appium:deviceName'] =
295
- this.device ?? env('VISOR_IOS_DEVICE', 'PATF_IOS_DEVICE', DEFAULT_IOS_DEVICE);
331
+ capabilities['appium:udid'] = this.device;
296
332
  capabilities['appium:bundleId'] =
297
333
  this.appId ?? env('VISOR_IOS_BUNDLE_ID', 'PATF_IOS_BUNDLE_ID', DEFAULT_IOS_BUNDLE);
298
334
  capabilities['appium:newCommandTimeout'] = 60;
@@ -305,13 +341,16 @@ export class RealAppiumAdapter {
305
341
  }
306
342
  }
307
343
  try {
344
+ const { remote } = await import('webdriverio');
308
345
  return await remote({
309
346
  protocol: server.protocol,
310
347
  hostname: server.host,
311
348
  port: server.port,
312
349
  path: server.pathname,
313
350
  capabilities,
314
- logLevel: 'error'
351
+ logLevel: 'error',
352
+ connectionRetryCount: envNumber('VISOR_WEBDRIVER_CONNECTION_RETRY_COUNT', 0),
353
+ connectionRetryTimeout: resolveWebdriverConnectionTimeout(this.platform)
315
354
  });
316
355
  }
317
356
  catch (error) {
@@ -343,83 +382,60 @@ export class RealAppiumAdapter {
343
382
  }
344
383
  throw new Error(`Coordinate tap is unsupported for platform: ${this.platform}`);
345
384
  }
346
- }
347
- export class MockAdapter {
348
- platform;
349
- constructor(platform) {
350
- this.platform = platform;
351
- }
352
- capability() {
353
- return {
354
- platform: this.platform,
355
- commands: ['navigate', 'tap', 'act', 'screenshot', 'wait', 'source']
356
- };
357
- }
358
- async navigate(args) {
359
- return { action: 'navigate', platform: this.platform, args };
360
- }
361
- async tap(args) {
362
- resolveTapMode(args);
363
- return { action: 'tap', platform: this.platform, args };
364
- }
365
- async act(args) {
366
- return { action: 'act', platform: this.platform, args };
367
- }
368
- async screenshot(args) {
369
- const label = String(args.label ?? 'capture');
370
- const filePath = path.resolve(typeof args.path === 'string' ? args.path : `${label}.png`);
371
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
372
- fs.writeFileSync(filePath, MINI_PNG);
373
- const { width, height } = pngDimensions(filePath);
374
- return {
375
- action: 'screenshot',
376
- platform: this.platform,
377
- args: {
378
- label,
379
- file: path.basename(filePath),
380
- path: filePath,
381
- width,
382
- height
385
+ async scrollViewport(direction, gesturePercent) {
386
+ const driver = this.requireDriver();
387
+ const size = await driver.getWindowSize();
388
+ const left = Math.max(0, Math.round(size.width * 0.1));
389
+ const top = Math.max(0, Math.round(size.height * 0.1));
390
+ const width = Math.max(1, Math.round(size.width * 0.8));
391
+ const height = Math.max(1, Math.round(size.height * 0.8));
392
+ if (this.platform === 'android') {
393
+ await driver.execute('mobile: scrollGesture', [
394
+ { left, top, width, height, direction, percent: gesturePercent }
395
+ ]);
396
+ return;
397
+ }
398
+ if (this.platform === 'ios') {
399
+ try {
400
+ await driver.execute('mobile: scrollGesture', [
401
+ { left, top, width, height, direction, percent: gesturePercent }
402
+ ]);
403
+ return;
404
+ }
405
+ catch {
406
+ await this.swipeViewport(direction, gesturePercent, size.width, size.height);
407
+ return;
383
408
  }
384
- };
385
- }
386
- async wait(args) {
387
- const ms = Number(args.ms ?? 0);
388
- if (ms < 0) {
389
- throw new Error('wait requires non-negative ms');
390
409
  }
391
- return { action: 'wait', platform: this.platform, args: { ms } };
410
+ throw new Error(`Scroll is unsupported for platform: ${this.platform}`);
392
411
  }
393
- async source(args) {
394
- const label = String(args.label ?? 'source');
395
- const filePath = path.resolve(typeof args.path === 'string' ? args.path : `${label}.xml`);
396
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
397
- const content = `<hierarchy platform="${this.platform}"><node text="mock" /></hierarchy>\n`;
398
- fs.writeFileSync(filePath, content, 'utf8');
399
- return {
400
- action: 'source',
401
- platform: this.platform,
402
- args: {
403
- label,
404
- file: path.basename(filePath),
405
- path: filePath,
406
- format: 'xml',
407
- bytes: fs.statSync(filePath).size
412
+ async swipeViewport(direction, gesturePercent, viewportWidth, viewportHeight) {
413
+ const driver = this.requireDriver();
414
+ const x = Math.round(viewportWidth / 2);
415
+ const lowY = Math.round(viewportHeight * 0.75);
416
+ const highY = Math.round(viewportHeight * 0.25);
417
+ const travel = Math.max(1, Math.round(viewportHeight * gesturePercent));
418
+ const startY = direction === 'down' ? lowY : highY;
419
+ const unclampedEndY = direction === 'down' ? startY - travel : startY + travel;
420
+ const endY = Math.max(1, Math.min(viewportHeight - 1, unclampedEndY));
421
+ await driver.performActions([
422
+ {
423
+ type: 'pointer',
424
+ id: 'finger1',
425
+ parameters: { pointerType: 'touch' },
426
+ actions: [
427
+ { type: 'pointerMove', duration: 0, x, y: startY },
428
+ { type: 'pointerDown', button: 0 },
429
+ { type: 'pause', duration: 150 },
430
+ { type: 'pointerMove', duration: 400, x, y: endY },
431
+ { type: 'pointerUp', button: 0 }
432
+ ]
408
433
  }
409
- };
410
- }
411
- async exists(target) {
412
- const lowered = target.toLowerCase();
413
- return !lowered.includes('missing') && !lowered.includes('not_found');
414
- }
415
- async close() {
416
- return;
434
+ ]);
435
+ await driver.releaseActions();
417
436
  }
418
437
  }
419
- export async function getAdapter(platform, serverUrl = DEFAULT_SERVER_URL, device, useMock = false, appId, attachToRunning = false) {
438
+ export async function getAdapter(platform, serverUrl = DEFAULT_SERVER_URL, device, appId, attachToRunning = false) {
420
439
  const normalized = platform.toLowerCase();
421
- if (useMock) {
422
- return new MockAdapter(normalized);
423
- }
424
440
  return RealAppiumAdapter.create(normalized, serverUrl, device, appId, attachToRunning);
425
441
  }
@@ -25,7 +25,10 @@ function pidExists(pid) {
25
25
  process.kill(pid, 0);
26
26
  return true;
27
27
  }
28
- catch {
28
+ catch (error) {
29
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EPERM') {
30
+ return true;
31
+ }
29
32
  return false;
30
33
  }
31
34
  }