@revyl/verify 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/session.js CHANGED
@@ -6,16 +6,22 @@ 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';
10
+ import { chooseAgent } from './agent/providers.js';
9
11
  const POLL_INTERVAL_MS = 2_000;
10
12
  const DEVICE_CONNECT_TIMEOUT_MS = 90_000;
11
13
  const STEP_POLL_BASE_MS = 500;
12
14
  const STEP_POLL_MAX_MS = 5_000;
15
+ // Revyl can briefly answer a just-accepted step's status with 404 "Live step not found" before its run is listed.
16
+ const LIVE_STEP_GRACE_MS = 10_000;
17
+ const LIVE_STEP_NOT_FOUND = /^live step not found/i;
13
18
  const STEP_CANCEL_TIMEOUT_MS = 5_000;
14
19
  const MAX_STEP_TEXT_LENGTH = 4_000;
15
20
  const ENDED_STATUSES = new Set(['completed', 'failed', 'cancelled', 'timeout', 'stopping']);
16
21
  const STEP_TERMINAL_STATUSES = new Set(['completed', 'failed']);
17
22
  const SESSION_NOT_RUNNING = /session is (not running|not active|stopping)/i;
18
23
  const UNCERTAIN_ERROR_CODES = new Set(['request_timeout', 'connection_closed', 'protocol_error']);
24
+ const WEB_TARGET_ERRORS = new Set(['stale_target', 'target_unstable', 'invalid_coordinates', 'target_not_found', 'unsupported_web_element', 'invalid_action', 'grounding_failed', 'password_field_required', 'password_focus_changed']);
19
25
  /** A Revyl cloud browser or iOS or Android device. */
