visor-ai 0.2.1 → 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 CHANGED
@@ -35,32 +35,23 @@ Supported today:
35
35
 
36
36
  Visor ships as the npm package `visor-ai` and requires Node `20` or later.
37
37
 
38
- For a published install:
39
-
40
38
  ```bash
41
39
  npm install -g visor-ai
42
40
  visor --help
43
41
  ```
44
42
 
45
- For a source checkout:
46
-
47
- ```bash
48
- npm install
49
- npm run build
50
- node dist/main.js --help
51
- ```
52
-
53
- ## Release automation
54
-
55
- GitHub Actions publishes tagged releases of `visor-ai` to the npm registry.
43
+ ## Documentation
56
44
 
57
- Maintainer requirements:
45
+ Comprehensive product documentation lives in [the docs site](https://www.visorai.dev/docs).
58
46
 
59
- - repository secret `NPM_TOKEN`
60
- - release tags in the form `v<package.json version>`
47
+ ## Releases
61
48
 
62
- The release workflow verifies the package with `npm ci`, `npm run build`, `npm test`, and `npm pack --dry-run` before publishing.
49
+ Create a release by running one of:
63
50
 
64
- ## Documentation
51
+ ```bash
52
+ npm run release:patch
53
+ npm run release:minor
54
+ npm run release:major
55
+ ```
65
56
 
66
- Comprehensive product documentation lives in [the docs site](https://na-ca6c7a2b.mintlify.app).
57
+ 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
@@ -115,6 +115,22 @@ export function resolveTapMode(args) {
115
115
  }
116
116
  throw new Error('tap requires target or x/y coordinates');
117
117
  }
118
+ function resolveScrollOptions(args) {
119
+ const direction = typeof args.direction === 'string' ? args.direction.toLowerCase() : '';
120
+ if (direction !== 'up' && direction !== 'down') {
121
+ throw new Error("scroll requires args.direction to be 'up' or 'down'");
122
+ }
123
+ const rawPercent = args.percent ?? 70;
124
+ const percent = Number(rawPercent);
125
+ if (!Number.isFinite(percent) || percent < 1 || percent > 100) {
126
+ throw new Error('scroll args.percent must be a number between 1 and 100');
127
+ }
128
+ return {
129
+ direction,
130
+ percent,
131
+ gesturePercent: percent / 100
132
+ };
133
+ }
118
134
  export class RealAppiumAdapter {
119
135
  platform;
120
136
  serverUrl;
@@ -137,7 +153,7 @@ export class RealAppiumAdapter {
137
153
  capability() {
138
154
  return {
139
155
  platform: this.platform,
140
- commands: ['navigate', 'tap', 'act', 'screenshot', 'wait', 'source']
156
+ commands: ['navigate', 'tap', 'act', 'scroll', 'screenshot', 'wait', 'source']
141
157
  };
142
158
  }
143
159
  async navigate(args) {
@@ -200,6 +216,15 @@ export class RealAppiumAdapter {
200
216
  }
201
217
  throw new Error('Unsupported act operation; use --name type --target <selector> --value <text> or --name back');
202
218
  }
219
+ async scroll(args) {
220
+ const { direction, percent, gesturePercent } = resolveScrollOptions(args);
221
+ await this.scrollViewport(direction, gesturePercent);
222
+ return {
223
+ action: 'scroll',
224
+ platform: this.platform,
225
+ args: { direction, percent }
226
+ };
227
+ }
203
228
  async screenshot(args) {
204
229
  const label = String(args.label ?? 'capture');
205
230
  const filePath = path.resolve(typeof args.path === 'string' ? args.path : `${label}.png`);
@@ -343,6 +368,58 @@ export class RealAppiumAdapter {
343
368
  }
344
369
  throw new Error(`Coordinate tap is unsupported for platform: ${this.platform}`);
345
370
  }
371
+ async scrollViewport(direction, gesturePercent) {
372
+ const driver = this.requireDriver();
373
+ const size = await driver.getWindowSize();
374
+ const left = Math.max(0, Math.round(size.width * 0.1));
375
+ const top = Math.max(0, Math.round(size.height * 0.1));
376
+ const width = Math.max(1, Math.round(size.width * 0.8));
377
+ const height = Math.max(1, Math.round(size.height * 0.8));
378
+ if (this.platform === 'android') {
379
+ await driver.execute('mobile: scrollGesture', [
380
+ { left, top, width, height, direction, percent: gesturePercent }
381
+ ]);
382
+ return;
383
+ }
384
+ if (this.platform === 'ios') {
385
+ try {
386
+ await driver.execute('mobile: scrollGesture', [
387
+ { left, top, width, height, direction, percent: gesturePercent }
388
+ ]);
389
+ return;
390
+ }
391
+ catch {
392
+ await this.swipeViewport(direction, gesturePercent, size.width, size.height);
393
+ return;
394
+ }
395
+ }
396
+ throw new Error(`Scroll is unsupported for platform: ${this.platform}`);
397
+ }
398
+ async swipeViewport(direction, gesturePercent, viewportWidth, viewportHeight) {
399
+ const driver = this.requireDriver();
400
+ const x = Math.round(viewportWidth / 2);
401
+ const lowY = Math.round(viewportHeight * 0.75);
402
+ const highY = Math.round(viewportHeight * 0.25);
403
+ const travel = Math.max(1, Math.round(viewportHeight * gesturePercent));
404
+ const startY = direction === 'down' ? lowY : highY;
405
+ const unclampedEndY = direction === 'down' ? startY - travel : startY + travel;
406
+ const endY = Math.max(1, Math.min(viewportHeight - 1, unclampedEndY));
407
+ await driver.performActions([
408
+ {
409
+ type: 'pointer',
410
+ id: 'finger1',
411
+ parameters: { pointerType: 'touch' },
412
+ actions: [
413
+ { type: 'pointerMove', duration: 0, x, y: startY },
414
+ { type: 'pointerDown', button: 0 },
415
+ { type: 'pause', duration: 150 },
416
+ { type: 'pointerMove', duration: 400, x, y: endY },
417
+ { type: 'pointerUp', button: 0 }
418
+ ]
419
+ }
420
+ ]);
421
+ await driver.releaseActions();
422
+ }
346
423
  }
347
424
  export class MockAdapter {
348
425
  platform;
@@ -352,7 +429,7 @@ export class MockAdapter {
352
429
  capability() {
353
430
  return {
354
431
  platform: this.platform,
355
- commands: ['navigate', 'tap', 'act', 'screenshot', 'wait', 'source']
432
+ commands: ['navigate', 'tap', 'act', 'scroll', 'screenshot', 'wait', 'source']
356
433
  };
357
434
  }
358
435
  async navigate(args) {
@@ -365,6 +442,14 @@ export class MockAdapter {
365
442
  async act(args) {
366
443
  return { action: 'act', platform: this.platform, args };
367
444
  }
445
+ async scroll(args) {
446
+ const { direction, percent } = resolveScrollOptions(args);
447
+ return {
448
+ action: 'scroll',
449
+ platform: this.platform,
450
+ args: { direction, percent }
451
+ };
452
+ }
368
453
  async screenshot(args) {
369
454
  const label = String(args.label ?? 'capture');
370
455
  const filePath = path.resolve(typeof args.path === 'string' ? args.path : `${label}.png`);
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ const ACTION_COMMANDS = new Set([
9
9
  'tap',
10
10
  'navigate',
11
11
  'act',
12
+ 'scroll',
12
13
  'screenshot',
13
14
  'wait',
14
15
  'source'
@@ -44,6 +45,8 @@ const ACTION_SPEC = {
44
45
  target: 'string',
45
46
  x: 'number',
46
47
  y: 'number',
48
+ direction: 'string',
49
+ percent: 'number',
47
50
  normalized: 'boolean',
48
51
  to: 'string',
49
52
  name: 'string',
@@ -103,6 +106,7 @@ const COMMAND_SPECS = {
103
106
  tap: ACTION_SPEC,
104
107
  navigate: ACTION_SPEC,
105
108
  act: ACTION_SPEC,
109
+ scroll: ACTION_SPEC,
106
110
  screenshot: ACTION_SPEC,
107
111
  wait: ACTION_SPEC,
108
112
  source: ACTION_SPEC
@@ -123,11 +127,12 @@ function helpText() {
123
127
  ' start [--server-url <url>]',
124
128
  ' status [--server-url <url>]',
125
129
  ' stop [--server-url <url>] [--force]',
126
- ' tap|navigate|act|screenshot|wait|source',
130
+ ' tap|navigate|act|scroll|screenshot|wait|source',
127
131
  '',
128
132
  'Examples:',
129
133
  ' visor validate scenarios/checkout-smoke.json',
130
134
  ' visor run scenarios/checkout-smoke.json --mock --output artifacts-test',
135
+ ' visor scroll --platform android --mock --direction down',
131
136
  ' node dist/main.js status'
132
137
  ].join('\n');
133
138
  }
@@ -288,6 +293,7 @@ function cmdHelp() {
288
293
  examples: [
289
294
  'visor validate scenarios/checkout-smoke.json',
290
295
  'visor run scenarios/checkout-smoke.json --mock --output artifacts-test',
296
+ 'visor scroll --platform android --mock --direction down',
291
297
  'node dist/main.js status'
292
298
  ]
293
299
  };
package/dist/main.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
2
5
  import { executeCommand } from './cli.js';
3
6
  import { makeError } from './errors.js';
4
7
  import { makeId, utcNowIso } from './utils.js';
@@ -35,7 +38,20 @@ async function main(argv = process.argv.slice(2)) {
35
38
  return 1;
36
39
  }
37
40
  }
38
- if (import.meta.url === `file://${process.argv[1]}`) {
41
+ function isDirectExecution() {
42
+ if (!process.argv[1]) {
43
+ return false;
44
+ }
45
+ try {
46
+ const currentModulePath = fs.realpathSync(fileURLToPath(import.meta.url));
47
+ const invokedPath = fs.realpathSync(path.resolve(process.argv[1]));
48
+ return currentModulePath === invokedPath;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ if (isDirectExecution()) {
39
55
  void main().then((code) => {
40
56
  process.exitCode = code;
41
57
  });
package/dist/runner.js CHANGED
@@ -11,6 +11,8 @@ async function runStep(adapter, command, args) {
11
11
  return adapter.navigate(args);
12
12
  case 'act':
13
13
  return adapter.act(args);
14
+ case 'scroll':
15
+ return adapter.scroll(args);
14
16
  case 'screenshot':
15
17
  return adapter.screenshot(args);
16
18
  case 'wait':
package/dist/validator.js CHANGED
@@ -5,7 +5,7 @@ const ALLOWED_CONFIG = new Set(['timeoutMs', 'seed', 'artifactsDir']);
5
5
  const ALLOWED_STEP = new Set(['id', 'command', 'args']);
6
6
  const ALLOWED_ASSERT = new Set(['id', 'type', 'target']);
7
7
  const ALLOWED_OUTPUT = new Set(['report']);
8
- const SUPPORTED_COMMANDS = new Set(['tap', 'navigate', 'act', 'screenshot', 'wait', 'source']);
8
+ const SUPPORTED_COMMANDS = new Set(['tap', 'navigate', 'act', 'scroll', 'screenshot', 'wait', 'source']);
9
9
  function validationIssue(severity, code, message, issuePath) {
10
10
  return { severity, code, message, path: issuePath };
11
11
  }
@@ -46,6 +46,21 @@ function validateTapArgs(args, issuePath) {
46
46
  }
47
47
  return issues;
48
48
  }
49
+ function validateScrollArgs(args, issuePath) {
50
+ const issues = [];
51
+ if (!Object.hasOwn(args, 'direction')) {
52
+ issues.push(validationIssue('error', 'ARG_ERROR', 'scroll requires args.direction', issuePath));
53
+ return issues;
54
+ }
55
+ if (typeof args.direction !== 'string' || !['up', 'down'].includes(args.direction.toLowerCase())) {
56
+ issues.push(validationIssue('error', 'ARG_ERROR', "scroll args.direction must be 'up' or 'down'", issuePath));
57
+ }
58
+ if (Object.hasOwn(args, 'percent') &&
59
+ (typeof args.percent !== 'number' || !Number.isFinite(args.percent) || args.percent < 1 || args.percent > 100)) {
60
+ issues.push(validationIssue('error', 'ARG_ERROR', 'scroll args.percent must be a number between 1 and 100', issuePath));
61
+ }
62
+ return issues;
63
+ }
49
64
  export function parseAndValidate(filePath) {
50
65
  const issues = [];
51
66
  const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -115,6 +130,9 @@ export function parseAndValidate(filePath) {
115
130
  if (command === 'navigate' && isRecord(args) && !Object.hasOwn(args, 'to')) {
116
131
  issues.push(validationIssue('error', 'ARG_ERROR', 'navigate requires args.to', `${issuePath}.args`));
117
132
  }
133
+ if (command === 'scroll' && isRecord(args)) {
134
+ issues.push(...validateScrollArgs(args, `${issuePath}.args`));
135
+ }
118
136
  if (command === 'screenshot' && isRecord(args) && !Object.hasOwn(args, 'label')) {
119
137
  issues.push(validationIssue('warning', 'DETERMINISM_WARNING', 'screenshot missing label may reduce determinism', `${issuePath}.args`));
120
138
  }
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "visor-ai",
3
- "version": "0.2.1",
3
+ "version": "0.2.5",
4
4
  "description": "Visor CLI for LLM-driven mobile app interaction and artifact capture",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/AleksKapera/visor"
10
+ },
11
+ "homepage": "https://github.com/AleksKapera/visor",
12
+ "bugs": {
13
+ "url": "https://github.com/AleksKapera/visor/issues"
14
+ },
7
15
  "engines": {
8
16
  "node": ">=20"
9
17
  },
@@ -22,7 +30,11 @@
22
30
  "build": "rm -rf dist && tsc -p tsconfig.json",
23
31
  "dev": "tsx src/main.ts",
24
32
  "test": "vitest run",
25
- "check": "npm run build && npm run test"
33
+ "check": "npm run build && npm run test",
34
+ "release": "node scripts/release.mjs",
35
+ "release:patch": "node scripts/release.mjs patch",
36
+ "release:minor": "node scripts/release.mjs minor",
37
+ "release:major": "node scripts/release.mjs major"
26
38
  },
27
39
  "keywords": [
28
40
  "appium",
@@ -43,4 +55,4 @@
43
55
  "typescript": "^5.8.2",
44
56
  "vitest": "^3.0.8"
45
57
  }
46
- }
58
+ }