@revyl/verify 0.3.2 → 0.4.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.
- package/CHANGELOG.md +72 -0
- package/README.md +60 -23
- package/dist/agent/image.d.ts +7 -0
- package/dist/agent/image.js +29 -0
- package/dist/agent/loop.d.ts +97 -0
- package/dist/agent/loop.js +215 -0
- package/dist/agent/model.d.ts +91 -0
- package/dist/agent/model.js +255 -0
- package/dist/agent/providers.d.ts +23 -0
- package/dist/agent/providers.js +58 -0
- package/dist/api/client.d.ts +1 -0
- package/dist/api/client.js +2 -2
- package/dist/auth.js +2 -2
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +34 -9
- package/dist/cli/login.js +7 -7
- package/dist/cli/main.js +84 -29
- package/dist/computer-session.d.ts +9 -0
- package/dist/computer-session.js +27 -0
- package/dist/errors.d.ts +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/open.d.ts +6 -0
- package/dist/open.js +6 -1
- package/dist/session.d.ts +77 -2
- package/dist/session.js +294 -12
- package/docs/README.md +4 -3
- package/docs/agents.md +34 -5
- package/docs/api.md +99 -13
- package/docs/computers.md +9 -11
- package/docs/configuration.md +12 -8
- package/docs/errors.md +8 -6
- package/docs/getting-started.md +22 -17
- package/docs/llms.txt +11 -6
- package/docs/mobile.md +48 -0
- package/docs/own-model.md +216 -0
- package/docs/web.md +58 -1
- package/package.json +2 -3
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,10 @@ export class DeviceSession {
|
|
|
34
40
|
stepRunning = false;
|
|
35
41
|
parent;
|
|
36
42
|
readySignal;
|
|
43
|
+
size;
|
|
44
|
+
sizeSource;
|
|
45
|
+
screenshotSize;
|
|
46
|
+
attribution;
|
|
37
47
|
get expiresAt() {
|
|
38
48
|
return this.parent?.expiresAt;
|
|
39
49
|
}
|
|
@@ -139,7 +149,7 @@ export class DeviceSession {
|
|
|
139
149
|
: reason === 'computer_not_found' || response.status === 404 ? 'NOT_FOUND'
|
|
140
150
|
: reason === 'computer_not_running' ? 'SESSION_ENDED'
|
|
141
151
|
: 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'
|
|
152
|
+
: reason === 'computer_at_capacity' || reason === 'computer_platform_at_capacity' || reason === 'computer_limit' ? 'COMPUTER_LIMIT'
|
|
143
153
|
: response.status === 422 || response.status === 400 ? 'INVALID_ARGUMENT'
|
|
144
154
|
: response.status === 409 ? 'SESSION_BUSY'
|
|
145
155
|
: response.status === 408 || response.status >= 500 ? 'ACTION_UNCERTAIN' : 'API_ERROR';
|
|
@@ -178,7 +188,7 @@ export class DeviceSession {
|
|
|
178
188
|
}
|
|
179
189
|
/** 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
190
|
async instruction(text, options = {}) {
|
|
181
|
-
const result = await this.step('instruction', text, options
|
|
191
|
+
const result = await this.step('instruction', text, options);
|
|
182
192
|
if (!result.passed) {
|
|
183
193
|
throw new VerifyError('INSTRUCTION_FAILED', `Instruction failed: "${result.text}". ${sentence(result.reason)} See ${this.viewerUrl}`);
|
|
184
194
|
}
|
|
@@ -189,7 +199,7 @@ export class DeviceSession {
|
|
|
189
199
|
* unless soft is set, and STEP_FAILED when Revyl could not reach a verdict.
|
|
190
200
|
*/
|
|
191
201
|
async validation(text, options = {}) {
|
|
192
|
-
const result = await this.step('validation', text, options
|
|
202
|
+
const result = await this.step('validation', text, options);
|
|
193
203
|
if (!result.passed && !options.soft) {
|
|
194
204
|
throw new VerifyError('VALIDATION_FAILED', `Validation failed: "${result.text}". ${sentence(result.reason)} See ${this.viewerUrl}`);
|
|
195
205
|
}
|
|
@@ -215,9 +225,12 @@ export class DeviceSession {
|
|
|
215
225
|
throw new VerifyError('API_ERROR', 'Revyl returned an invalid session report.');
|
|
216
226
|
return report;
|
|
217
227
|
}
|
|
218
|
-
|
|
228
|
+
navigate(url) {
|
|
229
|
+
return this.navigateTo(url);
|
|
230
|
+
}
|
|
231
|
+
async navigateTo(url, signal) {
|
|
219
232
|
if (this.platform !== 'web')
|
|
220
|
-
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'navigate() is available only on web sessions.');
|
|
233
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'navigate() is available only on web sessions; use openUrl() on iOS and Android.');
|
|
221
234
|
if (typeof url !== 'string' || url.length > 2048 || /[\x00-\x20\x7f-\x9f\s\\]/.test(url))
|
|
222
235
|
throw new VerifyError('INVALID_ARGUMENT', 'navigate() requires an http(s) URL without credentials, whitespace, or backslashes.');
|
|
223
236
|
let parsed;
|
|
@@ -229,7 +242,8 @@ export class DeviceSession {
|
|
|
229
242
|
}
|
|
230
243
|
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password)
|
|
231
244
|
throw new VerifyError('INVALID_ARGUMENT', 'navigate() requires an http(s) URL without credentials.');
|
|
232
|
-
|
|
245
|
+
signal?.throwIfAborted();
|
|
246
|
+
const response = await this.worker('POST', 'open_url', { body: { url }, retry: false, ...(signal ? { signal } : {}) });
|
|
233
247
|
const result = parseJson(response, 'open_url');
|
|
234
248
|
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
249
|
throw new VerifyError('API_ERROR', 'Revyl returned an invalid navigation response.');
|
|
@@ -279,11 +293,12 @@ export class DeviceSession {
|
|
|
279
293
|
[Symbol.asyncDispose]() {
|
|
280
294
|
return this.close();
|
|
281
295
|
}
|
|
282
|
-
async step(type, text,
|
|
296
|
+
async step(type, text, options) {
|
|
283
297
|
if (typeof text !== 'string' || text.trim() === '' || text.length > MAX_STEP_TEXT_LENGTH) {
|
|
284
298
|
throw new VerifyError('INVALID_ARGUMENT', `${type}() takes a description of 1 to ${MAX_STEP_TEXT_LENGTH} characters.`);
|
|
285
299
|
}
|
|
286
|
-
const
|
|
300
|
+
const agent = chooseAgent(options, `${type}()`) ?? this.options.agent;
|
|
301
|
+
const timeoutMs = options.timeout ?? this.options.stepTimeout;
|
|
287
302
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000) {
|
|
288
303
|
throw new VerifyError('INVALID_ARGUMENT', `${type}() takes a timeout of at least 1000 ms.`);
|
|
289
304
|
}
|
|
@@ -294,6 +309,13 @@ export class DeviceSession {
|
|
|
294
309
|
const description = text.trim();
|
|
295
310
|
const startedAt = Date.now();
|
|
296
311
|
try {
|
|
312
|
+
if (agent) {
|
|
313
|
+
const local = await this.runLocal(type, description, timeoutMs, agent);
|
|
314
|
+
const result = { type, text: description, passed: local.passed, ...(local.reason ? { reason: local.reason } : {}), durationMs: Date.now() - startedAt, model: local.run };
|
|
315
|
+
this.steps.push(result);
|
|
316
|
+
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}` : ''}`);
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
297
319
|
const outcome = await this.runStep(type, description, timeoutMs);
|
|
298
320
|
const output = outcome.step_output ?? {};
|
|
299
321
|
const reason = output.status_reason ?? output.reasoning ?? undefined;
|
|
@@ -336,9 +358,17 @@ export class DeviceSession {
|
|
|
336
358
|
throw new VerifyError('API_ERROR', 'Revyl answered execute_step without a result or an accepted step id.');
|
|
337
359
|
}
|
|
338
360
|
stepId = body.step_id;
|
|
361
|
+
const acceptedAt = Date.now();
|
|
362
|
+
const liveStepNotFound = (response) => response.status === 404 && LIVE_STEP_NOT_FOUND.test(responseDetail(response) ?? '');
|
|
339
363
|
for (let wait = STEP_POLL_BASE_MS;; wait = Math.min(wait * 2, STEP_POLL_MAX_MS)) {
|
|
340
364
|
await delay(wait, undefined, { signal });
|
|
341
|
-
const
|
|
365
|
+
const response = await this.worker('GET', `step_status/${encodeURIComponent(stepId)}`, { signal, retry: true, allow: liveStepNotFound });
|
|
366
|
+
if (liveStepNotFound(response)) {
|
|
367
|
+
if (Date.now() - acceptedAt < LIVE_STEP_GRACE_MS)
|
|
368
|
+
continue;
|
|
369
|
+
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 });
|
|
370
|
+
}
|
|
371
|
+
const status = parseJson(response, 'step_status');
|
|
342
372
|
if (status.status === 'running')
|
|
343
373
|
continue;
|
|
344
374
|
if (status.status === 'cancelled') {
|
|
@@ -365,6 +395,231 @@ export class DeviceSession {
|
|
|
365
395
|
throw new VerifyError('TIMEOUT', `The ${type} did not finish within ${timeoutMs} ms and was cancelled: "${description}". See ${this.viewerUrl}`);
|
|
366
396
|
}
|
|
367
397
|
}
|
|
398
|
+
async runLocal(type, description, timeoutMs, agent) {
|
|
399
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
400
|
+
this.attribution = {
|
|
401
|
+
'X-Revyl-Agent': `Verify local: ${agent.model.name}`.replace(/[^\x20-\x7e]/g, '?').slice(0, 100),
|
|
402
|
+
'X-Revyl-Agent-Session-Id': randomUUID(),
|
|
403
|
+
};
|
|
404
|
+
try {
|
|
405
|
+
return await runLocalStep(type, description, agent, this.agentControls(), signal, (line) => this.log(line));
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
if (signal.aborted && !(isVerifyError(error) && error.code !== 'TIMEOUT')) {
|
|
409
|
+
throw new VerifyError('TIMEOUT', `The ${type} did not finish within ${timeoutMs} ms on ${agent.model.name}: "${description}". See ${this.viewerUrl}`, { cause: error });
|
|
410
|
+
}
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
this.attribution = undefined;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
agentControls() {
|
|
418
|
+
const platform = this.platform;
|
|
419
|
+
if (platform !== 'ios' && platform !== 'android' && platform !== 'web')
|
|
420
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Local agents run on browsers and devices, not on ${platform}.`);
|
|
421
|
+
return {
|
|
422
|
+
platform,
|
|
423
|
+
capture: (signal) => this.capture(signal),
|
|
424
|
+
screenSize: (signal) => this.readScreenSize(signal),
|
|
425
|
+
perform: (action, screenToken, signal) => this.perform(action, screenToken, signal),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/** Taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point tapped, in screenshot pixels. */
|
|
429
|
+
async tap(at) {
|
|
430
|
+
const location = await this.toDevice(checkLocation(at));
|
|
431
|
+
if (this.platform === 'ios')
|
|
432
|
+
await this.readScreenshotSize();
|
|
433
|
+
return this.toScreenshot((await this.perform({ kind: 'tap', at: location })));
|
|
434
|
+
}
|
|
435
|
+
/** Taps a text field and types into it, clearing it first unless clear is false. Returns the point tapped. */
|
|
436
|
+
async type(at, text, options = {}) {
|
|
437
|
+
if (typeof text !== 'string' || text.length > 16_384)
|
|
438
|
+
throw new VerifyError('INVALID_ARGUMENT', 'type() takes text of at most 16384 characters.');
|
|
439
|
+
const location = await this.toDevice(checkLocation(at));
|
|
440
|
+
if (this.platform === 'ios')
|
|
441
|
+
await this.readScreenshotSize();
|
|
442
|
+
return this.toScreenshot((await this.perform({ kind: 'type', at: location, text, clear: options.clear !== false })));
|
|
443
|
+
}
|
|
444
|
+
/** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. */
|
|
445
|
+
async swipe(direction, options = {}) {
|
|
446
|
+
if (!['up', 'down', 'left', 'right'].includes(direction))
|
|
447
|
+
throw new VerifyError('INVALID_ARGUMENT', "swipe() takes 'up', 'down', 'left' or 'right'.");
|
|
448
|
+
await this.perform({ kind: 'swipe', direction, ...(options.from ? { from: await this.toDevice(checkLocation(options.from)) } : {}) });
|
|
449
|
+
}
|
|
450
|
+
/** Presses Enter or Backspace on the focused field. */
|
|
451
|
+
async pressKey(key) {
|
|
452
|
+
if (key !== 'enter' && key !== 'backspace')
|
|
453
|
+
throw new VerifyError('INVALID_ARGUMENT', "pressKey() takes 'enter' or 'backspace'.");
|
|
454
|
+
await this.perform({ kind: 'key', key });
|
|
455
|
+
}
|
|
456
|
+
/** Goes back: browser history on Web, the back button on Android. Not on iOS. */
|
|
457
|
+
async back() {
|
|
458
|
+
await this.perform({ kind: 'back' });
|
|
459
|
+
}
|
|
460
|
+
/** Opens a URL or deep link on a device, or loads a URL in a browser. */
|
|
461
|
+
async openUrl(url) {
|
|
462
|
+
if (typeof url !== 'string' || url.length === 0 || url.length > 8_192)
|
|
463
|
+
throw new VerifyError('INVALID_ARGUMENT', 'openUrl() takes a URL of 1 to 8192 characters.');
|
|
464
|
+
if (this.platform === 'web' && !/^https?:/i.test(url))
|
|
465
|
+
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.');
|
|
466
|
+
await this.perform({ kind: 'open_url', url });
|
|
467
|
+
}
|
|
468
|
+
/** Launches an installed app by bundle ID or package name. Devices only. */
|
|
469
|
+
async launchApp(bundleId) {
|
|
470
|
+
if (this.platform === 'web')
|
|
471
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'launchApp() is available only on iOS and Android sessions; on web, use openUrl() or navigate().');
|
|
472
|
+
if (typeof bundleId !== 'string' || !/^[A-Za-z0-9._-]{1,255}$/.test(bundleId))
|
|
473
|
+
throw new VerifyError('INVALID_ARGUMENT', 'launchApp() takes a bundle ID or package name.');
|
|
474
|
+
await this.act('launch', { bundle_id: bundleId });
|
|
475
|
+
}
|
|
476
|
+
/** Asks Revyl's grounding model where an element is on the current screen, in screenshot pixels. */
|
|
477
|
+
async locate(target) {
|
|
478
|
+
const { x, y } = await this.resolve(checkLocation({ target }));
|
|
479
|
+
return this.toScreenshot({ x, y });
|
|
480
|
+
}
|
|
481
|
+
/** The screenshot's size in pixels, the space every coordinate uses. */
|
|
482
|
+
async screenSize() {
|
|
483
|
+
const { width, height } = await this.readScreenshotSize();
|
|
484
|
+
return { width, height };
|
|
485
|
+
}
|
|
486
|
+
/** The screenshot's size and the device size Revyl acts in. They differ on iOS, where Revyl acts in points and a screenshot has more pixels. */
|
|
487
|
+
async readScreenshotSize(fresh = false) {
|
|
488
|
+
if (fresh) {
|
|
489
|
+
const cached = [this.size, this.sizeSource, this.screenshotSize];
|
|
490
|
+
[this.size, this.sizeSource, this.screenshotSize] = [undefined, undefined, undefined];
|
|
491
|
+
try {
|
|
492
|
+
return await this.readScreenshotSize();
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
[this.size, this.sizeSource, this.screenshotSize] = cached;
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (this.screenshotSize)
|
|
500
|
+
return this.screenshotSize;
|
|
501
|
+
const device = await this.readScreenSize();
|
|
502
|
+
let size = device;
|
|
503
|
+
if (this.platform === 'ios') {
|
|
504
|
+
if (this.sizeSource !== 'health')
|
|
505
|
+
throw new VerifyError('ACTION_FAILED', `Revyl did not report this iOS screen's size in points, so Verify cannot map screenshot pixels to it and sent no action. See ${this.viewerUrl}`);
|
|
506
|
+
size = pngSize((await this.capture()).png);
|
|
507
|
+
}
|
|
508
|
+
this.screenshotSize = { width: size.width, height: size.height, device };
|
|
509
|
+
return this.screenshotSize;
|
|
510
|
+
}
|
|
511
|
+
async toDevice(at) {
|
|
512
|
+
if ('target' in at)
|
|
513
|
+
return at;
|
|
514
|
+
const read = async (fresh) => {
|
|
515
|
+
try {
|
|
516
|
+
return await this.readScreenshotSize(fresh);
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
if (this.platform === 'ios' || !(error instanceof VerifyError))
|
|
520
|
+
throw error;
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
let size = await read(false);
|
|
525
|
+
if (size && (at.x >= size.width || at.y >= size.height))
|
|
526
|
+
size = await read(true);
|
|
527
|
+
if (!size)
|
|
528
|
+
return at;
|
|
529
|
+
if (at.x >= size.width || at.y >= size.height) {
|
|
530
|
+
throw new VerifyError('INVALID_ARGUMENT', `(${at.x}, ${at.y}) is outside the screenshot: x must be 0 to ${size.width - 1} and y 0 to ${size.height - 1}, in screenshot pixels.`);
|
|
531
|
+
}
|
|
532
|
+
return { x: Math.min(size.device.width - 1, Math.round((at.x * size.device.width) / size.width)), y: Math.min(size.device.height - 1, Math.round((at.y * size.device.height) / size.height)) };
|
|
533
|
+
}
|
|
534
|
+
async toScreenshot(point) {
|
|
535
|
+
if (this.platform !== 'ios')
|
|
536
|
+
return point;
|
|
537
|
+
const size = await this.readScreenshotSize();
|
|
538
|
+
return { x: Math.round((point.x * size.width) / size.device.width), y: Math.round((point.y * size.height) / size.device.height) };
|
|
539
|
+
}
|
|
540
|
+
async readScreenSize(signal) {
|
|
541
|
+
if (this.size)
|
|
542
|
+
return this.size;
|
|
543
|
+
if (this.platform !== 'web') {
|
|
544
|
+
const health = parseJson(await this.worker('GET', 'health', { retry: true, ...(signal ? { signal } : {}) }), 'health');
|
|
545
|
+
if (Number.isInteger(health.screen_width) && Number.isInteger(health.screen_height) && health.screen_width > 0 && health.screen_height > 0) {
|
|
546
|
+
this.size = { width: health.screen_width, height: health.screen_height };
|
|
547
|
+
this.sizeSource = 'health';
|
|
548
|
+
return this.size;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const size = pngSize((await this.capture(signal)).png);
|
|
552
|
+
if (!size)
|
|
553
|
+
throw new VerifyError('ACTION_FAILED', 'Revyl returned a screenshot that is not a PNG image.');
|
|
554
|
+
this.size = size;
|
|
555
|
+
return size;
|
|
556
|
+
}
|
|
557
|
+
async capture(signal) {
|
|
558
|
+
const response = await this.worker('GET', 'screenshot', { retry: true, ...(signal ? { signal } : {}) });
|
|
559
|
+
if (!pngSize(response.body))
|
|
560
|
+
throw new VerifyError('ACTION_FAILED', 'Revyl returned a screenshot that is not a PNG image.');
|
|
561
|
+
const screenToken = this.platform === 'web' ? response.headers.get('x-revyl-screen-token') ?? undefined : undefined;
|
|
562
|
+
return { png: response.body, ...(screenToken ? { screenToken } : {}) };
|
|
563
|
+
}
|
|
564
|
+
async perform(action, screenToken, signal) {
|
|
565
|
+
switch (action.kind) {
|
|
566
|
+
case 'tap': {
|
|
567
|
+
if ('target' in action.at && this.platform !== 'web') {
|
|
568
|
+
signal?.throwIfAborted();
|
|
569
|
+
const result = parseJson(await this.worker('POST', 'tap_target', { body: { target: action.at.target, ...(this.id ? { session_id: this.id } : {}) }, ...(signal ? { signal } : {}) }), 'tap_target');
|
|
570
|
+
if (result.found === false)
|
|
571
|
+
throw new VerifyError('ACTION_FAILED', `Revyl could not find "${action.at.target}" on the screen.`);
|
|
572
|
+
if (result.success !== true || typeof result.x !== 'number' || typeof result.y !== 'number')
|
|
573
|
+
throw new VerifyError('ACTION_FAILED', `tap failed: ${result.error ?? 'no reason given'}`);
|
|
574
|
+
return { x: Math.round(result.x), y: Math.round(result.y) };
|
|
575
|
+
}
|
|
576
|
+
const point = await this.resolve(action.at, screenToken, signal);
|
|
577
|
+
await this.act('tap', pointBody(point, action.at), signal);
|
|
578
|
+
return { x: point.x, y: point.y };
|
|
579
|
+
}
|
|
580
|
+
case 'type': {
|
|
581
|
+
const point = await this.resolve(action.at, screenToken, signal);
|
|
582
|
+
await this.act('input', { ...pointBody(point, action.at), text: action.text, clear_first: action.clear }, signal);
|
|
583
|
+
return { x: point.x, y: point.y };
|
|
584
|
+
}
|
|
585
|
+
case 'swipe': {
|
|
586
|
+
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) }));
|
|
587
|
+
const direction = this.platform === 'web' ? { up: 'down', down: 'up', left: 'right', right: 'left' }[action.direction] : action.direction;
|
|
588
|
+
await this.act('swipe', { ...pointBody(point, action.from), direction, duration_ms: 500 }, signal);
|
|
589
|
+
return undefined;
|
|
590
|
+
}
|
|
591
|
+
case 'key':
|
|
592
|
+
await this.act('key', { key: this.platform === 'web' ? (action.key === 'enter' ? 'Enter' : 'Backspace') : action.key.toUpperCase() }, signal);
|
|
593
|
+
return undefined;
|
|
594
|
+
case 'back':
|
|
595
|
+
if (this.platform === 'ios')
|
|
596
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'back() is not available on iOS; tap the on-screen back control.');
|
|
597
|
+
await this.act('back', undefined, signal);
|
|
598
|
+
return undefined;
|
|
599
|
+
case 'open_url':
|
|
600
|
+
signal?.throwIfAborted();
|
|
601
|
+
if (this.platform === 'web')
|
|
602
|
+
await this.navigateTo(action.url, signal);
|
|
603
|
+
else
|
|
604
|
+
await this.act('open_url', { url: action.url }, signal);
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async resolve(at, screenToken, signal) {
|
|
609
|
+
if ('x' in at)
|
|
610
|
+
return { x: at.x, y: at.y, ...(screenToken ? { screenToken } : {}) };
|
|
611
|
+
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');
|
|
612
|
+
if (result.found !== true || typeof result.x !== 'number' || typeof result.y !== 'number') {
|
|
613
|
+
throw new VerifyError('ACTION_FAILED', `Revyl could not find "${at.target}" on the screen${result.error && this.platform !== 'web' ? `: ${result.error}` : ''}.`);
|
|
614
|
+
}
|
|
615
|
+
return { x: Math.round(result.x), y: Math.round(result.y), ...(result.screen_token ? { screenToken: result.screen_token } : {}) };
|
|
616
|
+
}
|
|
617
|
+
async act(action, body, signal) {
|
|
618
|
+
signal?.throwIfAborted();
|
|
619
|
+
const result = parseJson(await this.worker('POST', action, { body: body ?? {}, ...(signal ? { signal } : {}) }), action);
|
|
620
|
+
if (result.success !== true)
|
|
621
|
+
throw new VerifyError('ACTION_FAILED', `${action} failed on the ${this.target} session: ${result.error ?? 'no reason given'}. See ${this.viewerUrl}`);
|
|
622
|
+
}
|
|
368
623
|
async launch(bundleId) {
|
|
369
624
|
const response = await this.worker('POST', 'launch', { body: { bundle_id: bundleId } });
|
|
370
625
|
const result = parseJson(response, 'launch');
|
|
@@ -413,10 +668,20 @@ export class DeviceSession {
|
|
|
413
668
|
...(this.parent.expiresAt ? [AbortSignal.timeout(Math.max(0, this.parent.expiresAt.getTime() - Date.now()))] : []),
|
|
414
669
|
...(options.signal ? [options.signal] : []),
|
|
415
670
|
]) : options.signal;
|
|
416
|
-
const
|
|
417
|
-
|
|
671
|
+
const { allow, ...request } = options;
|
|
672
|
+
let response;
|
|
673
|
+
try {
|
|
674
|
+
response = await this.client.worker(this.workflowRunId, action, { method, ...request, signal, ...(this.attribution ? { headers: this.attribution } : {}) });
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
if (method === 'POST' && action !== 'resolve_target' && options.signal?.aborted && isVerifyError(error) && error.code === 'TIMEOUT') {
|
|
678
|
+
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 });
|
|
679
|
+
}
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
if ((response.status >= 200 && response.status < 300) || allow?.(response))
|
|
418
683
|
return response;
|
|
419
|
-
if (action === 'open_url') {
|
|
684
|
+
if (action === 'open_url' && this.platform === 'web') {
|
|
420
685
|
const error = this.workerError(method, action, response);
|
|
421
686
|
const reason = responseRefusalReason(response);
|
|
422
687
|
const message = error.code === 'ACTION_UNCERTAIN' && reason === 'request_timeout'
|
|
@@ -439,6 +704,9 @@ export class DeviceSession {
|
|
|
439
704
|
}
|
|
440
705
|
return new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${baseAction} is not available on ${this.platform} sessions (${reason}).`);
|
|
441
706
|
}
|
|
707
|
+
if (WEB_TARGET_ERRORS.has(code ?? detail ?? '')) {
|
|
708
|
+
return new VerifyError('ACTION_FAILED', `${baseAction} could not act on the screen (${code ?? detail}). Take a fresh screenshot and try again. See ${this.viewerUrl}`);
|
|
709
|
+
}
|
|
442
710
|
if (response.status === 404 || response.status === 410) {
|
|
443
711
|
return new VerifyError('SESSION_ENDED', `The ${this.target} session is no longer reachable (${reason}). See ${this.viewerUrl}`);
|
|
444
712
|
}
|
|
@@ -554,6 +822,20 @@ export function pngSize(bytes) {
|
|
|
554
822
|
const height = view.getUint32(20);
|
|
555
823
|
return width > 0 && height > 0 ? { width, height } : undefined;
|
|
556
824
|
}
|
|
825
|
+
function checkLocation(at) {
|
|
826
|
+
if (at && typeof at === 'object' && 'target' in at) {
|
|
827
|
+
if (typeof at.target !== 'string' || at.target.trim() === '' || at.target.length > 500)
|
|
828
|
+
throw new VerifyError('INVALID_ARGUMENT', 'A target is a description of 1 to 500 characters.');
|
|
829
|
+
return { target: at.target.trim() };
|
|
830
|
+
}
|
|
831
|
+
if (!at || typeof at !== 'object' || !Number.isInteger(at.x) || !Number.isInteger(at.y) || at.x < 0 || at.y < 0) {
|
|
832
|
+
throw new VerifyError('INVALID_ARGUMENT', 'A location is { x, y } in non-negative whole screenshot pixels, or { target: "description" }.');
|
|
833
|
+
}
|
|
834
|
+
return { x: at.x, y: at.y };
|
|
835
|
+
}
|
|
836
|
+
function pointBody(point, at) {
|
|
837
|
+
return { x: point.x, y: point.y, ...(point.screenToken ? { screen_token: point.screenToken } : {}), ...(at && 'target' in at ? { target: at.target } : {}) };
|
|
838
|
+
}
|
|
557
839
|
function formatDuration(ms) {
|
|
558
840
|
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
841
|
}
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -22,13 +22,42 @@ This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that
|
|
|
22
22
|
4. Run it with `node path/to/script.ts`. Share the session link from the first `[verify]` line with the user, so they can watch.
|
|
23
23
|
5. Read the outcome from the exit code and stderr. Each step prints `✓` or `✗` with its text; a failure prints `CODE: message`, ending with the session link. Look at any screenshot you saved before changing the script or the app.
|
|
24
24
|
|
|
25
|
+
## Exact actions and coordinates
|
|
26
|
+
|
|
27
|
+
Use `instruction()` when the step needs judgement, and exact actions (`tap`, `type`, `swipe`, `pressKey`, `locate`, `screenSize`) when you know exactly what to press. They work on Web, iOS and Android, not on computers.
|
|
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()` and `swipe()` unchanged. `screenSize()` returns the screenshot's width and height, and `locate()` and the points `tap()` and `type()` return use the same pixels. A point outside the screenshot is refused with `INVALID_ARGUMENT` before any action is sent, and the message names the valid range. On iOS, Verify converts the pixels to the screen's points for you. On Web they equal CSS pixels at the default device scale factor of 1, and you still pass screenshot pixels at any other. This holds from TypeScript 0.4.1, Python 0.2.1 and Go v0.3.1; earlier versions took iOS points.
|
|
30
|
+
|
|
31
|
+
`swipe('up')` moves the finger up, so content scrolls down, on every platform. Actions return once Revyl accepts them, so after a swipe, `openUrl` or `launchApp`, wait for the screen to settle before you `locate` or tap by coordinates.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { open } from '@revyl/verify';
|
|
35
|
+
|
|
36
|
+
const session = await open('ios');
|
|
37
|
+
try {
|
|
38
|
+
await session.tap({ target: 'General' }); // first choice: describe the element
|
|
39
|
+
const about = await session.locate('About'); // a point in screenshot pixels
|
|
40
|
+
await session.tap(about); // so it goes straight to tap()
|
|
41
|
+
|
|
42
|
+
await session.screenshot({ path: '.verify/screen.png' }); // open it and find what to press
|
|
43
|
+
await session.tap({ x: 660, y: 1500 }); // a position read off the screenshot, unchanged
|
|
44
|
+
await session.swipe('up', { from: { x: 660, y: 2000 } }); // swipe from a screenshot position too
|
|
45
|
+
const { width, height } = await session.screenSize(); // the screenshot's size
|
|
46
|
+
console.log(`x is 0 to ${width - 1}, y is 0 to ${height - 1}`);
|
|
47
|
+
} finally {
|
|
48
|
+
await session.close();
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The [Python](https://github.com/RevylAI/verify/blob/v0.4.1/python/README.md#exact-actions) and [Go](https://github.com/RevylAI/verify/blob/v0.4.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.
|
|
53
|
+
|
|
25
54
|
## Rules
|
|
26
55
|
|
|
27
|
-
Mac
|
|
56
|
+
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
57
|
|
|
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.
|
|
58
|
+
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
59
|
|
|
31
|
-
Computers have a hard
|
|
60
|
+
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
61
|
|
|
33
62
|
- 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
63
|
- Use `instruction` for doing and `validation` for checking. Do not ask an instruction to check something, or a validation to change anything.
|