@revyl/verify 0.3.1 → 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.d.ts CHANGED
@@ -1,19 +1,27 @@
1
1
  import { RevylClient } from './api/client.js';
2
2
  import type { LiveStepType, Platform } from './api/types.js';
3
3
  import type { HostedTargetConfig, ResolvedDeviceTarget } from './config.js';
4
+ import { type LocalAgent, type Location, type ModelRun, type Point, type SwipeDirection } from './agent/loop.js';
5
+ import { type ModelOption } from './agent/providers.js';
4
6
  export type StepType = LiveStepType;
5
7
  /** The outcome of one instruction or validation, as Revyl's agent reported it. */
6
8
  export interface StepResult {
7
9
  type: StepType;
8
10
  text: string;
9
11
  passed: boolean;
10
- /** Revyl's explanation, when it gave one. */
12
+ /** Revyl's explanation, or your model's when the step ran on a local agent. */
11
13
  reason?: string;
12
14
  durationMs: number;
15
+ /** Present when the step ran on your model: its name, grounding, turns and token use. */
16
+ model?: ModelRun;
13
17
  }
14
18
  export interface StepOptions {
15
- /** Milliseconds before the step is cancelled on Revyl. Defaults to stepTimeout from the config, or 180000. */
19
+ /** Milliseconds before the step is cancelled. Defaults to stepTimeout from the config, or 180000. */
16
20
  timeout?: number;
21
+ /** Runs this step on your model, such as 'anthropic/claude-sonnet-5-5', instead of Revyl's agent. Overrides the model passed to open(). */
22
+ model?: ModelOption;
23
+ /** Runs this step on a custom agent from localAgent(). Overrides the one passed to open(); pass model or agent, not both. */
24
+ agent?: LocalAgent;
17
25
  }
18
26
  export interface ValidationOptions extends StepOptions {
19
27
  /** Return a failed result instead of throwing VALIDATION_FAILED. */
@@ -95,11 +103,37 @@ export interface OpenedSession extends Session {
95
103
  report(): Promise<Record<string, unknown>>;
96
104
  navigate(url: string): Promise<void>;
97
105
  open(target: HostedTargetConfig): Promise<OpenedSession>;
106
+ /** Taps a point in device units, or an element Revyl locates from a description. Returns the point tapped. Browsers and devices only. */
107
+ tap(at: Location): Promise<Point>;
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
+ type(at: Location, text: string, options?: {
110
+ clear?: boolean;
111
+ }): Promise<Point>;
112
+ /** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. Browsers and devices only. */
113
+ swipe(direction: SwipeDirection, options?: {
114
+ from?: Location;
115
+ }): Promise<void>;
116
+ /** Presses Enter or Backspace on the focused field. Browsers and devices only. */
117
+ pressKey(key: 'enter' | 'backspace'): Promise<void>;
118
+ /** Goes back: browser history on Web, the back button on Android. Not on iOS. */
119
+ back(): Promise<void>;
120
+ /** Opens a URL or deep link on a device, or loads a URL in a browser. */
121
+ openUrl(url: string): Promise<void>;
122
+ /** Launches an installed app by bundle ID or package name. Devices only. */
123
+ launchApp(bundleId: string): Promise<void>;
124
+ /** Asks Revyl's grounding model where an element is on the current screen, in device units. Browsers and devices only. */
125
+ locate(target: string): Promise<Point>;
126
+ /** The screen size in device units: iOS points, Android and Web pixels. Browsers and devices only. */
127
+ screenSize(): Promise<{
128
+ width: number;
129
+ height: number;
130
+ }>;
98
131
  }
99
132
  export interface SessionOptions {
100
133
  stepTimeout: number;
101
134
  log: ((line: string) => void) | undefined;
102
135
  appUrl?: string;
136
+ agent?: LocalAgent;
103
137
  }
104
138
  export interface HostingParent {
105
139
  id: string;
@@ -125,6 +159,8 @@ export declare class DeviceSession implements Session {
125
159
  private stepRunning;
126
160
  private parent?;
127
161
  private readySignal?;
162
+ private size?;
163
+ private attribution?;
128
164
  get expiresAt(): Date | undefined;
129
165
  private constructor();
130
166
  static start(client: RevylClient, target: ResolvedDeviceTarget, options: SessionOptions, parent?: HostingParent): Promise<DeviceSession>;
@@ -143,6 +179,7 @@ export declare class DeviceSession implements Session {
143
179
  }): Promise<Uint8Array>;
144
180
  report(): Promise<Record<string, unknown>>;
145
181
  navigate(url: string): Promise<void>;
182
+ private navigateTo;
146
183
  exec(): Promise<ExecResult>;
147
184
  open(): Promise<never>;
148
185
  recording(): Promise<never>;
@@ -156,6 +193,38 @@ export declare class DeviceSession implements Session {
156
193
  [Symbol.asyncDispose](): Promise<void>;
157
194
  private step;
158
195
  private runStep;
196
+ private runLocal;
197
+ private agentControls;
198
+ /** Taps a point in device units, or an element Revyl locates from a description. Returns the point tapped. */
199
+ tap(at: Location): Promise<Point>;
200
+ /** Taps a text field and types into it, clearing it first unless clear is false. Returns the point tapped. */
201
+ type(at: Location, text: string, options?: {
202
+ clear?: boolean;
203
+ }): Promise<Point>;
204
+ /** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. */
205
+ swipe(direction: SwipeDirection, options?: {
206
+ from?: Location;
207
+ }): Promise<void>;
208
+ /** Presses Enter or Backspace on the focused field. */
209
+ pressKey(key: 'enter' | 'backspace'): Promise<void>;
210
+ /** Goes back: browser history on Web, the back button on Android. Not on iOS. */
211
+ back(): Promise<void>;
212
+ /** Opens a URL or deep link on a device, or loads a URL in a browser. */
213
+ openUrl(url: string): Promise<void>;
214
+ /** Launches an installed app by bundle ID or package name. Devices only. */
215
+ launchApp(bundleId: string): Promise<void>;
216
+ /** Asks Revyl's grounding model where an element is on the current screen, in device units. */
217
+ locate(target: string): Promise<Point>;
218
+ /** The screen size in device units: iOS points, Android and Web pixels. */
219
+ screenSize(): Promise<{
220
+ width: number;
221
+ height: number;
222
+ }>;
223
+ private readScreenSize;
224
+ private capture;
225
+ private perform;
226
+ private resolve;
227
+ private act;
159
228
  private launch;
160
229
  private waitForScreenContent;
161
230
  private log;
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,11 +8,12 @@ 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
- | [errors.md](errors.md) | Understand an error code and fix it |
15
+ | [own-model.md](own-model.md) | Run instructions and validations on your own model, such as Claude, GPT or a local model |
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
 
18
19
  [llms.txt](llms.txt) lists the same files in the llms.txt format.
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.