@revyl/verify 0.4.0 → 0.5.0

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/session.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { RevylClient } from './api/client.js';
2
2
  import type { LiveStepType, Platform } from './api/types.js';
3
3
  import type { HostedTargetConfig, ResolvedDeviceTarget } from './config.js';
4
- import { type LocalAgent, type Location, type ModelRun, type Point, type SwipeDirection } from './agent/loop.js';
4
+ import { type Key, type KeyModifier, type LocalAgent, type Location, type ModelRun, type Point, type SwipeDirection } from './agent/loop.js';
5
5
  import { type ModelOption } from './agent/providers.js';
6
6
  export type StepType = LiveStepType;
7
7
  /** The outcome of one instruction or validation, as Revyl's agent reported it. */
@@ -103,27 +103,43 @@ export interface OpenedSession extends Session {
103
103
  report(): Promise<Record<string, unknown>>;
104
104
  navigate(url: string): Promise<void>;
105
105
  open(target: HostedTargetConfig): Promise<OpenedSession>;
106
- /** Taps a point in device units, or an element Revyl locates from a description. Returns the point tapped. Browsers and devices only. */
106
+ /** Taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point tapped, in screenshot pixels. Browsers and devices only. */
107
107
  tap(at: Location): Promise<Point>;
108
108
  /** Taps a text field and types into it, clearing it first unless clear is false. Returns the point tapped. Browsers and devices only. */
109
109
  type(at: Location, text: string, options?: {
110
110
  clear?: boolean;
111
111
  }): Promise<Point>;
112
+ /** Types into the field that already has focus, without tapping, replacing its content unless clear is false. Web only; on iOS and Android, use type(location, text). */
113
+ typeFocused(text: string, options?: {
114
+ clear?: boolean;
115
+ }): Promise<void>;
116
+ /** Double-taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point, in screenshot pixels. Browsers and devices only. */
117
+ doubleTap(at: Location): Promise<Point>;
118
+ /** Presses and holds a point or element for duration milliseconds (1 to 5000, default 1500). Returns the point, in screenshot pixels. Browsers and devices only. */
119
+ longPress(at: Location, options?: {
120
+ duration?: number;
121
+ }): Promise<Point>;
112
122
  /** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. Browsers and devices only. */
113
123
  swipe(direction: SwipeDirection, options?: {
114
124
  from?: Location;
115
125
  }): Promise<void>;
116
- /** Presses Enter or Backspace on the focused field. Browsers and devices only. */
117
- pressKey(key: 'enter' | 'backspace'): Promise<void>;
126
+ /** Presses at one point or element, moves to another and releases. Browsers and devices only. */
127
+ drag(from: Location, to: Location): Promise<void>;
128
+ /** Presses a key on the focused element. iOS and Android take 'enter' and 'backspace'; Web also takes the other Key names, and modifiers, with which a lowercase letter or digit also works. */
129
+ pressKey(key: Key | string, options?: {
130
+ modifiers?: KeyModifier[];
131
+ }): Promise<void>;
118
132
  /** Goes back: browser history on Web, the back button on Android. Not on iOS. */
119
133
  back(): Promise<void>;
134
+ /** Goes to the home screen on iOS and Android. Not on Web. */
135
+ goHome(): Promise<void>;
120
136
  /** Opens a URL or deep link on a device, or loads a URL in a browser. */
121
137
  openUrl(url: string): Promise<void>;
122
138
  /** Launches an installed app by bundle ID or package name. Devices only. */
123
139
  launchApp(bundleId: string): Promise<void>;
124
- /** Asks Revyl's grounding model where an element is on the current screen, in device units. Browsers and devices only. */
140
+ /** Asks Revyl's grounding model where an element is on the current screen, in screenshot pixels. Browsers and devices only. */
125
141
  locate(target: string): Promise<Point>;
126
- /** The screen size in device units: iOS points, Android and Web pixels. Browsers and devices only. */
142
+ /** The screenshot's size in pixels, the space every coordinate uses. Browsers and devices only. */
127
143
  screenSize(): Promise<{
128
144
  width: number;
129
145
  height: number;
@@ -160,6 +176,8 @@ export declare class DeviceSession implements Session {
160
176
  private parent?;
161
177
  private readySignal?;
162
178
  private size?;
179
+ private sizeSource?;
180
+ private screenshotSize?;
163
181
  private attribution?;
164
182
  get expiresAt(): Date | undefined;
165
183
  private constructor();
@@ -195,31 +213,51 @@ export declare class DeviceSession implements Session {
195
213
  private runStep;
196
214
  private runLocal;
197
215
  private agentControls;
198
- /** Taps a point in device units, or an element Revyl locates from a description. Returns the point tapped. */
216
+ /** Taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point tapped, in screenshot pixels. */
199
217
  tap(at: Location): Promise<Point>;
200
218
  /** Taps a text field and types into it, clearing it first unless clear is false. Returns the point tapped. */
201
219
  type(at: Location, text: string, options?: {
202
220
  clear?: boolean;
203
221
  }): Promise<Point>;
222
+ /** Types into the field that already has focus, without tapping, replacing its content unless clear is false. Web only. */
223
+ typeFocused(text: string, options?: {
224
+ clear?: boolean;
225
+ }): Promise<void>;
226
+ /** Double-taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point, in screenshot pixels. */
227
+ doubleTap(at: Location): Promise<Point>;
228
+ /** Presses and holds a point or element for duration milliseconds (1 to 5000, default 1500). Returns the point, in screenshot pixels. */
229
+ longPress(at: Location, options?: {
230
+ duration?: number;
231
+ }): Promise<Point>;
204
232
  /** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. */
205
233
  swipe(direction: SwipeDirection, options?: {
206
234
  from?: Location;
207
235
  }): Promise<void>;
208
- /** Presses Enter or Backspace on the focused field. */
209
- pressKey(key: 'enter' | 'backspace'): Promise<void>;
236
+ /** Presses at one point or element, moves to another and releases. */
237
+ drag(from: Location, to: Location): Promise<void>;
238
+ /** Presses a key on the focused element, with modifiers held on Web. */
239
+ pressKey(key: Key | string, options?: {
240
+ modifiers?: KeyModifier[];
241
+ }): Promise<void>;
210
242
  /** Goes back: browser history on Web, the back button on Android. Not on iOS. */
211
243
  back(): Promise<void>;
244
+ /** Goes to the home screen on iOS and Android. */
245
+ goHome(): Promise<void>;
212
246
  /** Opens a URL or deep link on a device, or loads a URL in a browser. */
213
247
  openUrl(url: string): Promise<void>;
214
248
  /** Launches an installed app by bundle ID or package name. Devices only. */
215
249
  launchApp(bundleId: string): Promise<void>;
216
- /** Asks Revyl's grounding model where an element is on the current screen, in device units. */
250
+ /** Asks Revyl's grounding model where an element is on the current screen, in screenshot pixels. */
217
251
  locate(target: string): Promise<Point>;
218
- /** The screen size in device units: iOS points, Android and Web pixels. */
252
+ /** The screenshot's size in pixels, the space every coordinate uses. */
219
253
  screenSize(): Promise<{
220
254
  width: number;
221
255
  height: number;
222
256
  }>;
257
+ /** The screenshot's size and the device size Revyl acts in. They differ on iOS, where Revyl acts in points and a screenshot has more pixels. */
258
+ private readScreenshotSize;
259
+ private toDevice;
260
+ private toScreenshot;
223
261
  private readScreenSize;
224
262
  private capture;
225
263
  private perform;
package/dist/session.js CHANGED
@@ -6,7 +6,7 @@ import { apiError, edgeBlockedError, parseJson, responseDetail, responseErrorCod
6
6
  import { trackSession, untrackSession } from './cleanup.js';
7
7
  import { VerifyError, errorMessage, isVerifyError } from './errors.js';
8
8
  import { screenHasContent } from './screen-readiness.js';
9
- import { runLocalStep } from './agent/loop.js';
9
+ import { WEB_KEYS, checkKey, runLocalStep } from './agent/loop.js';
10
10
  import { chooseAgent } from './agent/providers.js';
11
11
  const POLL_INTERVAL_MS = 2_000;
12
12
  const DEVICE_CONNECT_TIMEOUT_MS = 90_000;
@@ -41,6 +41,8 @@ export class DeviceSession {
41
41
  parent;
42
42
  readySignal;
43
43
  size;
44
+ sizeSource;
45
+ screenshotSize;
44
46
  attribution;
45
47
  get expiresAt() {
46
48
  return this.parent?.expiresAt;
@@ -423,38 +425,77 @@ export class DeviceSession {
423
425
  perform: (action, screenToken, signal) => this.perform(action, screenToken, signal),
424
426
  };
425
427
  }
426
- /** Taps a point in device units, or an element Revyl locates from a description. Returns the point tapped. */
428
+ /** Taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point tapped, in screenshot pixels. */
427
429
  async tap(at) {
428
- return (await this.perform({ kind: 'tap', at: checkLocation(at) }));
430
+ const location = await this.toDevice(checkLocation(at));
431
+ if (this.platform === 'ios')
432
+ await this.readScreenshotSize();
433
+ return this.toScreenshot((await this.perform({ kind: 'tap', at: location })));
429
434
  }
430
435
  /** Taps a text field and types into it, clearing it first unless clear is false. Returns the point tapped. */
431
436
  async type(at, text, options = {}) {
432
437
  if (typeof text !== 'string' || text.length > 16_384)
433
438
  throw new VerifyError('INVALID_ARGUMENT', 'type() takes text of at most 16384 characters.');
434
- return (await this.perform({ kind: 'type', at: checkLocation(at), text, clear: options.clear !== false }));
439
+ const location = await this.toDevice(checkLocation(at));
440
+ if (this.platform === 'ios')
441
+ await this.readScreenshotSize();
442
+ return this.toScreenshot((await this.perform({ kind: 'type', at: location, text, clear: options.clear !== false })));
443
+ }
444
+ /** Types into the field that already has focus, without tapping, replacing its content unless clear is false. Web only. */
445
+ async typeFocused(text, options = {}) {
446
+ if (this.platform !== 'web')
447
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'typeFocused() is available only on web sessions; on iOS and Android, use type(location, text), which taps the field first.');
448
+ if (typeof text !== 'string' || text.length > 16_384)
449
+ throw new VerifyError('INVALID_ARGUMENT', 'typeFocused() takes text of at most 16384 characters.');
450
+ await this.perform({ kind: 'type_focused', text, clear: options.clear !== false });
451
+ }
452
+ /** Double-taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point, in screenshot pixels. */
453
+ async doubleTap(at) {
454
+ const location = await this.toDevice(checkLocation(at));
455
+ if (this.platform === 'ios')
456
+ await this.readScreenshotSize();
457
+ return this.toScreenshot((await this.perform({ kind: 'double_tap', at: location })));
458
+ }
459
+ /** Presses and holds a point or element for duration milliseconds (1 to 5000, default 1500). Returns the point, in screenshot pixels. */
460
+ async longPress(at, options = {}) {
461
+ const { duration } = options;
462
+ if (duration !== undefined && (!Number.isInteger(duration) || duration < 1 || duration > 5_000))
463
+ throw new VerifyError('INVALID_ARGUMENT', 'longPress() duration is whole milliseconds from 1 to 5000.');
464
+ const location = await this.toDevice(checkLocation(at));
465
+ if (this.platform === 'ios')
466
+ await this.readScreenshotSize();
467
+ return this.toScreenshot((await this.perform({ kind: 'long_press', at: location, ...(duration !== undefined ? { durationMs: duration } : {}) })));
435
468
  }
436
469
  /** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. */
437
470
  async swipe(direction, options = {}) {
438
471
  if (!['up', 'down', 'left', 'right'].includes(direction))
439
472
  throw new VerifyError('INVALID_ARGUMENT', "swipe() takes 'up', 'down', 'left' or 'right'.");
440
- await this.perform({ kind: 'swipe', direction, ...(options.from ? { from: checkLocation(options.from) } : {}) });
473
+ await this.perform({ kind: 'swipe', direction, ...(options.from ? { from: await this.toDevice(checkLocation(options.from)) } : {}) });
474
+ }
475
+ /** Presses at one point or element, moves to another and releases. */
476
+ async drag(from, to) {
477
+ const [start, end] = [checkLocation(from), checkLocation(to)];
478
+ await this.perform({ kind: 'drag', from: await this.toDevice(start), to: await this.toDevice(end) });
441
479
  }
442
- /** Presses Enter or Backspace on the focused field. */
443
- async pressKey(key) {
444
- if (key !== 'enter' && key !== 'backspace')
445
- throw new VerifyError('INVALID_ARGUMENT', "pressKey() takes 'enter' or 'backspace'.");
446
- await this.perform({ kind: 'key', key });
480
+ /** Presses a key on the focused element, with modifiers held on Web. */
481
+ async pressKey(key, options = {}) {
482
+ const checked = checkKey(this.platform, key, options.modifiers ?? []);
483
+ await this.perform({ kind: 'key', key: checked.key, ...(checked.modifiers.length ? { modifiers: checked.modifiers } : {}) });
447
484
  }
448
485
  /** Goes back: browser history on Web, the back button on Android. Not on iOS. */
449
486
  async back() {
450
487
  await this.perform({ kind: 'back' });
451
488
  }
489
+ /** Goes to the home screen on iOS and Android. */
490
+ async goHome() {
491
+ if (this.platform === 'web')
492
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'goHome() is available only on iOS and Android sessions; on web, use openUrl() or navigate() to load a page.');
493
+ await this.perform({ kind: 'go_home' });
494
+ }
452
495
  /** Opens a URL or deep link on a device, or loads a URL in a browser. */
453
496
  async openUrl(url) {
454
497
  if (typeof url !== 'string' || url.length === 0 || url.length > 8_192)
455
498
  throw new VerifyError('INVALID_ARGUMENT', 'openUrl() takes a URL of 1 to 8192 characters.');
456
- if (this.platform === 'web' && !/^https?:/i.test(url))
457
- throw new VerifyError('INVALID_ARGUMENT', 'On web, openUrl() takes an http(s) URL; deep links such as myapp://orders/42 open only on iOS and Android.');
458
499
  await this.perform({ kind: 'open_url', url });
459
500
  }
460
501
  /** Launches an installed app by bundle ID or package name. Devices only. */
@@ -465,14 +506,69 @@ export class DeviceSession {
465
506
  throw new VerifyError('INVALID_ARGUMENT', 'launchApp() takes a bundle ID or package name.');
466
507
  await this.act('launch', { bundle_id: bundleId });
467
508
  }
468
- /** Asks Revyl's grounding model where an element is on the current screen, in device units. */
509
+ /** Asks Revyl's grounding model where an element is on the current screen, in screenshot pixels. */
469
510
  async locate(target) {
470
511
  const { x, y } = await this.resolve(checkLocation({ target }));
471
- return { x, y };
512
+ return this.toScreenshot({ x, y });
513
+ }
514
+ /** The screenshot's size in pixels, the space every coordinate uses. */
515
+ async screenSize() {
516
+ const { width, height } = await this.readScreenshotSize();
517
+ return { width, height };
518
+ }
519
+ /** The screenshot's size and the device size Revyl acts in. They differ on iOS, where Revyl acts in points and a screenshot has more pixels. */
520
+ async readScreenshotSize(fresh = false) {
521
+ if (fresh) {
522
+ const cached = [this.size, this.sizeSource, this.screenshotSize];
523
+ [this.size, this.sizeSource, this.screenshotSize] = [undefined, undefined, undefined];
524
+ try {
525
+ return await this.readScreenshotSize();
526
+ }
527
+ catch (error) {
528
+ [this.size, this.sizeSource, this.screenshotSize] = cached;
529
+ throw error;
530
+ }
531
+ }
532
+ if (this.screenshotSize)
533
+ return this.screenshotSize;
534
+ const device = await this.readScreenSize();
535
+ let size = device;
536
+ if (this.platform === 'ios') {
537
+ if (this.sizeSource !== 'health')
538
+ throw new VerifyError('ACTION_FAILED', `Revyl did not report this iOS screen's size in points, so Verify cannot map screenshot pixels to it and sent no action. See ${this.viewerUrl}`);
539
+ size = pngSize((await this.capture()).png);
540
+ }
541
+ this.screenshotSize = { width: size.width, height: size.height, device };
542
+ return this.screenshotSize;
543
+ }
544
+ async toDevice(at) {
545
+ if ('target' in at)
546
+ return at;
547
+ const read = async (fresh) => {
548
+ try {
549
+ return await this.readScreenshotSize(fresh);
550
+ }
551
+ catch (error) {
552
+ if (this.platform === 'ios' || !(error instanceof VerifyError))
553
+ throw error;
554
+ return undefined;
555
+ }
556
+ };
557
+ let size = await read(false);
558
+ if (size && (at.x >= size.width || at.y >= size.height))
559
+ size = await read(true);
560
+ if (!size)
561
+ return at;
562
+ if (at.x >= size.width || at.y >= size.height) {
563
+ throw new VerifyError('INVALID_ARGUMENT', `(${at.x}, ${at.y}) is outside the screenshot: x must be 0 to ${size.width - 1} and y 0 to ${size.height - 1}, in screenshot pixels.`);
564
+ }
565
+ return { x: Math.min(size.device.width - 1, Math.round((at.x * size.device.width) / size.width)), y: Math.min(size.device.height - 1, Math.round((at.y * size.device.height) / size.height)) };
472
566
  }
473
- /** The screen size in device units: iOS points, Android and Web pixels. */
474
- screenSize() {
475
- return this.readScreenSize();
567
+ async toScreenshot(point) {
568
+ if (this.platform !== 'ios')
569
+ return point;
570
+ const size = await this.readScreenshotSize();
571
+ return { x: Math.round((point.x * size.width) / size.device.width), y: Math.round((point.y * size.height) / size.device.height) };
476
572
  }
477
573
  async readScreenSize(signal) {
478
574
  if (this.size)
@@ -481,6 +577,7 @@ export class DeviceSession {
481
577
  const health = parseJson(await this.worker('GET', 'health', { retry: true, ...(signal ? { signal } : {}) }), 'health');
482
578
  if (Number.isInteger(health.screen_width) && Number.isInteger(health.screen_height) && health.screen_width > 0 && health.screen_height > 0) {
483
579
  this.size = { width: health.screen_width, height: health.screen_height };
580
+ this.sizeSource = 'health';
484
581
  return this.size;
485
582
  }
486
583
  }
@@ -513,11 +610,30 @@ export class DeviceSession {
513
610
  await this.act('tap', pointBody(point, action.at), signal);
514
611
  return { x: point.x, y: point.y };
515
612
  }
613
+ case 'double_tap': {
614
+ const point = await this.resolve(action.at, screenToken, signal);
615
+ await this.act('double_tap', pointBody(point, action.at), signal);
616
+ return { x: point.x, y: point.y };
617
+ }
618
+ case 'long_press': {
619
+ const point = await this.resolve(action.at, screenToken, signal);
620
+ await this.act('longpress', { ...pointBody(point, action.at), ...(action.durationMs !== undefined ? { duration_ms: action.durationMs } : {}) }, signal);
621
+ return { x: point.x, y: point.y };
622
+ }
516
623
  case 'type': {
517
624
  const point = await this.resolve(action.at, screenToken, signal);
518
625
  await this.act('input', { ...pointBody(point, action.at), text: action.text, clear_first: action.clear }, signal);
519
626
  return { x: point.x, y: point.y };
520
627
  }
628
+ case 'type_focused':
629
+ await this.act('input', { text: action.text, clear_first: action.clear }, signal);
630
+ return undefined;
631
+ case 'drag': {
632
+ const from = await this.resolve(action.from, screenToken, signal);
633
+ const to = await this.resolve(action.to, screenToken, signal);
634
+ await this.act('drag', { start_x: from.x, start_y: from.y, end_x: to.x, end_y: to.y }, signal);
635
+ return undefined;
636
+ }
521
637
  case 'swipe': {
522
638
  const point = action.from ? await this.resolve(action.from, screenToken, signal) : await this.readScreenSize(signal).then((size) => ({ x: Math.floor(size.width / 2), y: Math.floor(size.height / 2) }));
523
639
  const direction = this.platform === 'web' ? { up: 'down', down: 'up', left: 'right', right: 'left' }[action.direction] : action.direction;
@@ -525,7 +641,10 @@ export class DeviceSession {
525
641
  return undefined;
526
642
  }
527
643
  case 'key':
528
- await this.act('key', { key: this.platform === 'web' ? (action.key === 'enter' ? 'Enter' : 'Backspace') : action.key.toUpperCase() }, signal);
644
+ await this.act('key', { key: this.platform === 'web' ? (WEB_KEYS[action.key] ?? action.key) : action.key.toUpperCase(), ...(action.modifiers?.length ? { modifiers: action.modifiers } : {}) }, signal);
645
+ return undefined;
646
+ case 'go_home':
647
+ await this.act('go_home', undefined, signal);
529
648
  return undefined;
530
649
  case 'back':
531
650
  if (this.platform === 'ios')
@@ -534,6 +653,8 @@ export class DeviceSession {
534
653
  return undefined;
535
654
  case 'open_url':
536
655
  signal?.throwIfAborted();
656
+ if (this.platform === 'web' && !/^https?:/i.test(action.url))
657
+ throw new VerifyError('INVALID_ARGUMENT', 'On web, openUrl() takes an http(s) URL; deep links such as myapp://orders/42 open only on iOS and Android.');
537
658
  if (this.platform === 'web')
538
659
  await this.navigateTo(action.url, signal);
539
660
  else
@@ -765,7 +886,7 @@ function checkLocation(at) {
765
886
  return { target: at.target.trim() };
766
887
  }
767
888
  if (!at || typeof at !== 'object' || !Number.isInteger(at.x) || !Number.isInteger(at.y) || at.x < 0 || at.y < 0) {
768
- throw new VerifyError('INVALID_ARGUMENT', 'A location is { x, y } in non-negative whole device units, or { target: "description" }.');
889
+ throw new VerifyError('INVALID_ARGUMENT', 'A location is { x, y } in non-negative whole screenshot pixels, or { target: "description" }.');
769
890
  }
770
891
  return { x: at.x, y: at.y };
771
892
  }
package/docs/agents.md CHANGED
@@ -22,6 +22,39 @@ This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that
22
22
  4. Run it with `node path/to/script.ts`. Share the session link from the first `[verify]` line with the user, so they can watch.
23
23
  5. Read the outcome from the exit code and stderr. Each step prints `✓` or `✗` with its text; a failure prints `CODE: message`, ending with the session link. Look at any screenshot you saved before changing the script or the app.
24
24
 
25
+ ## Exact actions and coordinates
26
+
27
+ Use `instruction()` when the step needs judgement, and exact actions (`tap`, `doubleTap`, `longPress`, `type`, `swipe`, `drag`, `pressKey`, `locate`, `screenSize`) when you know exactly what to press. They work on Web, iOS and Android, not on computers. On Web, `typeFocused(text)` types into the field that has focus, and `pressKey` also takes keys such as `'escape'`, `'tab'` and arrows, with `modifiers`. On iOS and Android, `goHome()` goes to the home screen, `pressKey` takes only `'enter'` and `'backspace'`, and you type with `type(location, text)`. A call the platform lacks throws `NOT_AVAILABLE_ON_PLATFORM` before anything is sent, and the message names what to use instead.
28
+
29
+ Prefer a description, such as `tap({ target: 'Sign in button' })`: Revyl finds the element on the current screen, and you never handle coordinates. Otherwise a point is `{ x, y }` in screenshot pixels on every platform: whole numbers from the top-left corner of the image `screenshot()` returns. So a position you read off a screenshot goes to `tap()`, `type()`, `swipe()` and every other exact action unchanged. `screenSize()` returns the screenshot's width and height, and `locate()` and the points `tap()`, `doubleTap()`, `longPress()` and `type()` return use the same pixels. A point outside the screenshot is refused with `INVALID_ARGUMENT` before any action is sent, and the message names the valid range. On iOS, Verify converts the pixels to the screen's points for you. On Web they equal CSS pixels at the default device scale factor of 1, and you still pass screenshot pixels at any other. This holds from TypeScript 0.4.1, Python 0.2.1 and Go v0.3.1; earlier versions took iOS points.
30
+
31
+ `swipe('up')` moves the finger up, so content scrolls down, on every platform. Actions return once Revyl accepts them, so after a swipe, drag, `goHome`, `openUrl` or `launchApp`, wait for the screen to settle before you `locate` or tap by coordinates.
32
+
33
+ ```ts
34
+ import { open } from '@revyl/verify';
35
+
36
+ const session = await open('ios');
37
+ try {
38
+ await session.tap({ target: 'General' }); // first choice: describe the element
39
+ const about = await session.locate('About'); // a point in screenshot pixels
40
+ await session.tap(about); // so it goes straight to tap()
41
+
42
+ await session.screenshot({ path: '.verify/screen.png' }); // open it and find what to press
43
+ await session.tap({ x: 660, y: 1500 }); // a position read off the screenshot, unchanged
44
+ await session.swipe('up', { from: { x: 660, y: 2000 } }); // swipe from a screenshot position too
45
+ const { width, height } = await session.screenSize(); // the screenshot's size
46
+ console.log(`x is 0 to ${width - 1}, y is 0 to ${height - 1}`);
47
+
48
+ await session.longPress({ target: 'Wi-Fi row' }); // press and hold, 1.5 s by default
49
+ await session.drag({ x: 660, y: 2000 }, { x: 660, y: 800 }); // press, move, release: the list follows the finger
50
+ await session.goHome(); // the home screen, on iOS and Android
51
+ } finally {
52
+ await session.close();
53
+ }
54
+ ```
55
+
56
+ The [Python](https://github.com/RevylAI/verify/blob/v0.5.0/python/README.md#exact-actions) and [Go](https://github.com/RevylAI/verify/blob/v0.5.0/go/README.md#exact-actions) READMEs state the same rule with each language's names. [api.md](api.md#exact-actions) lists every call and its errors.
57
+
25
58
  ## Rules
26
59
 
27
60
  Mac computers are early access. Ask support@revyl.ai about access before starting a computer; do not repeat an uncertain start/stop to probe availability.
package/docs/api.md CHANGED
@@ -84,12 +84,15 @@ Web only, hosted or normal. Sends one deterministic `open_url` action and checks
84
84
 
85
85
  ## Exact actions
86
86
 
87
- **Since 0.4.0. Early access.** In TypeScript (`@revyl/verify` 0.4.0), Python (`revyl-verify` 0.2.0) and Go (v0.3.0); the [Python](https://github.com/RevylAI/verify/blob/v0.4.0/python/README.md#exact-actions) and [Go](https://github.com/RevylAI/verify/blob/v0.4.0/go/README.md#exact-actions) READMEs show them with each language's names. Web, iOS and Android sessions take device actions that need no agent.
87
+ **Early access.** Since TypeScript (`@revyl/verify`) 0.4.0, Python (`revyl-verify`) 0.2.0 and Go v0.3.0. `doubleTap`, `longPress`, `drag`, `typeFocused`, `goHome`, and Web keys and modifiers in `pressKey`, are new in TypeScript 0.5.0, Python 0.3.0 and Go v0.4.0. The [Python](https://github.com/RevylAI/verify/blob/v0.5.0/python/README.md#exact-actions) and [Go](https://github.com/RevylAI/verify/blob/v0.5.0/go/README.md#exact-actions) READMEs show them with each language's names. Web, iOS and Android sessions take device actions that need no agent.
88
88
 
89
89
  | Call | Web | iOS | Android | Mac computer |
90
90
  | --- | --- | --- | --- | --- |
91
- | `tap`, `type`, `swipe`, `pressKey`, `locate`, `screenSize` | Yes | Yes | Yes | No, use `exec()` and files |
91
+ | `tap`, `doubleTap`, `longPress`, `type`, `swipe`, `drag`, `locate`, `screenSize` | Yes | Yes | Yes | No, use `exec()` and files |
92
+ | `pressKey(key, { modifiers })` | Yes: `'enter'`, `'backspace'`, `'tab'`, `'escape'`, arrows and more, with modifiers | `'enter'` and `'backspace'` only | `'enter'` and `'backspace'` only | No |
93
+ | `typeFocused(text)` | Yes, types into the focused field | No, use `type(location, text)` | No, use `type(location, text)` | No |
92
94
  | `back()` | Yes, browser history | No, tap the on-screen back control | Yes, the system back button | No |
95
+ | `goHome()` | No, use `openUrl` or `navigate` | Yes, the home screen | Yes, the home screen | No |
93
96
  | `openUrl(url)` | Yes, loads an http(s) URL in the tab | Yes, a URL or deep link | Yes, a URL or deep link | No |
94
97
  | `launchApp(bundleId)` | No, use `openUrl` or `navigate` | Yes | Yes | No |
95
98
  | `navigate(url)` | Yes | No, use `openUrl` | No, use `openUrl` | No |
@@ -97,25 +100,40 @@ Web only, hosted or normal. Sends one deterministic `open_url` action and checks
97
100
  | `screenshot()` | Yes | Yes | Yes | Yes |
98
101
  | `exec`, files, processes | No, open a Mac computer | No, open a Mac computer | No, open a Mac computer | Yes |
99
102
 
100
- Coordinates are in iOS points, Android pixels and Web CSS pixels, from the top-left corner. A call a platform doesn't support throws `NOT_AVAILABLE_ON_PLATFORM`, and its message names the alternative.
103
+ A call a platform doesn't support throws `NOT_AVAILABLE_ON_PLATFORM`, and its message names the alternative.
101
104
 
102
- A location is `{ x, y }` in device units (iOS points, Android and Web pixels, from the top-left corner; `screenSize()` gives the bounds) or `{ target: 'Login button' }`, which Revyl's grounding model finds on the current screen.
105
+ A location is `{ x, y }` in screenshot pixels (see [Coordinates](#coordinates)) or `{ target: 'Login button' }`, which Revyl's grounding model finds on the current screen.
103
106
 
104
107
  | Call | What it does |
105
108
  | --- | --- |
106
109
  | `tap(at)` | Taps and resolves with the point tapped |
110
+ | `doubleTap(at)` | Double-taps (double-clicks on Web) and resolves with the point |
111
+ | `longPress(at, { duration })` | Presses and holds for `duration` milliseconds, 1 to 5000 (default 1500), and resolves with the point |
107
112
  | `type(at, text, { clear })` | Taps a text field and types into it, clearing it first unless `clear` is `false` |
113
+ | `typeFocused(text, { clear })` | Web only: types into the field that already has focus, without clicking, replacing its text unless `clear` is `false`. Focus the field first, for example with `tap` or `pressKey('tab')`. With no field focused nothing is typed and no error is thrown, so check the result. On iOS and Android it throws `NOT_AVAILABLE_ON_PLATFORM`; use `type(location, text)` |
108
114
  | `swipe(direction, { from })` | Swipes from a location or the screen's center; `'up'` moves the finger up, which scrolls content down |
109
- | `pressKey('enter' \| 'backspace')` | Presses a key on the focused field |
115
+ | `drag(from, to)` | Presses at one location, moves to the other and releases, for example to reorder a list, move a slider or select text |
116
+ | `pressKey(key, { modifiers })` | Presses a key on the focused element. Every platform takes `'enter'` and `'backspace'`. Web also takes `'tab'`, `'escape'`, `'delete'`, `'space'`, `'arrowup'`, `'arrowdown'`, `'arrowleft'`, `'arrowright'`, `'home'`, `'end'`, `'pageup'` and `'pagedown'`, and `modifiers`: up to four of `'ctrl'`, `'shift'`, `'alt'` and `'meta'`, held while the key is pressed. With modifiers a lowercase letter or digit works too, such as `pressKey('a', { modifiers: ['ctrl'] })`. Other keys and any modifiers throw `NOT_AVAILABLE_ON_PLATFORM` on iOS and Android |
110
117
  | `back()` | Browser history on Web, the back button on Android; iOS throws `NOT_AVAILABLE_ON_PLATFORM` |
118
+ | `goHome()` | Goes to the home screen on iOS and Android; Web throws `NOT_AVAILABLE_ON_PLATFORM` |
111
119
  | `openUrl(url)` | Opens a URL or deep link on a device, or loads a URL in a browser |
112
120
  | `launchApp(bundleId)` | Launches an installed app on a device |
113
121
  | `locate(target)` | Resolves with Revyl's best match on the current screen, without acting |
114
- | `screenSize()` | Resolves with `{ width, height }` in device units |
122
+ | `screenSize()` | Resolves with the screenshot's `{ width, height }` in pixels |
115
123
 
116
124
  A failed action throws `ACTION_FAILED`, including when Revyl cannot find a target or nothing at a point can take the action. One whose outcome Revyl can't confirm throws `ACTION_UNCERTAIN` or `TIMEOUT`, so check the session before acting again. Actions are never repeated. If Verify can't reach Revyl, the action fails with `API_ERROR` and isn't resent either, so check the session before trying it again.
117
125
 
118
- Exact actions return when Revyl has accepted them, not when the screen has finished changing. After `swipe`, `openUrl` or `launchApp`, let the screen settle or check it before you `locate` or tap coordinates. `locate` returns Revyl's best match on the current screen and can return a point for an element that is off screen, so bring the element on screen first and check the point before you use it.
126
+ Exact actions return when Revyl has accepted them, not when the screen has finished changing. After `swipe`, `drag`, `goHome`, `openUrl` or `launchApp`, let the screen settle or check it before you `locate` or tap coordinates. `locate` returns Revyl's best match on the current screen and can return a point for an element that is off screen, so bring the element on screen first. A point outside `screenSize()` is off screen, and an action at it is refused.
127
+
128
+ ### Coordinates
129
+
130
+ Prefer a description, such as `tap({ target: 'Sign in button' })`: Revyl finds the element on the current screen, and you never handle coordinates. Otherwise a point is `{ x, y }` in screenshot pixels on every platform: whole numbers from the top-left corner of the image `screenshot()` returns. So a position you read off a screenshot goes to `tap()`, `type()`, `swipe()` and every other exact action unchanged. `screenSize()` returns the screenshot's width and height, and `locate()` and the points `tap()`, `doubleTap()`, `longPress()` and `type()` return use the same pixels. A point outside the screenshot is refused with `INVALID_ARGUMENT` before any action is sent, and the message names the valid range. On iOS, Verify converts the pixels to the screen's points for you. On Web they equal CSS pixels at the default device scale factor of 1, and you still pass screenshot pixels at any other. This holds from TypeScript 0.4.1, Python 0.2.1 and Go v0.3.1; earlier versions took iOS points.
131
+
132
+ ```ts
133
+ await session.screenshot({ path: '.verify/screen.png' }); // open it and find the element
134
+ await session.tap({ x: 660, y: 1500 }); // the position as you read it, on every platform
135
+ const { width, height } = await session.screenSize(); // the screenshot's size: x is 0 to width - 1
136
+ ```
119
137
 
120
138
  ## session.report()
121
139
 
@@ -162,7 +180,7 @@ When inspecting `revyl device report --session-id <id>`, read validation verdict
162
180
 
163
181
  ## Your own model
164
182
 
165
- **Since 0.4.0. Early access.** In TypeScript (`@revyl/verify` 0.4.0), Python (`revyl-verify` 0.2.0) and Go (v0.3.0); the [Python](https://github.com/RevylAI/verify/blob/v0.4.0/python/README.md#your-own-model) and [Go](https://github.com/RevylAI/verify/blob/v0.4.0/go/README.md#your-own-model) READMEs show them with each language's names. Runs instructions and validations on a model you choose, in your process, instead of Revyl's agent. Revyl still provides the browser or device. Leave `model` out and Revyl's agent runs the step, as before.
183
+ **Early access.** Since TypeScript (`@revyl/verify`) 0.4.0, Python (`revyl-verify`) 0.2.0 and Go v0.3.0; the [Python](https://github.com/RevylAI/verify/blob/v0.5.0/python/README.md#your-own-model) and [Go](https://github.com/RevylAI/verify/blob/v0.5.0/go/README.md#your-own-model) READMEs show them with each language's names. Runs instructions and validations on a model you choose, in your process, instead of Revyl's agent. Revyl still provides the browser or device. Leave `model` out and Revyl's agent runs the step, as before.
166
184
 
167
185
  ```ts
168
186
  const session = await open('ios', { model: 'anthropic/claude-sonnet-5-5' }); // every step on your model
@@ -198,7 +216,7 @@ const session = await open('ios', { agent });
198
216
  ```
199
217
 
200
218
  - `model`: a `ModelAdapter`. `anthropic({ model, apiKey, baseURL, maxTokens, maxRetries })` uses `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL` for what you leave out. `openaiCompatible({ model, baseURL, apiKey, headers, provider, maxTokens, maxRetries })` uses `OPENAI_BASE_URL` and `OPENAI_API_KEY` only when `baseURL` is left out, so a key never reaches another provider; it then sends OpenAI's `max_completion_tokens`. To wrap another SDK or runtime, implement `{ name, complete(request, signal) }`: it gets the system prompt, text, screenshot and tools, and returns the chosen tool call and token usage.
201
- - `grounding`: `'revyl'` (default) as above. `'model'` has the model give screenshot coordinates instead, so Revyl receives only taps, typing and swipes, never element descriptions; it needs a model that points accurately.
219
+ - `grounding`: `'revyl'` (default) as above. `'model'` has the model give screenshot coordinates instead, so Revyl receives only device actions such as taps, typing and swipes, never element descriptions; it needs a model that points accurately.
202
220
  - `maxTurns` (default 20, up to 100), `modelTimeout` per model call (default 60000), `maxImageSide` for screenshots sent to the model (default 1280).
203
221
 
204
222
  ## session.screenshot(options?)
@@ -36,7 +36,7 @@ Mac targets are early access. Ask support@revyl.ai about access. See [computer a
36
36
  | `app.url` | A public HTTPS URL to open, registered as a build each time a session opens. |
37
37
  | `app.buildId` | An exact Revyl web build to open instead of a URL. |
38
38
  | `app.branch` | Branch to register the URL on, or to take the newest build from. |
39
- | `viewport` | `{ width, height, deviceScaleFactor? }` in CSS pixels |
39
+ | `viewport` | `{ width, height, deviceScaleFactor? }` in CSS pixels. Screenshots, and so exact-action coordinates, are `deviceScaleFactor` times the CSS size |
40
40
  | `signIn` | `{ path, secret, persona? }`: a test-only sign-in route. See [web.md](web.md). |
41
41
  | `startTimeout` | Milliseconds to wait for the browser. Default 600000. |
42
42
  | `idleTimeoutSeconds` | Revyl stops the session after this much inactivity. Default 600. |
package/docs/errors.md CHANGED
@@ -37,7 +37,7 @@ try {
37
37
 
38
38
  | Code | What happened | What to do |
39
39
  | --- | --- | --- |
40
- | `INVALID_ARGUMENT` | A call got a bad value: an unknown target name, an empty step, two steps at once on one session | The message names the argument |
40
+ | `INVALID_ARGUMENT` | A call got a bad value: an unknown target name, an empty step, two steps at once on one session, a point outside the screenshot | The message names the argument |
41
41
  | `CONFIG_NOT_FOUND` | `open('name')` found no `verify.config.ts` at or above the current folder | Run `npx @revyl/verify init`, pass `options.config`, or pass a target object |
42
42
  | `CONFIG_INVALID` | The config failed validation or did not load | The message names the exact option and the rule |
43
43
  | `AUTH_MISSING` | No `REVYL_API_KEY` and no saved login | Run `npx @revyl/verify login` in a terminal, or set `REVYL_API_KEY` |
@@ -48,7 +48,7 @@ try {
48
48
  | `SESSION_ENDED` | The session was closed, stopped, or expired, or Revyl answered 404 for the session itself | Close explicitly; computer expiry does not prove release |
49
49
  | `SESSION_BUSY` | A step conflict, computer operation limit, or recording requested before terminal state and confirmed release | Inspect the state before another call; a terminal computer alone does not prove release |
50
50
  | `COMPUTER_LIMIT` | Computer admission was refused due to queued work (including iOS) or the concurrency limit | Wait for capacity; Verify does not retry or stop a definitely refused start |
51
- | `NOT_AVAILABLE_ON_PLATFORM` | The call is unavailable: computer agent steps, device shell/file/recording methods, computer access or runtime unavailable, or a backend without web steps | See [web.md](web.md) and [computers.md](computers.md#availability-and-errors) |
51
+ | `NOT_AVAILABLE_ON_PLATFORM` | The call is unavailable: computer agent steps, device shell/file/recording methods, an exact action the platform lacks (such as `back()` on iOS, `goHome()` or `launchApp()` on Web, `typeFocused()` or keys other than Enter and Backspace on iOS and Android), computer access or runtime unavailable, or a backend without web steps | See [web.md](web.md) and [computers.md](computers.md#availability-and-errors) |
52
52
  | `COMMAND_FAILED` | A computer command exited nonzero | Read its reason; use `exec(argv, { check: false })` to inspect the exit code and output instead |
53
53
  | `NOT_FOUND` | A computer file or resource was not found | Check the workspace-relative path |
54
54
  | `ACCESS_DENIED` | The computer refused access | Check permissions; do not assume host/root access |
package/docs/llms.txt CHANGED
@@ -16,4 +16,10 @@ Mac computers are early access; ask support@revyl.ai about access. See computers
16
16
  - [CI and test runners](ci.md): GitHub Actions, node:test, Vitest, Jest, Playwright Test
17
17
  - [Use your own model](own-model.md): run instructions and validations on Claude, GPT, Gemini, a local model or any OpenAI-compatible endpoint; providers, limits, privacy and troubleshooting
18
18
  - [Errors](errors.md): every error code and what to do about it
19
- - [For coding agents](agents.md): the workflow and rules for agents using Verify
19
+ - [For coding agents](agents.md): the workflow and rules for agents using Verify, and the coordinate rule for exact actions
20
+
21
+ ## Exact actions and coordinates
22
+
23
+ Prefer a description, such as `tap({ target: 'Sign in button' })`: Revyl finds the element on the current screen, and you never handle coordinates. Otherwise a point is `{ x, y }` in screenshot pixels on every platform: whole numbers from the top-left corner of the image `screenshot()` returns. So a position you read off a screenshot goes to `tap()`, `type()`, `swipe()` and every other exact action unchanged. `screenSize()` returns the screenshot's width and height, and `locate()` and the points `tap()`, `doubleTap()`, `longPress()` and `type()` return use the same pixels. A point outside the screenshot is refused with `INVALID_ARGUMENT` before any action is sent, and the message names the valid range. On iOS, Verify converts the pixels to the screen's points for you. On Web they equal CSS pixels at the default device scale factor of 1, and you still pass screenshot pixels at any other. This holds from TypeScript 0.4.1, Python 0.2.1 and Go v0.3.1; earlier versions took iOS points. See [For coding agents](agents.md#exact-actions-and-coordinates) for a runnable example.
24
+
25
+ Exact actions are `tap`, `doubleTap`, `longPress`, `type`, `swipe`, `drag`, `pressKey`, `locate` and `screenSize` on Web, iOS and Android. Web adds `typeFocused(text)`, which types into the field that has focus, and `pressKey` keys such as `'escape'`, `'tab'` and arrows with `modifiers`. iOS and Android add `goHome()`; their `pressKey` takes only `'enter'` and `'backspace'`, and you type with `type(location, text)`. A call the platform lacks throws `NOT_AVAILABLE_ON_PLATFORM` before anything is sent, naming what to use instead. See [api.md](api.md#exact-actions) for every call.