20
26
  export class DeviceSession {
21
27
  client;
@@ -34,6 +40,8 @@ export class DeviceSession {
34
40
  stepRunning = false;
35
41
  parent;
36
42
  readySignal;
43
+ size;
44
+ attribution;
37
45
  get expiresAt() {
38
46
  return this.parent?.expiresAt;
39
47
  }
@@ -139,7 +147,7 @@ export class DeviceSession {
139
147
  : reason === 'computer_not_found' || response.status === 404 ? 'NOT_FOUND'
140
148
  : reason === 'computer_not_running' ? 'SESSION_ENDED'
141
149
  : reason === 'computer_cannot_host_platform' || reason === 'computer_runtime_unavailable' || reason === 'web_runtime_unavailable' || reason === 'web_runtime_not_configured' || reason === 'runtime_unavailable' || reason === 'not_available_on_platform' ? 'NOT_AVAILABLE_ON_PLATFORM'
142
- : reason === 'computer_at_capacity' || reason === 'computer_limit' ? 'COMPUTER_LIMIT'
150
+ : reason === 'computer_at_capacity' || reason === 'computer_platform_at_capacity' || reason === 'computer_limit' ? 'COMPUTER_LIMIT'
143
151
  : response.status === 422 || response.status === 400 ? 'INVALID_ARGUMENT'
144
152
  : response.status === 409 ? 'SESSION_BUSY'
145
153
  : response.status === 408 || response.status >= 500 ? 'ACTION_UNCERTAIN' : 'API_ERROR';
@@ -178,7 +186,7 @@ export class DeviceSession {
178
186
  }
179
187
  /** Asks Revyl's agent to do something on the app, such as "add the first product to the cart". Throws INSTRUCTION_FAILED when it cannot. */
180
188
  async instruction(text, options = {}) {
181
- const result = await this.step('instruction', text, options.timeout);
189
+ const result = await this.step('instruction', text, options);
182
190
  if (!result.passed) {
183
191
  throw new VerifyError('INSTRUCTION_FAILED', `Instruction failed: "${result.text}". ${sentence(result.reason)} See ${this.viewerUrl}`);
184
192
  }
@@ -189,7 +197,7 @@ export class DeviceSession {
189
197
  * unless soft is set, and STEP_FAILED when Revyl could not reach a verdict.
190
198
  */
191
199
  async validation(text, options = {}) {
192
- const result = await this.step('validation', text, options.timeout);
200
+ const result = await this.step('validation', text, options);
193
201
  if (!result.passed && !options.soft) {
194
202
  throw new VerifyError('VALIDATION_FAILED', `Validation failed: "${result.text}". ${sentence(result.reason)} See ${this.viewerUrl}`);
195
203
  }
@@ -215,9 +223,12 @@ export class DeviceSession {
215
223
  throw new VerifyError('API_ERROR', 'Revyl returned an invalid session report.');
216
224
  return report;
217
225
  }
218
- async navigate(url) {
226
+ navigate(url) {
227
+ return this.navigateTo(url);
228
+ }
229
+ async navigateTo(url, signal) {
219
230
  if (this.platform !== 'web')
220
- throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'navigate() is available only on web sessions.');
231
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'navigate() is available only on web sessions; use openUrl() on iOS and Android.');
221
232
  if (typeof url !== 'string' || url.length > 2048 || /[\x00-\x20\x7f-\x9f\s\\]/.test(url))
222
233
  throw new VerifyError('INVALID_ARGUMENT', 'navigate() requires an http(s) URL without credentials, whitespace, or backslashes.');
223
234
  let parsed;
@@ -229,7 +240,8 @@ export class DeviceSession {
229
240
  }
230
241
  if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password)
231
242
  throw new VerifyError('INVALID_ARGUMENT', 'navigate() requires an http(s) URL without credentials.');
232
- const response = await this.worker('POST', 'open_url', { body: { url }, retry: false });
243
+ signal?.throwIfAborted();
244
+ const response = await this.worker('POST', 'open_url', { body: { url }, retry: false, ...(signal ? { signal } : {}) });
233
245
  const result = parseJson(response, 'open_url');
234
246
  if (!result || typeof result !== 'object' || typeof result.success !== 'boolean' || (result.action !== undefined && result.action !== 'navigate') || (result.status_code !== undefined && (!Number.isInteger(result.status_code) || result.status_code < 100 || result.status_code > 599)))
235
247
  throw new VerifyError('API_ERROR', 'Revyl returned an invalid navigation response.');
@@ -279,11 +291,12 @@ export class DeviceSession {
279
291
  [Symbol.asyncDispose]() {
280
292
  return this.close();
281
293
  }
282
- async step(type, text, timeout) {
294
+ async step(type, text, options) {
283
295
  if (typeof text !== 'string' || text.trim() === '' || text.length > MAX_STEP_TEXT_LENGTH) {
284
296
  throw new VerifyError('INVALID_ARGUMENT', `${type}() takes a description of 1 to ${MAX_STEP_TEXT_LENGTH} characters.`);
285
297
  }
286
- const timeoutMs = timeout ?? this.options.stepTimeout;
298
+ const agent = chooseAgent(options, `${type}()`) ?? this.options.agent;
299
+ const timeoutMs = options.timeout ?? this.options.stepTimeout;
287
300
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000) {
288
301
  throw new VerifyError('INVALID_ARGUMENT', `${type}() takes a timeout of at least 1000 ms.`);
289
302
  }
@@ -294,6 +307,13 @@ export class DeviceSession {
294
307
  const description = text.trim();
295
308
  const startedAt = Date.now();
296
309
  try {
310
+ if (agent) {
311
+ const local = await this.runLocal(type, description, timeoutMs, agent);
312
+ const result = { type, text: description, passed: local.passed, ...(local.reason ? { reason: local.reason } : {}), durationMs: Date.now() - startedAt, model: local.run };
313
+ this.steps.push(result);
314
+ this.log(`${local.passed ? '✓' : '✗'} ${type} "${description}" (${formatDuration(result.durationMs)}, ${local.run.turns.length} turn${local.run.turns.length === 1 ? '' : 's'} on ${local.run.id}, ${local.run.inputTokens + local.run.outputTokens} tokens)${!local.passed && local.reason ? `: ${local.reason}` : ''}`);
315
+ return result;
316
+ }
297
317
  const outcome = await this.runStep(type, description, timeoutMs);
298
318
  const output = outcome.step_output ?? {};
299
319
  const reason = output.status_reason ?? output.reasoning ?? undefined;
@@ -336,9 +356,17 @@ export class DeviceSession {
336
356
  throw new VerifyError('API_ERROR', 'Revyl answered execute_step without a result or an accepted step id.');
337
357
  }
338
358
  stepId = body.step_id;
359
+ const acceptedAt = Date.now();
360
+ const liveStepNotFound = (response) => response.status === 404 && LIVE_STEP_NOT_FOUND.test(responseDetail(response) ?? '');
339
361
  for (let wait = STEP_POLL_BASE_MS;; wait = Math.min(wait * 2, STEP_POLL_MAX_MS)) {
340
362
  await delay(wait, undefined, { signal });
341
- const status = parseJson(await this.worker('GET', `step_status/${encodeURIComponent(stepId)}`, { signal, retry: true }), 'step_status');
363
+ const response = await this.worker('GET', `step_status/${encodeURIComponent(stepId)}`, { signal, retry: true, allow: liveStepNotFound });
364
+ if (liveStepNotFound(response)) {
365
+ if (Date.now() - acceptedAt < LIVE_STEP_GRACE_MS)
366
+ continue;
367
+ throw new VerifyError('STEP_FAILED', `Revyl accepted the ${type} but could not find it for ${LIVE_STEP_GRACE_MS / 1000} s: "${description}". The session is still open and the step may still be running, so wait for it to finish before sending another. See ${this.viewerUrl}`, { httpStatus: 404 });
368
+ }
369
+ const status = parseJson(response, 'step_status');
342
370
  if (status.status === 'running')
343
371
  continue;
344
372
  if (status.status === 'cancelled') {
@@ -365,6 +393,169 @@ export class DeviceSession {
365
393
  throw new VerifyError('TIMEOUT', `The ${type} did not finish within ${timeoutMs} ms and was cancelled: "${description}". See ${this.viewerUrl}`);
366
394
  }
367
395
  }
396
+ async runLocal(type, description, timeoutMs, agent) {
397
+ const signal = AbortSignal.timeout(timeoutMs);
398
+ this.attribution = {
399
+ 'X-Revyl-Agent': `Verify local: ${agent.model.name}`.replace(/[^\x20-\x7e]/g, '?').slice(0, 100),
400
+ 'X-Revyl-Agent-Session-Id': randomUUID(),
401
+ };
402
+ try {
403
+ return await runLocalStep(type, description, agent, this.agentControls(), signal, (line) => this.log(line));
404
+ }
405
+ catch (error) {
406
+ if (signal.aborted && !(isVerifyError(error) && error.code !== 'TIMEOUT')) {
407
+ throw new VerifyError('TIMEOUT', `The ${type} did not finish within ${timeoutMs} ms on ${agent.model.name}: "${description}". See ${this.viewerUrl}`, { cause: error });
408
+ }
409
+ throw error;
410
+ }
411
+ finally {
412
+ this.attribution = undefined;
413
+ }
414
+ }
415
+ agentControls() {
416
+ const platform = this.platform;
417
+ if (platform !== 'ios' && platform !== 'android' && platform !== 'web')
418
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Local agents run on browsers and devices, not on ${platform}.`);
419
+ return {
420
+ platform,
421
+ capture: (signal) => this.capture(signal),
422
+ screenSize: (signal) => this.readScreenSize(signal),
423
+ perform: (action, screenToken, signal) => this.perform(action, screenToken, signal),
424
+ };
425
+ }
426
+ /** Taps a point in device units, or an element Revyl locates from a description. Returns the point tapped. */
427
+ async tap(at) {
428
+ return (await this.perform({ kind: 'tap', at: checkLocation(at) }));
429
+ }
430
+ /** Taps a text field and types into it, clearing it first unless clear is false. Returns the point tapped. */
431
+ async type(at, text, options = {}) {
432
+ if (typeof text !== 'string' || text.length > 16_384)
433
+ throw new VerifyError('INVALID_ARGUMENT', 'type() takes text of at most 16384 characters.');
434
+ return (await this.perform({ kind: 'type', at: checkLocation(at), text, clear: options.clear !== false }));
435
+ }
436
+ /** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. */
437
+ async swipe(direction, options = {}) {
438
+ if (!['up', 'down', 'left', 'right'].includes(direction))
439
+ 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) } : {}) });
441
+ }
442
+ /** Presses Enter or Backspace on the focused field. */
443
+ async pressKey(key) {
444
+ if (key !== 'enter' && key !== 'backspace')
445
+ throw new VerifyError('INVALID_ARGUMENT', "pressKey() takes 'enter' or 'backspace'.");
446
+ await this.perform({ kind: 'key', key });
447
+ }
448
+ /** Goes back: browser history on Web, the back button on Android. Not on iOS. */
449
+ async back() {
450
+ await this.perform({ kind: 'back' });
451
+ }
452
+ /** Opens a URL or deep link on a device, or loads a URL in a browser. */
453
+ async openUrl(url) {
454
+ if (typeof url !== 'string' || url.length === 0 || url.length > 8_192)
455
+ 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
+ await this.perform({ kind: 'open_url', url });
459
+ }
460
+ /** Launches an installed app by bundle ID or package name. Devices only. */
461
+ async launchApp(bundleId) {
462
+ if (this.platform === 'web')
463
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'launchApp() is available only on iOS and Android sessions; on web, use openUrl() or navigate().');
464
+ if (typeof bundleId !== 'string' || !/^[A-Za-z0-9._-]{1,255}$/.test(bundleId))
465
+ throw new VerifyError('INVALID_ARGUMENT', 'launchApp() takes a bundle ID or package name.');
466
+ await this.act('launch', { bundle_id: bundleId });
467
+ }
468
+ /** Asks Revyl's grounding model where an element is on the current screen, in device units. */
469
+ async locate(target) {
470
+ const { x, y } = await this.resolve(checkLocation({ target }));
471
+ return { x, y };
472
+ }
473
+ /** The screen size in device units: iOS points, Android and Web pixels. */
474
+ screenSize() {
475
+ return this.readScreenSize();
476
+ }
477
+ async readScreenSize(signal) {
478
+ if (this.size)
479
+ return this.size;
480
+ if (this.platform !== 'web') {
481
+ const health = parseJson(await this.worker('GET', 'health', { retry: true, ...(signal ? { signal } : {}) }), 'health');
482
+ if (Number.isInteger(health.screen_width) && Number.isInteger(health.screen_height) && health.screen_width > 0 && health.screen_height > 0) {
483
+ this.size = { width: health.screen_width, height: health.screen_height };
484
+ return this.size;
485
+ }
486
+ }
487
+ const size = pngSize((await this.capture(signal)).png);
488
+ if (!size)
489
+ throw new VerifyError('ACTION_FAILED', 'Revyl returned a screenshot that is not a PNG image.');
490
+ this.size = size;
491
+ return size;
492
+ }
493
+ async capture(signal) {
494
+ const response = await this.worker('GET', 'screenshot', { retry: true, ...(signal ? { signal } : {}) });
495
+ if (!pngSize(response.body))
496
+ throw new VerifyError('ACTION_FAILED', 'Revyl returned a screenshot that is not a PNG image.');
497
+ const screenToken = this.platform === 'web' ? response.headers.get('x-revyl-screen-token') ?? undefined : undefined;
498
+ return { png: response.body, ...(screenToken ? { screenToken } : {}) };
499
+ }
500
+ async perform(action, screenToken, signal) {
501
+ switch (action.kind) {
502
+ case 'tap': {
503
+ if ('target' in action.at && this.platform !== 'web') {
504
+ signal?.throwIfAborted();
505
+ const result = parseJson(await this.worker('POST', 'tap_target', { body: { target: action.at.target, ...(this.id ? { session_id: this.id } : {}) }, ...(signal ? { signal } : {}) }), 'tap_target');
506
+ if (result.found === false)
507
+ throw new VerifyError('ACTION_FAILED', `Revyl could not find "${action.at.target}" on the screen.`);
508
+ if (result.success !== true || typeof result.x !== 'number' || typeof result.y !== 'number')
509
+ throw new VerifyError('ACTION_FAILED', `tap failed: ${result.error ?? 'no reason given'}`);
510
+ return { x: Math.round(result.x), y: Math.round(result.y) };
511
+ }
512
+ const point = await this.resolve(action.at, screenToken, signal);
513
+ await this.act('tap', pointBody(point, action.at), signal);
514
+ return { x: point.x, y: point.y };
515
+ }
516
+ case 'type': {
517
+ const point = await this.resolve(action.at, screenToken, signal);
518
+ await this.act('input', { ...pointBody(point, action.at), text: action.text, clear_first: action.clear }, signal);
519
+ return { x: point.x, y: point.y };
520
+ }
521
+ case 'swipe': {
522
+ 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
+ const direction = this.platform === 'web' ? { up: 'down', down: 'up', left: 'right', right: 'left' }[action.direction] : action.direction;
524
+ await this.act('swipe', { ...pointBody(point, action.from), direction, duration_ms: 500 }, signal);
525
+ return undefined;
526
+ }
527
+ case 'key':
528
+ await this.act('key', { key: this.platform === 'web' ? (action.key === 'enter' ? 'Enter' : 'Backspace') : action.key.toUpperCase() }, signal);
529
+ return undefined;
530
+ case 'back':
531
+ if (this.platform === 'ios')
532
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'back() is not available on iOS; tap the on-screen back control.');
533
+ await this.act('back', undefined, signal);
534
+ return undefined;
535
+ case 'open_url':
536
+ signal?.throwIfAborted();
537
+ if (this.platform === 'web')
538
+ await this.navigateTo(action.url, signal);
539
+ else
540
+ await this.act('open_url', { url: action.url }, signal);
541
+ return undefined;
542
+ }
543
+ }
544
+ async resolve(at, screenToken, signal) {
545
+ if ('x' in at)
546
+ return { x: at.x, y: at.y, ...(screenToken ? { screenToken } : {}) };
547
+ const result = parseJson(await this.worker('POST', 'resolve_target', { body: { target: at.target, ...(this.id ? { session_id: this.id } : {}), ...(screenToken ? { screen_token: screenToken } : {}) }, ...(signal ? { signal } : {}) }), 'resolve_target');
548
+ if (result.found !== true || typeof result.x !== 'number' || typeof result.y !== 'number') {
549
+ throw new VerifyError('ACTION_FAILED', `Revyl could not find "${at.target}" on the screen${result.error && this.platform !== 'web' ? `: ${result.error}` : ''}.`);
550
+ }
551
+ return { x: Math.round(result.x), y: Math.round(result.y), ...(result.screen_token ? { screenToken: result.screen_token } : {}) };
552
+ }
553
+ async act(action, body, signal) {
554
+ signal?.throwIfAborted();
555
+ const result = parseJson(await this.worker('POST', action, { body: body ?? {}, ...(signal ? { signal } : {}) }), action);
556
+ if (result.success !== true)
557
+ throw new VerifyError('ACTION_FAILED', `${action} failed on the ${this.target} session: ${result.error ?? 'no reason given'}. See ${this.viewerUrl}`);
558
+ }
368
559
  async launch(bundleId) {
369
560
  const response = await this.worker('POST', 'launch', { body: { bundle_id: bundleId } });
370
561
  const result = parseJson(response, 'launch');
@@ -413,10 +604,20 @@ export class DeviceSession {
413
604
  ...(this.parent.expiresAt ? [AbortSignal.timeout(Math.max(0, this.parent.expiresAt.getTime() - Date.now()))] : []),
414
605
  ...(options.signal ? [options.signal] : []),
415
606
  ]) : options.signal;
416
- const response = await this.client.worker(this.workflowRunId, action, { method, ...options, signal });
417
- if (response.status >= 200 && response.status < 300)
607
+ const { allow, ...request } = options;
608
+ let response;
609
+ try {
610
+ response = await this.client.worker(this.workflowRunId, action, { method, ...request, signal, ...(this.attribution ? { headers: this.attribution } : {}) });
611
+ }
612
+ catch (error) {
613
+ if (method === 'POST' && action !== 'resolve_target' && options.signal?.aborted && isVerifyError(error) && error.code === 'TIMEOUT') {
614
+ throw new VerifyError('ACTION_UNCERTAIN', `The step deadline passed while ${action} was in flight, so it may have been applied. Verify did not repeat it. See ${this.viewerUrl}`, { cause: error });
615
+ }
616
+ throw error;
617
+ }
618
+ if ((response.status >= 200 && response.status < 300) || allow?.(response))
418
619
  return response;
419
- if (action === 'open_url') {
620
+ if (action === 'open_url' && this.platform === 'web') {
420
621
  const error = this.workerError(method, action, response);
421
622
  const reason = responseRefusalReason(response);
422
623
  const message = error.code === 'ACTION_UNCERTAIN' && reason === 'request_timeout'
@@ -439,6 +640,9 @@ export class DeviceSession {
439
640
  }
440
641
  return new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${baseAction} is not available on ${this.platform} sessions (${reason}).`);
441
642
  }
643
+ if (WEB_TARGET_ERRORS.has(code ?? detail ?? '')) {
644
+ return new VerifyError('ACTION_FAILED', `${baseAction} could not act on the screen (${code ?? detail}). Take a fresh screenshot and try again. See ${this.viewerUrl}`);
645
+ }
442
646
  if (response.status === 404 || response.status === 410) {
443
647
  return new VerifyError('SESSION_ENDED', `The ${this.target} session is no longer reachable (${reason}). See ${this.viewerUrl}`);
444
648
  }
@@ -554,6 +758,20 @@ export function pngSize(bytes) {
554
758
  const height = view.getUint32(20);
555
759
  return width > 0 && height > 0 ? { width, height } : undefined;
556
760
  }
761
+ function checkLocation(at) {
762
+ if (at && typeof at === 'object' && 'target' in at) {
763
+ if (typeof at.target !== 'string' || at.target.trim() === '' || at.target.length > 500)
764
+ throw new VerifyError('INVALID_ARGUMENT', 'A target is a description of 1 to 500 characters.');
765
+ return { target: at.target.trim() };
766
+ }
767
+ if (!at || typeof at !== 'object' || !Number.isInteger(at.x) || !Number.isInteger(at.y) || at.x < 0 || at.y < 0) {
768
+ throw new VerifyError('INVALID_ARGUMENT', 'A location is { x, y } in non-negative whole device units, or { target: "description" }.');
769
+ }
770
+ return { x: at.x, y: at.y };
771
+ }
772
+ function pointBody(point, at) {
773
+ return { x: point.x, y: point.y, ...(point.screenToken ? { screen_token: point.screenToken } : {}), ...(at && 'target' in at ? { target: at.target } : {}) };
774
+ }
557
775
  function formatDuration(ms) {
558
776
  return ms < 60_000 ? `${(ms / 1_000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1_000)}s`;
559
777
  }
package/docs/README.md CHANGED
@@ -8,10 +8,11 @@ These files ship inside the npm package at `node_modules/@revyl/verify/docs/`, s
8
8
  | [getting-started.md](getting-started.md) | Install Verify, authenticate, and run a first script |
9
9
  | [api.md](api.md) | Look up session lifecycle, agent steps, screenshots, commands, files, and processes |
10
10
  | [configuration.md](configuration.md) | Look up every option in `verify.config.ts`, the environment variables, and the CLI |
11
- | [web.md](web.md) | Open a web app in a Revyl cloud browser |
12
- | [mobile.md](mobile.md) | Open an iOS or Android app on a Revyl cloud device |
13
- | [computers.md](computers.md) | Open a Mac or Linux computer for commands, files, processes, screenshots, and optional recording metadata |
11
+ | [web.md](web.md) | Open a web app in a Revyl cloud browser, and act on it with exact actions |
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 |
14
14
  | [ci.md](ci.md) | Call Verify from CI or from a test runner you already use |
15
+ | [own-model.md](own-model.md) | Run instructions and validations on your own model, such as Claude, GPT or a local model |
15
16
  | [errors.md](errors.md) | Understand an error code and fix it, or troubleshoot a common problem |
16
17
  | [agents.md](agents.md) | Use Verify as a coding agent |
17
18
 
package/docs/agents.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # For coding agents
2
2
 
3
- This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that use Verify to check an app on a cloud browser/device or run commands and files on a Mac or Linux computer. The rest of this folder describes the API in full; this page is the workflow.
3
+ This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that use Verify to check an app on a cloud browser/device or run commands and files on a Mac computer. The rest of this folder describes the API in full; this page is the workflow.
4
4
 
5
5
  ## Workflow
6
6
 
7
- 1. Read `verify.config.ts` to learn the target names and their platforms. If there is none, run `npx @revyl/verify init --platform <web|ios|android> --app-id <id>`, or `npx @revyl/verify init --platform mac` without an app id. You can also pass a target object to `open()`.
7
+ 1. Read `verify.config.ts` to learn the target names and their platforms. If there is none, run `npx @revyl/verify init --platform ios` or `--platform android` (without `--app-id` they open the built-in Settings app), `npx @revyl/verify init --platform web --app-id <id> --url <https-url>`, or `npx @revyl/verify init --platform mac`. You can also pass a target object to `open()`.
8
8
  2. Check the credentials once with `npx @revyl/verify whoami`. It starts no session.
9
9
  3. Write a short script, or extend an existing one, that opens one target, runs the steps you need, and closes:
10
10
  ```ts
@@ -24,11 +24,11 @@ This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that
24
24
 
25
25
  ## Rules
26
26
 
27
- Mac and Linux computers are early access. Ask support@revyl.ai about access before starting a computer; do not repeat an uncertain start/stop to probe availability.
27
+ 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.
28
28
 
29
- 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. Linux uses inline targets or config entries; Linux init is intentionally unavailable. Starting a computer is a paid external action and needs the same approval as starting a device.
29
+ 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.
30
30
 
31
- Computers have a hard 60-minute lifetime from create, not from readiness or activity. `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.
31
+ 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.
32
32
 
33
33
  - Write steps the way you would brief a person: what to do, or what should be true, in one sentence. Name visible things ("the blue Save button in the footer"), not implementation details.
34
34
  - Use `instruction` for doing and `validation` for checking. Do not ask an instruction to check something, or a validation to change anything.
package/docs/api.md CHANGED
@@ -6,22 +6,21 @@ import { attach, open, defineConfig, VerifyError, isVerifyError } from '@revyl/v
6
6
 
7
7
  ## open(target, options?)
8
8
 
9
- Linux targets are supported **since 0.2.0**; 0.1.0 includes the target type but rejects Linux locally.
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
- Starts a Revyl cloud browser, device, or Mac or Linux 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.
12
-
13
- Both computer operating systems are early access. Ask support@revyl.ai about access.
11
+ Mac computers are early access. Ask support@revyl.ai about access.
14
12
 
15
13
  ```ts
16
14
  const web = await open('web'); // a target named in verify.config.ts
17
15
  const ios = await open({ platform: 'ios', app: { appId: '…' } }); // or a target object, no config needed
18
16
  const mac = await open({ platform: 'mac' }); // computer, no app needed
19
- const linux = await open({ platform: 'linux' }); // same computer surface
20
17
  ```
21
18
 
22
19
  - `target`: a target name from `verify.config.ts`, or a target object with the same shape as a config entry (see [configuration.md](configuration.md)). Verify reads the nearest `verify.config.ts` at or above the current folder.
23
20
  - `options.config`: the path of a config file to read instead.
24
21
  - `options.log`: where progress lines go. By default Verify writes the session link and each step's outcome to stderr, prefixed with `[verify]`. Pass a function to receive the lines yourself, or `false` to turn them off.
22
+ - `options.model`: runs every instruction and validation on your own model instead of Revyl's agent; see [Your own model](#your-own-model). Browsers and devices only; computers throw `NOT_AVAILABLE_ON_PLATFORM`.
23
+ - `options.agent`: the same with a custom agent; see [Custom agents](#custom-agents). Pass `model` or `agent`, not both.
25
24
 
26
25
  `open` needs credentials: `REVYL_API_KEY` or a Revyl CLI login. It throws `AUTH_MISSING` without them, `CONFIG_NOT_FOUND` or `CONFIG_INVALID` for config problems, and `SESSION_START_FAILED` or `TIMEOUT` when the browser or device does not start.
27
26
 
@@ -53,7 +52,7 @@ The attachment, hosted-child and navigation APIs below are included in the TypeS
53
52
 
54
53
  `open()` and `computer.open()` return the extended `OpenedSession` interface. The original `Session` interface keeps its existing required members, so applications implementing or mocking it do not need to add the new methods. Use `OpenedSession` when explicitly annotating a value that calls `report`, `navigate` or `open`.
55
54
 
56
- Attaches to an active Mac or Linux computer in your organization without creating a new one. `options.log` controls progress. The returned computer owns cleanup by default: `close()` stops it, and automatic cleanup also applies. Attaching requires a valid expiry and does not extend the lifetime. Invalid, inaccessible, inactive or expired computers fail without sending a stop request.
55
+ Attaches to an active Mac computer in your organization without creating a new one. `options.log` controls progress. The returned computer owns cleanup by default: `close()` stops it, and automatic cleanup also applies. Attaching requires a valid expiry and does not extend the lifetime. Invalid, inaccessible, inactive or expired computers fail without sending a stop request.
57
56
 
58
57
  When attaching to a computer you do not own, such as a CI runner, pass `attach(computerId, { stopOnClose: false })`. The attachment is not registered for automatic cleanup. Its `close()` still cancels pending child starts and gives hosted children the same five-second cleanup phase, but never stops the computer; its owner must release it. Child cleanup failures remain observable. This option is explicit and is never inferred from the environment.
59
58
 
@@ -61,7 +60,7 @@ When attaching to a computer you do not own, such as a CI runner, pass `attach(c
61
60
 
62
61
  Starts a child app session inside this computer, returning the normal session API. It inherits the parent's authenticated client, logging and step timeout; callers cannot route a child to another backend. Both `target.app.appId` and `target.app.buildId` are required. Web accepts an existing external HTTPS build, plus optional viewport/sign-in settings; native builds use the computer's fixed device profile and accept no bundle, artifact or device override. Named targets and initial URL overrides are not supported here.
63
62
 
64
- Mac supports Web and iOS children. Linux supports Android and may support Web when its runtime advertises that capability; Linux Web currently has a runtime limitation, so do not rely on it without confirming availability. 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.
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.
65
64
 
66
65
  ```ts
67
66
  const computer = await attach(existingComputerId, { log: false });
@@ -83,6 +82,41 @@ This requires an already-running service on the computer. The initial Web build
83
82
 
84
83
  Web only, hosted or normal. Sends one deterministic `open_url` action and checks the returned navigation outcome. Accepts an HTTP(S) URL without credentials, whitespace, control characters or backslashes. Loopback addresses are accepted on every Web session: a hosted browser can reach its parent computer's loopback address; a normal cloud browser's localhost is its own runtime, not your machine. A cold development server may need a warm-up request before navigation succeeds; after an uncertain timeout, re-navigating is the caller's choice because the original request may still commit. The runtime is responsible for enforcing its navigation network policy. Navigation is not replayed by Verify and does not run an agent step. It resolves with no value, or throws a typed error. Computer and native sessions reject it locally.
85
84
 
85
+ ## Exact actions
86
+
87
+ **Since 0.4.0. Early access.** In TypeScript (`@revyl/verify` 0.4.0), Python (`revyl-verify` 0.2.0) and Go (v0.3.0); the [Python](https://github.com/RevylAI/verify/blob/v0.4.0/python/README.md#exact-actions) and [Go](https://github.com/RevylAI/verify/blob/v0.4.0/go/README.md#exact-actions) READMEs show them with each language's names. Web, iOS and Android sessions take device actions that need no agent.
88
+
89
+ | Call | Web | iOS | Android | Mac computer |
90
+ | --- | --- | --- | --- | --- |
91
+ | `tap`, `type`, `swipe`, `pressKey`, `locate`, `screenSize` | Yes | Yes | Yes | No, use `exec()` and files |
92
+ | `back()` | Yes, browser history | No, tap the on-screen back control | Yes, the system back button | No |
93
+ | `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
+ | `launchApp(bundleId)` | No, use `openUrl` or `navigate` | Yes | Yes | No |
95
+ | `navigate(url)` | Yes | No, use `openUrl` | No, use `openUrl` | No |
96
+ | `instruction`, `validation`, your own model | Yes | Yes | Yes | No, use `exec()` |
97
+ | `screenshot()` | Yes | Yes | Yes | Yes |
98
+ | `exec`, files, processes | No, open a Mac computer | No, open a Mac computer | No, open a Mac computer | Yes |
99
+
100
+ Coordinates are in iOS points, Android pixels and Web CSS pixels, from the top-left corner. A call a platform doesn't support throws `NOT_AVAILABLE_ON_PLATFORM`, and its message names the alternative.
101
+
102
+ A location is `{ x, y }` in device units (iOS points, Android and Web pixels, from the top-left corner; `screenSize()` gives the bounds) or `{ target: 'Login button' }`, which Revyl's grounding model finds on the current screen.
103
+
104
+ | Call | What it does |
105
+ | --- | --- |
106
+ | `tap(at)` | Taps and resolves with the point tapped |
107
+ | `type(at, text, { clear })` | Taps a text field and types into it, clearing it first unless `clear` is `false` |
108
+ | `swipe(direction, { from })` | Swipes from a location or the screen's center; `'up'` moves the finger up, which scrolls content down |
109
+ | `pressKey('enter' \| 'backspace')` | Presses a key on the focused field |
110
+ | `back()` | Browser history on Web, the back button on Android; iOS throws `NOT_AVAILABLE_ON_PLATFORM` |
111
+ | `openUrl(url)` | Opens a URL or deep link on a device, or loads a URL in a browser |
112
+ | `launchApp(bundleId)` | Launches an installed app on a device |
113
+ | `locate(target)` | Resolves with Revyl's best match on the current screen, without acting |
114
+ | `screenSize()` | Resolves with `{ width, height }` in device units |
115
+
116
+ 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
+
118
+ Exact actions return when Revyl has accepted them, not when the screen has finished changing. After `swipe`, `openUrl` or `launchApp`, let the screen settle or check it before you `locate` or tap coordinates. `locate` returns Revyl's best match on the current screen and can return a point for an element that is off screen, so bring the element on screen first and check the point before you use it.
119
+
86
120
  ## session.report()
87
121
 
88
122
  Returns authenticated JSON metadata: `/execution/device-sessions/{id}` for normal and hosted app sessions, or the computer report for computers. It works after close, but fails locally with `NOT_FOUND` when no session ID is known. It does not download recordings or turn a device stop acknowledgment into proof of physical release. Treat returned metadata as private.
@@ -116,8 +150,9 @@ interface StepResult {
116
150
  type: 'instruction' | 'validation';
117
151
  text: string; // the step as sent, trimmed
118
152
  passed: boolean;
119
- reason?: string; // Revyl's explanation, when it gave one
153
+ reason?: string; // Revyl's explanation, or your model's on a local agent
120
154
  durationMs: number;
155
+ model?: ModelRun; // set when the step ran on your model
121
156
  }
122
157
  ```
123
158
 
@@ -125,6 +160,47 @@ interface StepResult {
125
160
 
126
161
  When inspecting `revyl device report --session-id <id>`, read validation verdicts from the step's `effective_status` and `validation_result`, with `validations_passed` / `total_validations` for the validation summary. `passed_steps` / `failed_steps` and transport `status` are not enough: a successfully executed validation can have a false verdict. The SDK's `StepResult.passed` reflects that verdict, not just transport success.
127
162
 
163
+ ## Your own model
164
+
165
+ **Since 0.4.0. Early access.** In TypeScript (`@revyl/verify` 0.4.0), Python (`revyl-verify` 0.2.0) and Go (v0.3.0); the [Python](https://github.com/RevylAI/verify/blob/v0.4.0/python/README.md#your-own-model) and [Go](https://github.com/RevylAI/verify/blob/v0.4.0/go/README.md#your-own-model) READMEs show them with each language's names. Runs instructions and validations on a model you choose, in your process, instead of Revyl's agent. Revyl still provides the browser or device. Leave `model` out and Revyl's agent runs the step, as before.
166
+
167
+ ```ts
168
+ const session = await open('ios', { model: 'anthropic/claude-sonnet-5-5' }); // every step on your model
169
+ await session.instruction('Open General, then About');
170
+ await session.validation('The screen shows the iOS version');
171
+ await session.instruction('Open Privacy', { model: 'openai/gpt-5.5' }); // or one step
172
+
173
+ // Your own OpenAI-compatible endpoint: vLLM, LiteLLM, a gateway
174
+ const custom = await open('ios', {
175
+ model: { id: 'qwen-3.8-27b', baseURL: 'https://api.cerebras.ai/v1', apiKey: process.env.CEREBRAS_API_KEY },
176
+ });
177
+ ```
178
+
179
+ `options.model` is a `'provider/model'` string, with the key read from the provider's usual environment variable (and `ANTHROPIC_BASE_URL` or `OPENAI_BASE_URL` for a proxy), or `{ id, baseURL, apiKey?, headers?, provider?, grounding? }` for any OpenAI-compatible endpoint. [Use your own model](own-model.md) has the provider table, which providers are verified live, how to pick a model, what Revyl still sees, the limits and troubleshooting. A missing key throws `MODEL_KEY_MISSING` before any session starts; an unknown prefix throws `INVALID_ARGUMENT`. The model must accept images and tool calls.
180
+
181
+ The object form's key comes only from `apiKey`, never from the environment.
182
+
183
+ `StepResult.model` reports `{ id, grounding, turns, inputTokens, outputTokens }`, with each turn's action, model and action time, and token counts. Provider failures throw `MODEL_ERROR` with the key redacted; model calls are retried up to twice on rate limits and server errors. An instruction stops after 20 turns, and the step's `timeout` bounds the whole step.
184
+
185
+ ## Custom agents
186
+
187
+ For more control, build the agent yourself with `localAgent()` and pass it as `agent` on `open()` or a step:
188
+
189
+ ```ts
190
+ import { localAgent, anthropic, openaiCompatible } from '@revyl/verify';
191
+
192
+ const agent = localAgent({
193
+ model: openaiCompatible({ baseURL: 'http://localhost:11434/v1', model: 'qwen3-vl:8b' }),
194
+ grounding: 'model',
195
+ maxTurns: 10,
196
+ });
197
+ const session = await open('ios', { agent });
198
+ ```
199
+
200
+ - `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.
202
+ - `maxTurns` (default 20, up to 100), `modelTimeout` per model call (default 60000), `maxImageSide` for screenshots sent to the model (default 1280).
203
+
128
204
  ## session.screenshot(options?)
129
205
 
130
206
  Resolves with a PNG of the screen as a `Uint8Array`. With `options.path`, also writes it there, creating folders as needed.
@@ -137,7 +213,7 @@ For owning computers, cleanup must be confirmed; a terminal state alone does not
137
213
 
138
214
  ## Computer commands, files, and processes
139
215
 
140
- Mac and Linux 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.
216
+ 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.
141
217
 
142
218
  ### `session.recording(): Promise<ComputerRecording | null>`
143
219
 
@@ -151,17 +227,17 @@ Computers only. First reads the report and requires terminal state plus confirme
151
227
  | --- | --- |
152
228
  | `viewerUrl` | The session in Revyl: a live view while it runs, its recording/report after. Use `revyl device report --session-id <id>` for web/mobile, or `revyl computer report <id>` for computers. |
153
229
  | `id` | Revyl's session id |
154
- | `platform` | `'web'`, `'ios'`, `'android'`, `'mac'`, or `'linux'` |
155
- | `expiresAt` | **Since 0.2.0.** Validated API expiry as a `Date`, or undefined for devices and older APIs. Computers have a hard 60-minute lifetime from create. Operations/screenshot observation are bounded by expiry; close and recording remain possible. |
230
+ | `platform` | `'web'`, `'ios'`, `'android'`, or `'mac'` |
231
+ | `expiresAt` | **Since 0.2.0.** Validated API expiry as a `Date`, or undefined for devices and older APIs. Computers have a hard lifetime from create (two hours today); this value is the authoritative deadline. Operations/screenshot observation are bounded by expiry; close and recording remain possible. |
156
232
  | `target` | The target name, or the platform for a target object |
157
233
  | `closed` | Whether `close()` was called |
158
234
 
159
235
  ## Rules worth knowing
160
236
 
161
237
  - **One step at a time.** Await each step before starting the next on the same session; a second concurrent step throws `INVALID_ARGUMENT`. Separate sessions can run in parallel.
162
- - **No hidden retries.** Verify never repeats a step or a device action. A step whose outcome is unknown because the connection failed mid-request throws `ACTION_UNCERTAIN`.
238
+ - **No hidden retries.** Verify never repeats a step or a device action. A local agent retries only its own model calls. A step whose outcome is unknown because the connection failed mid-request throws `ACTION_UNCERTAIN`.
163
239
  - **No memory between steps.** Each step is independent: Revyl does not carry values from one step to the next. Put everything a step needs into its text.
164
- - **No replay or cache.** Every step runs Revyl's agent again, every time.
240
+ - **No replay or cache.** Every step runs its agent again, every time.
165
241
 
166
242
  ## Cleanup
167
243