@revyl/verify 0.5.0 → 0.6.1

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.
@@ -6,13 +6,26 @@ import { edgeBlockedError, responseDetail, responseErrorCode, responseRefusalRea
6
6
  import { trackSession, untrackSession } from './cleanup.js';
7
7
  import { validateHostedTarget } from './config.js';
8
8
  import { VerifyError, isVerifyError } from './errors.js';
9
- import { DeviceSession, pngSize } from './session.js';
9
+ import { WEB_KEYS, checkKey } from './agent/loop.js';
10
+ import { DeviceSession, checkLocation, pngSize } from './session.js';
10
11
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
12
  const STATES = new Set(['starting', 'active', 'closing', 'stopped', 'failed', 'owner_lost']);
12
13
  const MAX_OUTPUT_BYTES = 1_048_576;
13
- const COMPUTER_AVAILABILITY = 'Computers are early access. Ask support@revyl.ai about access.';
14
+ const COMPUTER_AVAILABILITY = 'Mac computers need a current paid Revyl plan, and Linux computers are not generally available. Ask support@revyl.ai about access.';
14
15
  const COMPUTER_CAPACITY = 'Computer admission was refused because of queued work (including iOS) or the concurrency limit. Verify will not retry the start.';
15
- const ERROR_CODES = new Set(['computer_not_enabled', 'run_closed', 'inaccessible', 'invalid_handle', 'operation_not_found', 'owner_unavailable', 'invalid_request', 'unauthenticated', 'forbidden', 'runtime_unavailable', 'start_failed', 'computer_limit', 'request_conflict', 'run_conflict', 'evidence_conflict', 'evidence_unavailable', 'authentication_unavailable', 'operation_limit', 'screen_starting', 'screen_unavailable']);
16
+ const ERROR_CODES = new Set(['computer_not_enabled', 'run_closed', 'inaccessible', 'invalid_handle', 'operation_not_found', 'owner_unavailable', 'invalid_request', 'unauthenticated', 'forbidden', 'runtime_unavailable', 'start_failed', 'computer_limit', 'request_conflict', 'run_conflict', 'evidence_conflict', 'evidence_unavailable', 'authentication_unavailable', 'operation_limit', 'screen_starting', 'screen_unavailable', 'screen_control_held', 'screen_control_lost', 'screen_input_unavailable', 'screen_input_uncertain', 'screen_key_unsupported', 'screen_text_unsupported', 'screen_target_not_found', 'screen_target_unavailable']);
17
+ const COMPUTER_KEYS = { enter: 'Return', backspace: 'BackSpace', tab: 'Tab', escape: 'Escape', delete: 'Delete', space: 'space', arrowup: 'Up', arrowdown: 'Down', arrowleft: 'Left', arrowright: 'Right', home: 'Home', end: 'End', pageup: 'Page_Up', pagedown: 'Page_Down' };
18
+ const REFUSAL_NEXT_STEPS = {
19
+ AUTH_REJECTED: "Check that REVYL_API_KEY is a current key and that its owner has the Member role or above in this computer's organization.",
20
+ SESSION_ENDED: 'This computer has stopped; start a new computer to continue.',
21
+ NOT_FOUND: 'Check that the id is right and that REVYL_API_KEY belongs to the organization that started this computer.',
22
+ ACTION_UNCERTAIN: "The request may have taken effect. Verify will not repeat it; check the computer's state before you do.",
23
+ INVALID_ARGUMENT: "Check the call's arguments against the docs; if they match, update Verify to the latest version.",
24
+ SESSION_START_FAILED: 'Start a new computer.',
25
+ SESSION_BUSY: 'The computer is busy with other work; try again once it finishes.',
26
+ API_ERROR: 'Try again; if it keeps failing, send this message to support@revyl.ai.',
27
+ };
28
+ const SCREEN_CALLS = 'For the screen, use screenshot(), tap({ x, y }) or tap({ target }), typeFocused(text, { clear: false }) and pressKey(); for commands, use exec().';
16
29
  export class ComputerSession {
17
30
  client;
18
31
  options;
@@ -123,14 +136,89 @@ export class ComputerSession {
123
136
  navigate() {
124
137
  return this.unavailable('navigate');
125
138
  }
126
- tap() {
127
- return this.unavailable('tap');
139
+ /** Clicks a point in screenshot pixels, or an element Revyl finds from a description, with the left button. Returns the point clicked. */
140
+ async tap(at) {
141
+ if (at && typeof at === 'object' && 'target' in at && ('x' in at || 'y' in at))
142
+ throw new VerifyError('INVALID_ARGUMENT', 'tap() takes a point { x, y } or a description { target }, not both.');
143
+ const location = checkLocation(at);
144
+ if ('target' in location)
145
+ return this.tapTarget(location.target);
146
+ const { x, y } = location;
147
+ let response;
148
+ try {
149
+ response = await this.act({ action: 'click', x, y });
150
+ }
151
+ catch (error) {
152
+ if (isVerifyError(error) && error.code === 'INVALID_ARGUMENT' && error.httpStatus === 422)
153
+ throw new VerifyError('INVALID_ARGUMENT', `Nothing was clicked: the computer refused a click at (${x}, ${y}). That happens when the point is no longer on its screen, for example after its display size changed. Take a new screenshot() and pick the point from it. See ${this.viewerUrl}`, { cause: error, reason: error.reason, httpStatus: 422 });
154
+ throw error;
155
+ }
156
+ if (response.status === 422) {
157
+ const size = await this.screenSize().catch(() => undefined);
158
+ const range = size ? `x is 0 to ${size.width - 1} and y is 0 to ${size.height - 1}, in pixels of the image screenshot() returns.` : 'Use a point inside the image screenshot() returns.';
159
+ throw new VerifyError('INVALID_ARGUMENT', `Nothing was clicked: (${x}, ${y}) is outside the screen. ${range}`);
160
+ }
161
+ return { x, y };
162
+ }
163
+ async tapTarget(target) {
164
+ let response;
165
+ try {
166
+ response = await this.act({ action: 'click', target }, 90_000);
167
+ }
168
+ catch (error) {
169
+ if (isVerifyError(error) && error.code === 'INVALID_ARGUMENT' && error.httpStatus === 422)
170
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Nothing was clicked: this Revyl backend can't click a described target on a computer yet (invalid_request). Read the point off screenshot() and pass it to tap({ x, y }). See ${this.viewerUrl}`, { cause: error, httpStatus: 422 });
171
+ throw error;
172
+ }
173
+ let point;
174
+ try {
175
+ point = JSON.parse(new TextDecoder().decode(response.body))?.point;
176
+ }
177
+ catch { }
178
+ const { x, y } = point ?? {};
179
+ if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0)
180
+ throw new VerifyError('ACTION_UNCERTAIN', `Revyl answered the described click without the point it clicked, so where it landed is unknown. Take a screenshot() to check. See ${this.viewerUrl}`);
181
+ return { x: x, y: y };
128
182
  }
129
183
  type() {
130
184
  return this.unavailable('type');
131
185
  }
132
- typeFocused() {
133
- return this.unavailable('typeFocused');
186
+ /** Types into whatever has keyboard focus. Computers can't clear a field, so pass { clear: false }. */
187
+ async typeFocused(text, options = {}) {
188
+ if (options.clear !== false)
189
+ throw new VerifyError('INVALID_ARGUMENT', "On a computer, typeFocused() can't clear the field first, so pass { clear: false } to type at the cursor: typeFocused(text, { clear: false }). To replace text, select it first, for example with pressKey('a', { modifiers: ['meta'] }) (Command+A on a Mac computer).");
190
+ if (typeof text !== 'string' || text.length > 16_384)
191
+ throw new VerifyError('INVALID_ARGUMENT', 'typeFocused() takes text of at most 16384 characters.');
192
+ const control = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.exec(text);
193
+ if (control)
194
+ throw new VerifyError('INVALID_ARGUMENT', `Nothing was typed: typeFocused() on a computer takes newline and tab but no other control characters, and the text has one at index ${control.index}. Use pressKey() for keys such as 'escape'.`);
195
+ const chunks = [];
196
+ let chunk = '';
197
+ for (const character of text) {
198
+ if ([...chunk].length === 500 || Buffer.byteLength(chunk + character) > 1_500) {
199
+ chunks.push(chunk);
200
+ chunk = '';
201
+ }
202
+ chunk += character;
203
+ }
204
+ if (chunk)
205
+ chunks.push(chunk);
206
+ const total = [...text].length;
207
+ let typed = 0;
208
+ for (const part of chunks) {
209
+ const length = [...part].length;
210
+ try {
211
+ await this.act({ action: 'type', text: part });
212
+ }
213
+ catch (error) {
214
+ if (chunks.length === 1 || !isVerifyError(error))
215
+ throw error;
216
+ const uncertain = error.code === 'ACTION_UNCERTAIN';
217
+ const done = typed === 0 ? `typed nothing${uncertain ? ' for certain' : ''}: the first` : `typed the first ${typed} of ${total} characters; the next`;
218
+ throw new VerifyError(error.code, `typeFocused() ${done} ${length}${typed === 0 ? ` of ${total}` : ''} characters ${uncertain ? 'may have been typed' : 'were not typed'}, and nothing after them was. ${error.message}`, { cause: error, reason: error.reason, httpStatus: error.httpStatus });
219
+ }
220
+ typed += length;
221
+ }
134
222
  }
135
223
  doubleTap() {
136
224
  return this.unavailable('doubleTap');
@@ -144,8 +232,12 @@ export class ComputerSession {
144
232
  drag() {
145
233
  return this.unavailable('drag');
146
234
  }
147
- pressKey() {
148
- return this.unavailable('pressKey');
235
+ /** Presses a key with modifiers held. 'meta' is the Command key on a Mac computer, and 'alt' is Option. */
236
+ async pressKey(key, options = {}) {
237
+ const checked = checkKey('web', key, options.modifiers ?? []);
238
+ const name = checked.key in WEB_KEYS ? COMPUTER_KEYS[checked.key] : checked.key;
239
+ const modifiers = [...new Set(checked.modifiers)].map((modifier) => (modifier === 'meta' ? 'cmd' : modifier));
240
+ await this.act({ action: 'key', keys: [...modifiers, name].join('+') });
149
241
  }
150
242
  back() {
151
243
  return this.unavailable('back');
@@ -160,10 +252,12 @@ export class ComputerSession {
160
252
  return this.unavailable('launchApp');
161
253
  }
162
254
  locate() {
163
- return this.unavailable('locate');
255
+ return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', "locate() isn't available on computers, because Revyl can't find a point there without clicking it. tap({ target }) clicks a description and returns the point it clicked; to choose the point yourself, read it off screenshot() and pass it to tap({ x, y })."));
164
256
  }
165
- screenSize() {
166
- return this.unavailable('screenSize');
257
+ /** The screenshot's size in pixels, the space tap() uses. */
258
+ async screenSize() {
259
+ const { width, height } = pngSize(await this.screenshot());
260
+ return { width, height };
167
261
  }
168
262
  get expiresAt() {
169
263
  return this.expiresAtMs === undefined ? undefined : new Date(this.expiresAtMs);
@@ -406,13 +500,15 @@ export class ComputerSession {
406
500
  throw error;
407
501
  }
408
502
  }
409
- async request(method, suffix, body, signal) {
503
+ async request(method, suffix, body, signal, timeoutMs) {
410
504
  const path = `/api/v1/computer-runs${suffix === '' ? '' : `/${this.id}${suffix}`}`;
411
505
  let response;
412
506
  try {
413
- response = await this.client.send(method, path, { body, signal, timeoutMs: suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000, retry: method === 'GET' && suffix !== '/screen/screenshot', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
507
+ response = await this.client.send(method, path, { body, signal, timeoutMs: timeoutMs ?? (suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000), retry: method === 'GET' && suffix !== '/screen/screenshot', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
414
508
  }
415
509
  catch (error) {
510
+ if (suffix === '/screen/actions')
511
+ throw new VerifyError('ACTION_UNCERTAIN', `The screen action may have happened: Revyl's answer never arrived. Verify will not repeat it; take a screenshot() to check before you do. See ${this.viewerUrl}`);
416
512
  if (method === 'POST' || suffix.startsWith('/operations/'))
417
513
  throw new VerifyError('ACTION_UNCERTAIN', `Computer request outcome is unconfirmed. Verify will not repeat it. See ${this.viewerUrl}`);
418
514
  if (isVerifyError(error))
@@ -428,6 +524,7 @@ export class ComputerSession {
428
524
  return response;
429
525
  const detail = responseRefusalReason(response) ?? responseDetail(response);
430
526
  const refusal = { reason: responseRefusalReason(response), httpStatus: response.status };
527
+ const reason = detail && ERROR_CODES.has(detail) ? detail : `HTTP ${response.status}`;
431
528
  if (suffix === '/screen/screenshot') {
432
529
  let raw = null;
433
530
  try {
@@ -443,6 +540,30 @@ export class ComputerSession {
443
540
  }
444
541
  if (response.status === 429 && detail === 'computer_limit')
445
542
  throw new VerifyError('COMPUTER_LIMIT', `${COMPUTER_CAPACITY} See ${this.viewerUrl}`, refusal);
543
+ if (suffix === '/screen/actions') {
544
+ if (response.status === 422 && detail === 'screen_point_outside')
545
+ return response;
546
+ if (response.status === 409 && detail === 'screen_starting')
547
+ return response;
548
+ if (detail === 'screen_target_not_found')
549
+ throw new VerifyError('ACTION_FAILED', `Nothing was clicked: Revyl could not find the described target on the screen (screen_target_not_found). Take a screenshot() and pass a point to tap({ x, y }). See ${this.viewerUrl}`, refusal);
550
+ if (detail === 'screen_target_unavailable')
551
+ throw new VerifyError('API_ERROR', `Nothing was clicked: Revyl could not look up the described target just now (screen_target_unavailable). It is safe to try again. See ${this.viewerUrl}`, refusal);
552
+ if (detail === 'screen_control_held')
553
+ throw new VerifyError('SESSION_BUSY', `Nothing happened: someone else is controlling this computer's screen in the viewer (screen_control_held). Try again after they release it; control frees itself after 2 minutes without input. See ${this.viewerUrl}`, refusal);
554
+ if (detail === 'screen_input_uncertain' || detail === 'screen_unavailable' || detail === 'screen_input_unavailable')
555
+ throw new VerifyError('ACTION_UNCERTAIN', `The screen action may have happened (${detail}). Verify will not repeat it; take a screenshot() to check before you do. See ${this.viewerUrl}`, refusal);
556
+ if (response.status === 403)
557
+ throw new VerifyError('AUTH_REJECTED', `Revyl refused the screen action (forbidden). The API key's owner needs the Member role or above in the organization, and CI runner keys can't control the screen yet. See ${this.viewerUrl}`, refusal);
558
+ if (response.status === 503 && detail === 'authentication_unavailable')
559
+ throw new VerifyError('API_ERROR', `Nothing happened: Revyl could not confirm your access just now (authentication_unavailable). It is safe to try again. See ${this.viewerUrl}`, refusal);
560
+ if (detail === 'screen_text_unsupported')
561
+ throw new VerifyError('INVALID_ARGUMENT', `Nothing was typed: this computer can't type some of these characters (screen_text_unsupported). See ${this.viewerUrl}`, refusal);
562
+ if (detail === 'screen_control_lost')
563
+ throw new VerifyError('SESSION_BUSY', `Nothing happened: control of this computer's screen changed hands before the action was sent (screen_control_lost). Try again; if someone is controlling the screen in the viewer, wait until they release it, which happens on its own after 2 minutes without input. See ${this.viewerUrl}`, refusal);
564
+ if (detail === 'screen_key_unsupported')
565
+ throw new VerifyError('INVALID_ARGUMENT', `Nothing was pressed: this computer can't press that key (screen_key_unsupported). Use a key pressKey() lists in the docs, or typeFocused() for a printable character. See ${this.viewerUrl}`, refusal);
566
+ }
446
567
  let code = 'API_ERROR';
447
568
  if (response.status === 401 || response.status === 403)
448
569
  code = 'AUTH_REJECTED';
@@ -462,8 +583,38 @@ export class ComputerSession {
462
583
  code = 'ACTION_UNCERTAIN';
463
584
  else if (method === 'POST' && (response.status >= 500 || response.status < 400))
464
585
  code = 'ACTION_UNCERTAIN';
465
- const reason = detail && ERROR_CODES.has(detail) ? detail : `HTTP ${response.status}`;
466
- throw new VerifyError(code, `Revyl refused the computer request (${reason}). See ${this.viewerUrl}`, refusal);
586
+ let next = REFUSAL_NEXT_STEPS[code];
587
+ if (suffix === '/screen/actions' && code === 'ACTION_UNCERTAIN')
588
+ next = 'The screen action may have happened. Verify will not repeat it; take a screenshot() to check before you do.';
589
+ else if (suffix === '/screen/actions' && code === 'INVALID_ARGUMENT')
590
+ next = 'Part of the screen action may have run, so take a screenshot() to check the screen before you try again.';
591
+ else if (code === 'ACTION_UNCERTAIN' && method === 'GET' && !suffix.startsWith('/operations/'))
592
+ next = 'Nothing on the computer changed; it is safe to try again.';
593
+ throw new VerifyError(code, `Revyl refused the computer request (${reason}). ${next} See ${this.viewerUrl}`, refusal);
594
+ }
595
+ async act(action, timeoutMs = 30_000) {
596
+ this.assertOpen();
597
+ const signal = this.observationSignal(timeoutMs);
598
+ const deadlineMs = Date.now() + 30_000;
599
+ for (;;) {
600
+ const response = await this.request('POST', '/screen/actions', { action, screenshot: false }, signal, timeoutMs);
601
+ if (response.status !== 409)
602
+ return response;
603
+ const retryAfter = Number(response.headers.get('retry-after'));
604
+ const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 60) * 1_000 : 1_000;
605
+ const seconds = Math.ceil(waitMs / 1_000);
606
+ const starting = new VerifyError('SESSION_BUSY', `Nothing happened: the computer's screen is still starting (screen_starting). Try again in ${seconds} second${seconds === 1 ? '' : 's'}. See ${this.viewerUrl}`, { httpStatus: 409 });
607
+ if (Date.now() + waitMs >= deadlineMs)
608
+ throw starting;
609
+ try {
610
+ await delay(waitMs, undefined, { signal });
611
+ }
612
+ catch {
613
+ this.assertOpen();
614
+ throw starting;
615
+ }
616
+ this.assertOpen();
617
+ }
467
618
  }
468
619
  parseReport(response) {
469
620
  const report = parseComputerJson(response);
@@ -482,7 +633,7 @@ export class ComputerSession {
482
633
  return AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(Math.max(0, remainingMs))]);
483
634
  }
484
635
  unavailable(method) {
485
- return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on computers; use exec() and file operations. See ${this.viewerUrl}`));
636
+ return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on computers. ${SCREEN_CALLS} See ${this.viewerUrl}`));
486
637
  }
487
638
  log(line) {
488
639
  this.options.log?.(`${this.target}: ${line}`);
package/dist/session.d.ts CHANGED
@@ -103,13 +103,13 @@ 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 screenshot pixels, or an element Revyl locates from a description. Returns the point tapped, in screenshot pixels. 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. */
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). */
112
+ /** Types into the field that already has focus, without tapping, replacing its content unless clear is false. Web and Mac computers, where clear must be false; on iOS and Android, use type(location, text). */
113
113
  typeFocused(text: string, options?: {
114
114
  clear?: boolean;
115
115
  }): Promise<void>;
@@ -125,7 +125,7 @@ export interface OpenedSession extends Session {
125
125
  }): Promise<void>;
126
126
  /** Presses at one point or element, moves to another and releases. Browsers and devices only. */
127
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. */
128
+ /** Presses a key on the focused element. iOS and Android take 'enter' and 'backspace'; Web and Mac computers also take the other Key names, and modifiers, with which a lowercase letter or digit also works. On a Mac computer 'meta' is Command. */
129
129
  pressKey(key: Key | string, options?: {
130
130
  modifiers?: KeyModifier[];
131
131
  }): Promise<void>;
@@ -139,7 +139,7 @@ export interface OpenedSession extends Session {
139
139
  launchApp(bundleId: string): Promise<void>;
140
140
  /** Asks Revyl's grounding model where an element is on the current screen, in screenshot pixels. Browsers and devices only. */
141
141
  locate(target: string): Promise<Point>;
142
- /** The screenshot's size in pixels, the space every coordinate uses. Browsers and devices only. */
142
+ /** The screenshot's size in pixels, the space every coordinate uses. */
143
143
  screenSize(): Promise<{
144
144
  width: number;
145
145
  height: number;
@@ -277,3 +277,4 @@ export declare function pngSize(bytes: Uint8Array): {
277
277
  width: number;
278
278
  height: number;
279
279
  } | undefined;
280
+ export declare function checkLocation(at: Location): Location;
package/dist/session.js CHANGED
@@ -879,7 +879,7 @@ export function pngSize(bytes) {
879
879
  const height = view.getUint32(20);
880
880
  return width > 0 && height > 0 ? { width, height } : undefined;
881
881
  }
882
- function checkLocation(at) {
882
+ export function checkLocation(at) {
883
883
  if (at && typeof at === 'object' && 'target' in at) {
884
884
  if (typeof at.target !== 'string' || at.target.trim() === '' || at.target.length > 500)
885
885
  throw new VerifyError('INVALID_ARGUMENT', 'A target is a description of 1 to 500 characters.');
package/docs/README.md CHANGED
@@ -10,7 +10,7 @@ These files ship inside the npm package at `node_modules/@revyl/verify/docs/`, s
10
10
  | [configuration.md](configuration.md) | Look up every option in `verify.config.ts`, the environment variables, and the CLI |
11
11
  | [web.md](web.md) | Open a web app in a Revyl cloud browser, and act on it with exact actions |
12
12
  | [mobile.md](mobile.md) | Open an iOS or Android app on a Revyl cloud device, and act on it with exact actions |
13
- | [computers.md](computers.md) | Open a Mac computer for commands, files, processes, screenshots, and optional recording metadata |
13
+ | [computers.md](computers.md) | Open a Mac computer for commands, files, processes, screenshots, clicks and keys on its screen, and optional recording metadata |
14
14
  | [ci.md](ci.md) | Call Verify from CI or from a test runner you already use |
15
15
  | [own-model.md](own-model.md) | Run instructions and validations on your own model, such as Claude, GPT or a local model |
16
16
  | [errors.md](errors.md) | Understand an error code and fix it, or troubleshoot a common problem |
package/docs/agents.md CHANGED
@@ -19,14 +19,14 @@ This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that
19
19
  await session.close();
20
20
  }
21
21
  ```
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.
22
+ 4. Name it `.mts` and run it with `node path/to/script.mts`; Node runs a `.mts` file as an ES module whatever `package.json` says, while a `.ts` file fails to import in a CommonJS project. 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
25
  ## Exact actions and coordinates
26
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.
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. On a Mac computer's screen, `tap({ x, y })`, `tap({ target })`, `screenSize()`, `typeFocused(text, { clear: false })` and `pressKey` work; see [computers.md](computers.md#screen). On Web and Mac computers, `typeFocused(text)` types into the field that has focus, and `pressKey` also takes keys such as `'escape'`, `'tab'` and arrows, with `modifiers`. Select all with `pressKey('a', { modifiers: ['meta'] })` on a Mac computer, where `'meta'` is Command, and with `pressKey('a', { modifiers: ['ctrl'] })` on Web, where `'meta'` is not the shortcut key. 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
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.
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. On a Mac computer a point is in screenshot pixels too, and `tap({ target })` returns the point it clicked in the same pixels. Verify refuses a negative or fractional point before sending it, and Revyl refuses one past the screen's edge without clicking; both throw `INVALID_ARGUMENT` naming the valid range.
30
30
 
31
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
32
 
@@ -53,13 +53,13 @@ try {
53
53
  }
54
54
  ```
55
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.
56
+ The [Python](https://github.com/RevylAI/verify/blob/v0.6.1/python/README.md#exact-actions) and [Go](https://github.com/RevylAI/verify/blob/v0.6.1/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
57
 
58
58
  ## Rules
59
59
 
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.
60
+ Mac computers are available to organizations on a current paid Revyl plan. Ask before starting a computer, as for a device; do not repeat an uncertain start/stop to probe availability.
61
61
 
62
- For computer work, use structured `exec(['program', 'arg'])`, `readFile`, `writeFile`, `stat`, and run-owned handles from `processes()`/`terminate(id)`. Check command output with ordinary assertions and close in `finally`; screenshots are supported, but instruction/validation are not. Do not fall back to agent steps on computers or shell/file methods on mobile/web. See [computers.md](computers.md) for limits. Starting a computer is a paid external action and needs the same approval as starting a device.
62
+ For computer work, use structured `exec(['program', 'arg'])`, `readFile`, `writeFile`, `stat`, and run-owned handles from `processes()`/`terminate(id)`. Check command output with ordinary assertions and close in `finally`; screenshots are supported, but instruction/validation are not. To use the screen, read a point off `screenshot()` and `tap({ x, y })` it, or describe it with `tap({ target })`, and type with `typeFocused(text, { clear: false })`. A described click takes several seconds, because Revyl takes a screenshot and finds the element first. It can pick a look-alike, and when the target isn't on the screen it can still click something else instead of failing. So check the point it returns or take a screenshot afterwards, and for anything that's hard to undo, read the point off `screenshot()` and use `tap({ x, y })`. After `ACTION_UNCERTAIN`, take a screenshot before you act again. Do not fall back to agent steps on computers or shell/file methods on mobile/web. See [computers.md](computers.md) for limits. Starting a computer is a paid external action and needs the same approval as starting a device.
63
63
 
64
64
  Computers have a hard lifetime from create (two hours today), not from readiness or activity; `expiresAt` is the authoritative deadline. `expiresAt` is optional on older APIs; expiry does not prevent close. After terminal state and confirmed release, `recording()` can return private signed metadata or null, including after close. Never log or share its signed URL/query. Do not automatically wait for recordings or retry `COMPUTER_LIMIT` starts: queued work (including iOS) or the concurrency limit may be responsible.
65
65
 
package/docs/api.md CHANGED
@@ -8,7 +8,7 @@ import { attach, open, defineConfig, VerifyError, isVerifyError } from '@revyl/v
8
8
 
9
9
  Starts a Revyl cloud browser, device, or Mac computer. Mobile first-screen readiness is [best-effort](mobile.md#steps), not a foreground/rendered-readiness guarantee. Computer starts retain a 10-minute default timeout; an unclaimed start may fail after 120 seconds with no-host context.
10
10
 
11
- Mac computers are early access. Ask support@revyl.ai about access.
11
+ Mac computers are available to organizations on a current paid Revyl plan.
12
12
 
13
13
  ```ts
14
14
  const web = await open('web'); // a target named in verify.config.ts
@@ -63,20 +63,20 @@ Starts a child app session inside this computer, returning the normal session AP
63
63
  Mac supports Web and iOS children. The server remains authoritative for access, capability and capacity. Children require and share the parent's known deadline. Closing a child leaves the parent running; closing the parent cancels pending starts and gives child cleanup at most five seconds total before requesting confirmed computer release. The parent stop also stops any remaining children, so a slow child cannot prevent that request. A failed child start never closes the parent.
64
64
 
65
65
  ```ts
66
- const computer = await attach(existingComputerId, { log: false });
66
+ const computer = await attach(existingComputerId, { stopOnClose: false, log: false }); // not yours, so close() leaves it running
67
67
  try {
68
68
  const web = await computer.open({
69
69
  platform: 'web',
70
70
  app: { appId: webAppId, buildId: externalBuildId },
71
71
  });
72
- await web.navigate('http://localhost:3000');
73
- await web.validation('The home page is visible');
72
+ await web.navigate('https://www.saucedemo.com');
73
+ await web.validation('The Swag Labs login form is shown');
74
74
  } finally {
75
75
  await computer.close();
76
76
  }
77
77
  ```
78
78
 
79
- This requires an already-running service on the computer. The initial Web build still uses HTTPS; navigation after start can reach the computer's localhost. No localhost-start option is implied.
79
+ The initial Web build uses HTTPS. After start, `navigate()` can also reach `http://localhost:<port>` on the computer, but only when a service is already listening there, such as one an earlier step of a `revyl-macos` job started. Verify has no call that starts one, and a process started through `exec()` ends when the command returns. Leave out `stopOnClose: false` only for a computer your script owns: then `close()` stops it.
80
80
 
81
81
  ## session.navigate(url)
82
82
 
@@ -84,19 +84,21 @@ Web only, hosted or normal. Sends one deterministic `open_url` action and checks
84
84
 
85
85
  ## Exact actions
86
86
 
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.
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. On a Mac computer's screen, `tap` (by point or by description), `screenSize`, `typeFocused` and `pressKey` are new in TypeScript 0.6.0, Python 0.4.0 and Go v0.5.0. The [Python](https://github.com/RevylAI/verify/blob/v0.6.1/python/README.md#exact-actions) and [Go](https://github.com/RevylAI/verify/blob/v0.6.1/go/README.md#exact-actions) READMEs show them with each language's names. Web, iOS and Android sessions, and a Mac computer's screen, take device actions that need no agent.
88
88
 
89
89
  | Call | Web | iOS | Android | Mac computer |
90
90
  | --- | --- | --- | --- | --- |
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 |
91
+ | `tap({ x, y })`, `tap({ target })`, `screenSize` | Yes | Yes | Yes | Yes |
92
+ | `locate` | Yes | Yes | Yes | No, use `tap({ target })`, which returns the point it clicked, or read the point off `screenshot()` |
93
+ | `doubleTap`, `longPress`, `type`, `swipe`, `drag` | Yes | Yes | Yes | No |
94
+ | `pressKey(key, { modifiers })` | Yes: `'enter'`, `'backspace'`, `'tab'`, `'escape'`, arrows and more, with modifiers | `'enter'` and `'backspace'` only | `'enter'` and `'backspace'` only | Yes: the Web keys and modifiers, where `'meta'` is Command |
95
+ | `typeFocused(text, { clear })` | Yes, types into the focused field | No, use `type(location, text)` | No, use `type(location, text)` | Yes, with `{ clear: false }` |
94
96
  | `back()` | Yes, browser history | No, tap the on-screen back control | Yes, the system back button | No |
95
97
  | `goHome()` | No, use `openUrl` or `navigate` | Yes, the home screen | Yes, the home screen | No |
96
98
  | `openUrl(url)` | Yes, loads an http(s) URL in the tab | Yes, a URL or deep link | Yes, a URL or deep link | No |
97
99
  | `launchApp(bundleId)` | No, use `openUrl` or `navigate` | Yes | Yes | No |
98
100
  | `navigate(url)` | Yes | No, use `openUrl` | No, use `openUrl` | No |
99
- | `instruction`, `validation`, your own model | Yes | Yes | Yes | No, use `exec()` |
101
+ | `instruction`, `validation`, your own model | Yes | Yes | Yes | No, use `exec()` and the screen calls above |
100
102
  | `screenshot()` | Yes | Yes | Yes | Yes |
101
103
  | `exec`, files, processes | No, open a Mac computer | No, open a Mac computer | No, open a Mac computer | Yes |
102
104
 
@@ -106,14 +108,14 @@ A location is `{ x, y }` in screenshot pixels (see [Coordinates](#coordinates))
106
108
 
107
109
  | Call | What it does |
108
110
  | --- | --- |
109
- | `tap(at)` | Taps and resolves with the point tapped |
111
+ | `tap(at)` | Taps (clicks on Web and Mac computers) and resolves with the point tapped. On a Mac computer a described tap takes several seconds, can click a look-alike, and can click something else when the target isn't on the screen, so check the point or a screenshot afterwards; see [computers.md](computers.md#screen) |
110
112
  | `doubleTap(at)` | Double-taps (double-clicks on Web) and resolves with the point |
111
113
  | `longPress(at, { duration })` | Presses and holds for `duration` milliseconds, 1 to 5000 (default 1500), and resolves with the point |
112
114
  | `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)` |
115
+ | `typeFocused(text, { clear })` | Web and Mac computers: types into the field that already has focus, without clicking. 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 Web it replaces the field's text unless `clear` is `false`. On a Mac computer pass `{ clear: false }`: it types where the cursor is, and leaving `clear` out throws `INVALID_ARGUMENT`. To replace text there, select it first with `pressKey('a', { modifiers: ['meta'] })`. On iOS and Android it throws `NOT_AVAILABLE_ON_PLATFORM`; use `type(location, text)` |
114
116
  | `swipe(direction, { from })` | Swipes from a location or the screen's center; `'up'` moves the finger up, which scrolls content down |
115
117
  | `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 |
118
+ | `pressKey(key, { modifiers })` | Presses a key on the focused element. Every platform takes `'enter'` and `'backspace'`. Web and Mac computers also take `'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. The shortcut key differs: select all with `pressKey('a', { modifiers: ['meta'] })` on a Mac computer, where `'meta'` is Command and `'alt'` is Option, and with `pressKey('a', { modifiers: ['ctrl'] })` on Web, where `'meta'` is not the shortcut key. Other keys and any modifiers throw `NOT_AVAILABLE_ON_PLATFORM` on iOS and Android |
117
119
  | `back()` | Browser history on Web, the back button on Android; iOS throws `NOT_AVAILABLE_ON_PLATFORM` |
118
120
  | `goHome()` | Goes to the home screen on iOS and Android; Web throws `NOT_AVAILABLE_ON_PLATFORM` |
119
121
  | `openUrl(url)` | Opens a URL or deep link on a device, or loads a URL in a browser |
@@ -121,13 +123,13 @@ A location is `{ x, y }` in screenshot pixels (see [Coordinates](#coordinates))
121
123
  | `locate(target)` | Resolves with Revyl's best match on the current screen, without acting |
122
124
  | `screenSize()` | Resolves with the screenshot's `{ width, height }` in pixels |
123
125
 
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.
126
+ 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. A Mac computer's screen has a few errors of its own, listed in [computers.md](computers.md#screen).
125
127
 
126
128
  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
129
 
128
130
  ### Coordinates
129
131
 
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.
132
+ 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. On a Mac computer a point is in screenshot pixels too, and `tap({ target })` returns the point it clicked in the same pixels. Verify refuses a negative or fractional point before sending it, and Revyl refuses one past the screen's edge without clicking; both throw `INVALID_ARGUMENT` naming the valid range.
131
133
 
132
134
  ```ts
133
135
  await session.screenshot({ path: '.verify/screen.png' }); // open it and find the element
@@ -180,7 +182,7 @@ When inspecting `revyl device report --session-id <id>`, read validation verdict
180
182
 
181
183
  ## Your own model
182
184
 
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.
185
+ **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.6.1/python/README.md#your-own-model) and [Go](https://github.com/RevylAI/verify/blob/v0.6.1/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.
184
186
 
185
187
  ```ts
186
188
  const session = await open('ios', { model: 'anthropic/claude-sonnet-5-5' }); // every step on your model
@@ -231,7 +233,7 @@ For owning computers, cleanup must be confirmed; a terminal state alone does not
231
233
 
232
234
  ## Computer commands, files, and processes
233
235
 
234
- Mac sessions provide `exec(argv: string[], options?)`, `readFile(path, options?)` returning `Buffer`, `writeFile(path, string | Uint8Array, options?)`, `stat(path, options?)` returning `{ kind, size }`, `processes(options?)` returning `{ id, state, exitCode? }[]`, and `terminate(id, options?)`. They throw `NOT_AVAILABLE_ON_PLATFORM` on web/iOS/Android. See [computers.md](computers.md) for complete signatures, limits, timeouts, and examples. Both support `screenshot()`; instruction/validation do not run on computers.
236
+ Mac sessions provide `exec(argv: string[], options?)`, `readFile(path, options?)` returning `Buffer`, `writeFile(path, string | Uint8Array, options?)`, `stat(path, options?)` returning `{ kind, size }`, `processes(options?)` returning `{ id, state, exitCode? }[]`, and `terminate(id, options?)`. They throw `NOT_AVAILABLE_ON_PLATFORM` on web/iOS/Android. See [computers.md](computers.md) for complete signatures, limits, timeouts, and examples. Both support `screenshot()`, and computers take `tap({ x, y })`, `screenSize()`, `typeFocused(text, { clear: false })` and `pressKey` on the screen (see [computers.md](computers.md#screen)); instruction/validation do not run on computers.
235
237
 
236
238
  ### `session.recording(): Promise<ComputerRecording | null>`
237
239
 
@@ -265,7 +267,7 @@ While a session is open, Verify attempts best-effort cleanup with a 15-second bu
265
267
  - on an uncaught error, such as a failed validation that nothing catches: it prints the error, attempts cleanup, then exits with 1;
266
268
  - when the script finishes without calling `close()`: it attempts cleanup and prints a reminder.
267
269
 
268
- Verify skips installing a signal or error handler when that event already has listeners, so test runners and apps that manage their own shutdown keep control of it. Always close in `try/finally` or an `after` hook; automatic cleanup cannot guarantee success. A process that is killed outright, or calls `process.exit()` with sessions open, leaves browser/mobile sessions running until their idle timeout (10 minutes by default). Computers have a server-bounded one-hour lifetime instead; no idle timeout is configurable.
270
+ Verify skips installing a signal or error handler when that event already has listeners, so test runners and apps that manage their own shutdown keep control of it. Always close in `try/finally` or an `after` hook; automatic cleanup cannot guarantee success. A process that is killed outright, or calls `process.exit()` with sessions open, leaves browser/mobile sessions running until their idle timeout (10 minutes by default). Computers have a hard lifetime from create instead (two hours today; `expiresAt` is authoritative); no idle timeout is configurable.
269
271
 
270
272
  ## defineConfig(config)
271
273
 
package/docs/ci.md CHANGED
@@ -4,7 +4,7 @@ Verify is a library, so it runs wherever your code runs. Browsers, devices, and
4
4
 
5
5
  ## A script in GitHub Actions
6
6
 
7
- Add `REVYL_API_KEY` as a repository secret, then run your script with `node`. A failed instruction or validation makes the script exit non-zero, which fails the job.
7
+ Add `REVYL_API_KEY` as a repository secret, then run your script with `node`. Name it `.mts`, as `init` does, so Node runs it as an ES module whatever your `package.json` says. A failed instruction or validation makes the script exit non-zero, which fails the job.
8
8
 
9
9
  ```yaml
10
10
  name: Verify
@@ -22,7 +22,7 @@ jobs:
22
22
  node-version: 24
23
23
  cache: npm
24
24
  - run: npm ci
25
- - run: node verify/checkout.ts
25
+ - run: node verify/checkout.mts
26
26
  env:
27
27
  REVYL_API_KEY: ${{ secrets.REVYL_API_KEY }}
28
28
  - if: ${{ !cancelled() }}
@@ -71,4 +71,4 @@ A thrown `VerifyError` fails the test with its code, Revyl's reason, and the ses
71
71
 
72
72
  ## Cost and parallelism
73
73
 
74
- Each `open()` holds one Revyl session until `close()`. Sessions run in parallel when you open several at once, so close each one as soon as you are done with it. A browser/mobile session that loses its process stops itself after `idleTimeoutSeconds` (10 minutes by default). Computers instead have a server-bounded one-hour lifetime and no configurable idle timeout; close in `finally` and do not treat a timed-out CI job as proof of physical release.
74
+ Each `open()` holds one Revyl session until `close()`. Sessions run in parallel when you open several at once, so close each one as soon as you are done with it. A browser/mobile session that loses its process stops itself after `idleTimeoutSeconds` (10 minutes by default). Computers instead have a hard lifetime from create (two hours today; `expiresAt` is authoritative) and no configurable idle timeout; close in `finally` and do not treat a timed-out CI job as proof of physical release.