@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/CHANGELOG.md +50 -0
- package/README.md +22 -15
- package/dist/agent/loop.d.ts +44 -2
- package/dist/agent/loop.js +118 -20
- package/dist/computer-session.d.ts +5 -0
- package/dist/computer-session.js +15 -0
- package/dist/index.d.ts +1 -1
- package/dist/session.d.ts +49 -11
- package/dist/session.js +140 -19
- package/docs/agents.md +33 -0
- package/docs/api.md +27 -9
- package/docs/configuration.md +1 -1
- package/docs/errors.md +2 -2
- package/docs/llms.txt +7 -1
- package/docs/mobile.md +34 -7
- package/docs/own-model.md +4 -4
- package/docs/web.md +38 -6
- package/package.json +1 -1
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
|
|
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
|
|
117
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
209
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
443
|
-
async pressKey(key) {
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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`, `
|
|
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
|
-
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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?)
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|