@revyl/verify 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -3
- package/README.md +24 -17
- package/dist/agent/loop.d.ts +43 -1
- package/dist/agent/loop.js +118 -20
- package/dist/computer-session.d.ts +22 -3
- package/dist/computer-session.js +180 -14
- package/dist/index.d.ts +1 -1
- package/dist/session.d.ts +40 -7
- package/dist/session.js +67 -10
- package/docs/README.md +1 -1
- package/docs/agents.md +9 -5
- package/docs/api.md +22 -12
- package/docs/ci.md +1 -1
- package/docs/computers.md +71 -2
- package/docs/errors.md +29 -6
- package/docs/llms.txt +5 -3
- package/docs/mobile.md +34 -7
- package/docs/own-model.md +4 -4
- package/docs/web.md +38 -6
- package/package.json +1 -1
package/dist/computer-session.js
CHANGED
|
@@ -6,13 +6,26 @@ import { edgeBlockedError, responseDetail, responseErrorCode, responseRefusalRea
|
|
|
6
6
|
import { trackSession, untrackSession } from './cleanup.js';
|
|
7
7
|
import { validateHostedTarget } from './config.js';
|
|
8
8
|
import { VerifyError, isVerifyError } from './errors.js';
|
|
9
|
-
import {
|
|
9
|
+
import { WEB_KEYS, checkKey } from './agent/loop.js';
|
|
10
|
+
import { DeviceSession, checkLocation, pngSize } from './session.js';
|
|
10
11
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
12
|
const STATES = new Set(['starting', 'active', 'closing', 'stopped', 'failed', 'owner_lost']);
|
|
12
13
|
const MAX_OUTPUT_BYTES = 1_048_576;
|
|
13
14
|
const COMPUTER_AVAILABILITY = 'Computers are early access. Ask support@revyl.ai about access.';
|
|
14
15
|
const COMPUTER_CAPACITY = 'Computer admission was refused because of queued work (including iOS) or the concurrency limit. Verify will not retry the start.';
|
|
15
|
-
const ERROR_CODES = new Set(['computer_not_enabled', 'run_closed', 'inaccessible', 'invalid_handle', 'operation_not_found', 'owner_unavailable', 'invalid_request', 'unauthenticated', 'forbidden', 'runtime_unavailable', 'start_failed', 'computer_limit', 'request_conflict', 'run_conflict', 'evidence_conflict', 'evidence_unavailable', 'authentication_unavailable', 'operation_limit', 'screen_starting', 'screen_unavailable']);
|
|
16
|
+
const ERROR_CODES = new Set(['computer_not_enabled', 'run_closed', 'inaccessible', 'invalid_handle', 'operation_not_found', 'owner_unavailable', 'invalid_request', 'unauthenticated', 'forbidden', 'runtime_unavailable', 'start_failed', 'computer_limit', 'request_conflict', 'run_conflict', 'evidence_conflict', 'evidence_unavailable', 'authentication_unavailable', 'operation_limit', 'screen_starting', 'screen_unavailable', 'screen_control_held', 'screen_control_lost', 'screen_input_unavailable', 'screen_input_uncertain', 'screen_key_unsupported', 'screen_text_unsupported', 'screen_target_not_found', 'screen_target_unavailable']);
|
|
17
|
+
const COMPUTER_KEYS = { enter: 'Return', backspace: 'BackSpace', tab: 'Tab', escape: 'Escape', delete: 'Delete', space: 'space', arrowup: 'Up', arrowdown: 'Down', arrowleft: 'Left', arrowright: 'Right', home: 'Home', end: 'End', pageup: 'Page_Up', pagedown: 'Page_Down' };
|
|
18
|
+
const REFUSAL_NEXT_STEPS = {
|
|
19
|
+
AUTH_REJECTED: "Check that REVYL_API_KEY is a current key and that its owner has the Member role or above in this computer's organization.",
|
|
20
|
+
SESSION_ENDED: 'This computer has stopped; start a new computer to continue.',
|
|
21
|
+
NOT_FOUND: 'Check that the id is right and that REVYL_API_KEY belongs to the organization that started this computer.',
|
|
22
|
+
ACTION_UNCERTAIN: "The request may have taken effect. Verify will not repeat it; check the computer's state before you do.",
|
|
23
|
+
INVALID_ARGUMENT: "Check the call's arguments against the docs; if they match, update Verify to the latest version.",
|
|
24
|
+
SESSION_START_FAILED: 'Start a new computer.',
|
|
25
|
+
SESSION_BUSY: 'The computer is busy with other work; try again once it finishes.',
|
|
26
|
+
API_ERROR: 'Try again; if it keeps failing, send this message to support@revyl.ai.',
|
|
27
|
+
};
|
|
28
|
+
const SCREEN_CALLS = 'For the screen, use screenshot(), tap({ x, y }) or tap({ target }), typeFocused(text, { clear: false }) and pressKey(); for commands, use exec().';
|
|
16
29
|
export class ComputerSession {
|
|
17
30
|
client;
|
|
18
31
|
options;
|
|
@@ -123,21 +136,115 @@ export class ComputerSession {
|
|
|
123
136
|
navigate() {
|
|
124
137
|
return this.unavailable('navigate');
|
|
125
138
|
}
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
/** Clicks a point in screenshot pixels, or an element Revyl finds from a description, with the left button. Returns the point clicked. */
|
|
140
|
+
async tap(at) {
|
|
141
|
+
if (at && typeof at === 'object' && 'target' in at && ('x' in at || 'y' in at))
|
|
142
|
+
throw new VerifyError('INVALID_ARGUMENT', 'tap() takes a point { x, y } or a description { target }, not both.');
|
|
143
|
+
const location = checkLocation(at);
|
|
144
|
+
if ('target' in location)
|
|
145
|
+
return this.tapTarget(location.target);
|
|
146
|
+
const { x, y } = location;
|
|
147
|
+
let response;
|
|
148
|
+
try {
|
|
149
|
+
response = await this.act({ action: 'click', x, y });
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (isVerifyError(error) && error.code === 'INVALID_ARGUMENT' && error.httpStatus === 422)
|
|
153
|
+
throw new VerifyError('INVALID_ARGUMENT', `Nothing was clicked: the computer refused a click at (${x}, ${y}). That happens when the point is no longer on its screen, for example after its display size changed. Take a new screenshot() and pick the point from it. See ${this.viewerUrl}`, { cause: error, reason: error.reason, httpStatus: 422 });
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
if (response.status === 422) {
|
|
157
|
+
const size = await this.screenSize().catch(() => undefined);
|
|
158
|
+
const range = size ? `x is 0 to ${size.width - 1} and y is 0 to ${size.height - 1}, in pixels of the image screenshot() returns.` : 'Use a point inside the image screenshot() returns.';
|
|
159
|
+
throw new VerifyError('INVALID_ARGUMENT', `Nothing was clicked: (${x}, ${y}) is outside the screen. ${range}`);
|
|
160
|
+
}
|
|
161
|
+
return { x, y };
|
|
162
|
+
}
|
|
163
|
+
async tapTarget(target) {
|
|
164
|
+
let response;
|
|
165
|
+
try {
|
|
166
|
+
response = await this.act({ action: 'click', target }, 90_000);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (isVerifyError(error) && error.code === 'INVALID_ARGUMENT' && error.httpStatus === 422)
|
|
170
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Nothing was clicked: this Revyl backend can't click a described target on a computer yet (invalid_request). Read the point off screenshot() and pass it to tap({ x, y }). See ${this.viewerUrl}`, { cause: error, httpStatus: 422 });
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
let point;
|
|
174
|
+
try {
|
|
175
|
+
point = JSON.parse(new TextDecoder().decode(response.body))?.point;
|
|
176
|
+
}
|
|
177
|
+
catch { }
|
|
178
|
+
const { x, y } = point ?? {};
|
|
179
|
+
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0)
|
|
180
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Revyl answered the described click without the point it clicked, so where it landed is unknown. Take a screenshot() to check. See ${this.viewerUrl}`);
|
|
181
|
+
return { x: x, y: y };
|
|
128
182
|
}
|
|
129
183
|
type() {
|
|
130
184
|
return this.unavailable('type');
|
|
131
185
|
}
|
|
186
|
+
/** Types into whatever has keyboard focus. Computers can't clear a field, so pass { clear: false }. */
|
|
187
|
+
async typeFocused(text, options = {}) {
|
|
188
|
+
if (options.clear !== false)
|
|
189
|
+
throw new VerifyError('INVALID_ARGUMENT', "On a computer, typeFocused() can't clear the field first, so pass { clear: false } to type at the cursor: typeFocused(text, { clear: false }). To replace text, select it first, for example with pressKey('a', { modifiers: ['meta'] }) (Command+A on a Mac computer).");
|
|
190
|
+
if (typeof text !== 'string' || text.length > 16_384)
|
|
191
|
+
throw new VerifyError('INVALID_ARGUMENT', 'typeFocused() takes text of at most 16384 characters.');
|
|
192
|
+
const control = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.exec(text);
|
|
193
|
+
if (control)
|
|
194
|
+
throw new VerifyError('INVALID_ARGUMENT', `Nothing was typed: typeFocused() on a computer takes newline and tab but no other control characters, and the text has one at index ${control.index}. Use pressKey() for keys such as 'escape'.`);
|
|
195
|
+
const chunks = [];
|
|
196
|
+
let chunk = '';
|
|
197
|
+
for (const character of text) {
|
|
198
|
+
if ([...chunk].length === 500 || Buffer.byteLength(chunk + character) > 1_500) {
|
|
199
|
+
chunks.push(chunk);
|
|
200
|
+
chunk = '';
|
|
201
|
+
}
|
|
202
|
+
chunk += character;
|
|
203
|
+
}
|
|
204
|
+
if (chunk)
|
|
205
|
+
chunks.push(chunk);
|
|
206
|
+
const total = [...text].length;
|
|
207
|
+
let typed = 0;
|
|
208
|
+
for (const part of chunks) {
|
|
209
|
+
const length = [...part].length;
|
|
210
|
+
try {
|
|
211
|
+
await this.act({ action: 'type', text: part });
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (chunks.length === 1 || !isVerifyError(error))
|
|
215
|
+
throw error;
|
|
216
|
+
const uncertain = error.code === 'ACTION_UNCERTAIN';
|
|
217
|
+
const done = typed === 0 ? `typed nothing${uncertain ? ' for certain' : ''}: the first` : `typed the first ${typed} of ${total} characters; the next`;
|
|
218
|
+
throw new VerifyError(error.code, `typeFocused() ${done} ${length}${typed === 0 ? ` of ${total}` : ''} characters ${uncertain ? 'may have been typed' : 'were not typed'}, and nothing after them was. ${error.message}`, { cause: error, reason: error.reason, httpStatus: error.httpStatus });
|
|
219
|
+
}
|
|
220
|
+
typed += length;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
doubleTap() {
|
|
224
|
+
return this.unavailable('doubleTap');
|
|
225
|
+
}
|
|
226
|
+
longPress() {
|
|
227
|
+
return this.unavailable('longPress');
|
|
228
|
+
}
|
|
132
229
|
swipe() {
|
|
133
230
|
return this.unavailable('swipe');
|
|
134
231
|
}
|
|
135
|
-
|
|
136
|
-
return this.unavailable('
|
|
232
|
+
drag() {
|
|
233
|
+
return this.unavailable('drag');
|
|
234
|
+
}
|
|
235
|
+
/** Presses a key with modifiers held. 'meta' is the Command key on a Mac computer, and 'alt' is Option. */
|
|
236
|
+
async pressKey(key, options = {}) {
|
|
237
|
+
const checked = checkKey('web', key, options.modifiers ?? []);
|
|
238
|
+
const name = checked.key in WEB_KEYS ? COMPUTER_KEYS[checked.key] : checked.key;
|
|
239
|
+
const modifiers = [...new Set(checked.modifiers)].map((modifier) => (modifier === 'meta' ? 'cmd' : modifier));
|
|
240
|
+
await this.act({ action: 'key', keys: [...modifiers, name].join('+') });
|
|
137
241
|
}
|
|
138
242
|
back() {
|
|
139
243
|
return this.unavailable('back');
|
|
140
244
|
}
|
|
245
|
+
goHome() {
|
|
246
|
+
return this.unavailable('goHome');
|
|
247
|
+
}
|
|
141
248
|
openUrl() {
|
|
142
249
|
return this.unavailable('openUrl');
|
|
143
250
|
}
|
|
@@ -145,10 +252,12 @@ export class ComputerSession {
|
|
|
145
252
|
return this.unavailable('launchApp');
|
|
146
253
|
}
|
|
147
254
|
locate() {
|
|
148
|
-
return
|
|
255
|
+
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', "locate() isn't available on computers, because Revyl can't find a point there without clicking it. tap({ target }) clicks a description and returns the point it clicked; to choose the point yourself, read it off screenshot() and pass it to tap({ x, y })."));
|
|
149
256
|
}
|
|
150
|
-
|
|
151
|
-
|
|
257
|
+
/** The screenshot's size in pixels, the space tap() uses. */
|
|
258
|
+
async screenSize() {
|
|
259
|
+
const { width, height } = pngSize(await this.screenshot());
|
|
260
|
+
return { width, height };
|
|
152
261
|
}
|
|
153
262
|
get expiresAt() {
|
|
154
263
|
return this.expiresAtMs === undefined ? undefined : new Date(this.expiresAtMs);
|
|
@@ -391,13 +500,15 @@ export class ComputerSession {
|
|
|
391
500
|
throw error;
|
|
392
501
|
}
|
|
393
502
|
}
|
|
394
|
-
async request(method, suffix, body, signal) {
|
|
503
|
+
async request(method, suffix, body, signal, timeoutMs) {
|
|
395
504
|
const path = `/api/v1/computer-runs${suffix === '' ? '' : `/${this.id}${suffix}`}`;
|
|
396
505
|
let response;
|
|
397
506
|
try {
|
|
398
|
-
response = await this.client.send(method, path, { body, signal, timeoutMs: suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000, retry: method === 'GET' && suffix !== '/screen/screenshot', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
|
|
507
|
+
response = await this.client.send(method, path, { body, signal, timeoutMs: timeoutMs ?? (suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000), retry: method === 'GET' && suffix !== '/screen/screenshot', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
|
|
399
508
|
}
|
|
400
509
|
catch (error) {
|
|
510
|
+
if (suffix === '/screen/actions')
|
|
511
|
+
throw new VerifyError('ACTION_UNCERTAIN', `The screen action may have happened: Revyl's answer never arrived. Verify will not repeat it; take a screenshot() to check before you do. See ${this.viewerUrl}`);
|
|
401
512
|
if (method === 'POST' || suffix.startsWith('/operations/'))
|
|
402
513
|
throw new VerifyError('ACTION_UNCERTAIN', `Computer request outcome is unconfirmed. Verify will not repeat it. See ${this.viewerUrl}`);
|
|
403
514
|
if (isVerifyError(error))
|
|
@@ -413,6 +524,7 @@ export class ComputerSession {
|
|
|
413
524
|
return response;
|
|
414
525
|
const detail = responseRefusalReason(response) ?? responseDetail(response);
|
|
415
526
|
const refusal = { reason: responseRefusalReason(response), httpStatus: response.status };
|
|
527
|
+
const reason = detail && ERROR_CODES.has(detail) ? detail : `HTTP ${response.status}`;
|
|
416
528
|
if (suffix === '/screen/screenshot') {
|
|
417
529
|
let raw = null;
|
|
418
530
|
try {
|
|
@@ -428,6 +540,30 @@ export class ComputerSession {
|
|
|
428
540
|
}
|
|
429
541
|
if (response.status === 429 && detail === 'computer_limit')
|
|
430
542
|
throw new VerifyError('COMPUTER_LIMIT', `${COMPUTER_CAPACITY} See ${this.viewerUrl}`, refusal);
|
|
543
|
+
if (suffix === '/screen/actions') {
|
|
544
|
+
if (response.status === 422 && detail === 'screen_point_outside')
|
|
545
|
+
return response;
|
|
546
|
+
if (response.status === 409 && detail === 'screen_starting')
|
|
547
|
+
return response;
|
|
548
|
+
if (detail === 'screen_target_not_found')
|
|
549
|
+
throw new VerifyError('ACTION_FAILED', `Nothing was clicked: Revyl could not find the described target on the screen (screen_target_not_found). Take a screenshot() and pass a point to tap({ x, y }). See ${this.viewerUrl}`, refusal);
|
|
550
|
+
if (detail === 'screen_target_unavailable')
|
|
551
|
+
throw new VerifyError('API_ERROR', `Nothing was clicked: Revyl could not look up the described target just now (screen_target_unavailable). It is safe to try again. See ${this.viewerUrl}`, refusal);
|
|
552
|
+
if (detail === 'screen_control_held')
|
|
553
|
+
throw new VerifyError('SESSION_BUSY', `Nothing happened: someone else is controlling this computer's screen in the viewer (screen_control_held). Try again after they release it; control frees itself after 2 minutes without input. See ${this.viewerUrl}`, refusal);
|
|
554
|
+
if (detail === 'screen_input_uncertain' || detail === 'screen_unavailable' || detail === 'screen_input_unavailable')
|
|
555
|
+
throw new VerifyError('ACTION_UNCERTAIN', `The screen action may have happened (${detail}). Verify will not repeat it; take a screenshot() to check before you do. See ${this.viewerUrl}`, refusal);
|
|
556
|
+
if (response.status === 403)
|
|
557
|
+
throw new VerifyError('AUTH_REJECTED', `Revyl refused the screen action (forbidden). The API key's owner needs the Member role or above in the organization, and CI runner keys can't control the screen yet. See ${this.viewerUrl}`, refusal);
|
|
558
|
+
if (response.status === 503 && detail === 'authentication_unavailable')
|
|
559
|
+
throw new VerifyError('API_ERROR', `Nothing happened: Revyl could not confirm your access just now (authentication_unavailable). It is safe to try again. See ${this.viewerUrl}`, refusal);
|
|
560
|
+
if (detail === 'screen_text_unsupported')
|
|
561
|
+
throw new VerifyError('INVALID_ARGUMENT', `Nothing was typed: this computer can't type some of these characters (screen_text_unsupported). See ${this.viewerUrl}`, refusal);
|
|
562
|
+
if (detail === 'screen_control_lost')
|
|
563
|
+
throw new VerifyError('SESSION_BUSY', `Nothing happened: control of this computer's screen changed hands before the action was sent (screen_control_lost). Try again; if someone is controlling the screen in the viewer, wait until they release it, which happens on its own after 2 minutes without input. See ${this.viewerUrl}`, refusal);
|
|
564
|
+
if (detail === 'screen_key_unsupported')
|
|
565
|
+
throw new VerifyError('INVALID_ARGUMENT', `Nothing was pressed: this computer can't press that key (screen_key_unsupported). Use a key pressKey() lists in the docs, or typeFocused() for a printable character. See ${this.viewerUrl}`, refusal);
|
|
566
|
+
}
|
|
431
567
|
let code = 'API_ERROR';
|
|
432
568
|
if (response.status === 401 || response.status === 403)
|
|
433
569
|
code = 'AUTH_REJECTED';
|
|
@@ -447,8 +583,38 @@ export class ComputerSession {
|
|
|
447
583
|
code = 'ACTION_UNCERTAIN';
|
|
448
584
|
else if (method === 'POST' && (response.status >= 500 || response.status < 400))
|
|
449
585
|
code = 'ACTION_UNCERTAIN';
|
|
450
|
-
|
|
451
|
-
|
|
586
|
+
let next = REFUSAL_NEXT_STEPS[code];
|
|
587
|
+
if (suffix === '/screen/actions' && code === 'ACTION_UNCERTAIN')
|
|
588
|
+
next = 'The screen action may have happened. Verify will not repeat it; take a screenshot() to check before you do.';
|
|
589
|
+
else if (suffix === '/screen/actions' && code === 'INVALID_ARGUMENT')
|
|
590
|
+
next = 'Part of the screen action may have run, so take a screenshot() to check the screen before you try again.';
|
|
591
|
+
else if (code === 'ACTION_UNCERTAIN' && method === 'GET' && !suffix.startsWith('/operations/'))
|
|
592
|
+
next = 'Nothing on the computer changed; it is safe to try again.';
|
|
593
|
+
throw new VerifyError(code, `Revyl refused the computer request (${reason}). ${next} See ${this.viewerUrl}`, refusal);
|
|
594
|
+
}
|
|
595
|
+
async act(action, timeoutMs = 30_000) {
|
|
596
|
+
this.assertOpen();
|
|
597
|
+
const signal = this.observationSignal(timeoutMs);
|
|
598
|
+
const deadlineMs = Date.now() + 30_000;
|
|
599
|
+
for (;;) {
|
|
600
|
+
const response = await this.request('POST', '/screen/actions', { action, screenshot: false }, signal, timeoutMs);
|
|
601
|
+
if (response.status !== 409)
|
|
602
|
+
return response;
|
|
603
|
+
const retryAfter = Number(response.headers.get('retry-after'));
|
|
604
|
+
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 60) * 1_000 : 1_000;
|
|
605
|
+
const seconds = Math.ceil(waitMs / 1_000);
|
|
606
|
+
const starting = new VerifyError('SESSION_BUSY', `Nothing happened: the computer's screen is still starting (screen_starting). Try again in ${seconds} second${seconds === 1 ? '' : 's'}. See ${this.viewerUrl}`, { httpStatus: 409 });
|
|
607
|
+
if (Date.now() + waitMs >= deadlineMs)
|
|
608
|
+
throw starting;
|
|
609
|
+
try {
|
|
610
|
+
await delay(waitMs, undefined, { signal });
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
this.assertOpen();
|
|
614
|
+
throw starting;
|
|
615
|
+
}
|
|
616
|
+
this.assertOpen();
|
|
617
|
+
}
|
|
452
618
|
}
|
|
453
619
|
parseReport(response) {
|
|
454
620
|
const report = parseComputerJson(response);
|
|
@@ -467,7 +633,7 @@ export class ComputerSession {
|
|
|
467
633
|
return AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(Math.max(0, remainingMs))]);
|
|
468
634
|
}
|
|
469
635
|
unavailable(method) {
|
|
470
|
-
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on computers
|
|
636
|
+
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on computers. ${SCREEN_CALLS} See ${this.viewerUrl}`));
|
|
471
637
|
}
|
|
472
638
|
log(line) {
|
|
473
639
|
this.options.log?.(`${this.target}: ${line}`);
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export type { Platform, ComputerPlatform, MobilePlatform } from './api/types.js'
|
|
|
7
7
|
export { VerifyError, isVerifyError } from './errors.js';
|
|
8
8
|
export type { RefusalReason, VerifyErrorCode } from './errors.js';
|
|
9
9
|
export { localAgent } from './agent/loop.js';
|
|
10
|
-
export type { Grounding, LocalAgent, LocalAgentOptions, Location, ModelRun, ModelTurn, Point, SwipeDirection, Target } from './agent/loop.js';
|
|
10
|
+
export type { Grounding, Key, KeyModifier, LocalAgent, LocalAgentOptions, Location, ModelRun, ModelTurn, Point, SwipeDirection, Target } from './agent/loop.js';
|
|
11
11
|
export type { CustomModel, ModelOption } from './agent/providers.js';
|
|
12
12
|
export { anthropic, openaiCompatible } from './agent/model.js';
|
|
13
13
|
export type { AnthropicOptions, JsonSchema, ModelAdapter, ModelReply, ModelRequest, ModelTool, ModelToolCall, ModelUsage, OpenAICompatibleOptions } from './agent/model.js';
|
package/dist/session.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { RevylClient } from './api/client.js';
|
|
2
2
|
import type { LiveStepType, Platform } from './api/types.js';
|
|
3
3
|
import type { HostedTargetConfig, ResolvedDeviceTarget } from './config.js';
|
|
4
|
-
import { type LocalAgent, type Location, type ModelRun, type Point, type SwipeDirection } from './agent/loop.js';
|
|
4
|
+
import { type Key, type KeyModifier, type LocalAgent, type Location, type ModelRun, type Point, type SwipeDirection } from './agent/loop.js';
|
|
5
5
|
import { type ModelOption } from './agent/providers.js';
|
|
6
6
|
export type StepType = LiveStepType;
|
|
7
7
|
/** The outcome of one instruction or validation, as Revyl's agent reported it. */
|
|
@@ -103,27 +103,43 @@ export interface OpenedSession extends Session {
|
|
|
103
103
|
report(): Promise<Record<string, unknown>>;
|
|
104
104
|
navigate(url: string): Promise<void>;
|
|
105
105
|
open(target: HostedTargetConfig): Promise<OpenedSession>;
|
|
106
|
-
/** Taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point tapped, in screenshot pixels.
|
|
106
|
+
/** Taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point tapped, in screenshot pixels. */
|
|
107
107
|
tap(at: Location): Promise<Point>;
|
|
108
108
|
/** Taps a text field and types into it, clearing it first unless clear is false. Returns the point tapped. Browsers and devices only. */
|
|
109
109
|
type(at: Location, text: string, options?: {
|
|
110
110
|
clear?: boolean;
|
|
111
111
|
}): Promise<Point>;
|
|
112
|
+
/** Types into the field that already has focus, without tapping, replacing its content unless clear is false. Web and Mac computers, where clear must be false; on iOS and Android, use type(location, text). */
|
|
113
|
+
typeFocused(text: string, options?: {
|
|
114
|
+
clear?: boolean;
|
|
115
|
+
}): Promise<void>;
|
|
116
|
+
/** Double-taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point, in screenshot pixels. Browsers and devices only. */
|
|
117
|
+
doubleTap(at: Location): Promise<Point>;
|
|
118
|
+
/** Presses and holds a point or element for duration milliseconds (1 to 5000, default 1500). Returns the point, in screenshot pixels. Browsers and devices only. */
|
|
119
|
+
longPress(at: Location, options?: {
|
|
120
|
+
duration?: number;
|
|
121
|
+
}): Promise<Point>;
|
|
112
122
|
/** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. Browsers and devices only. */
|
|
113
123
|
swipe(direction: SwipeDirection, options?: {
|
|
114
124
|
from?: Location;
|
|
115
125
|
}): Promise<void>;
|
|
116
|
-
/** Presses
|
|
117
|
-
|
|
126
|
+
/** Presses at one point or element, moves to another and releases. Browsers and devices only. */
|
|
127
|
+
drag(from: Location, to: Location): Promise<void>;
|
|
128
|
+
/** Presses a key on the focused element. iOS and Android take 'enter' and 'backspace'; Web and Mac computers also take the other Key names, and modifiers, with which a lowercase letter or digit also works. On a Mac computer 'meta' is Command. */
|
|
129
|
+
pressKey(key: Key | string, options?: {
|
|
130
|
+
modifiers?: KeyModifier[];
|
|
131
|
+
}): Promise<void>;
|
|
118
132
|
/** Goes back: browser history on Web, the back button on Android. Not on iOS. */
|
|
119
133
|
back(): Promise<void>;
|
|
134
|
+
/** Goes to the home screen on iOS and Android. Not on Web. */
|
|
135
|
+
goHome(): Promise<void>;
|
|
120
136
|
/** Opens a URL or deep link on a device, or loads a URL in a browser. */
|
|
121
137
|
openUrl(url: string): Promise<void>;
|
|
122
138
|
/** Launches an installed app by bundle ID or package name. Devices only. */
|
|
123
139
|
launchApp(bundleId: string): Promise<void>;
|
|
124
140
|
/** Asks Revyl's grounding model where an element is on the current screen, in screenshot pixels. Browsers and devices only. */
|
|
125
141
|
locate(target: string): Promise<Point>;
|
|
126
|
-
/** The screenshot's size in pixels, the space every coordinate uses.
|
|
142
|
+
/** The screenshot's size in pixels, the space every coordinate uses. */
|
|
127
143
|
screenSize(): Promise<{
|
|
128
144
|
width: number;
|
|
129
145
|
height: number;
|
|
@@ -203,14 +219,30 @@ export declare class DeviceSession implements Session {
|
|
|
203
219
|
type(at: Location, text: string, options?: {
|
|
204
220
|
clear?: boolean;
|
|
205
221
|
}): Promise<Point>;
|
|
222
|
+
/** Types into the field that already has focus, without tapping, replacing its content unless clear is false. Web only. */
|
|
223
|
+
typeFocused(text: string, options?: {
|
|
224
|
+
clear?: boolean;
|
|
225
|
+
}): Promise<void>;
|
|
226
|
+
/** Double-taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point, in screenshot pixels. */
|
|
227
|
+
doubleTap(at: Location): Promise<Point>;
|
|
228
|
+
/** Presses and holds a point or element for duration milliseconds (1 to 5000, default 1500). Returns the point, in screenshot pixels. */
|
|
229
|
+
longPress(at: Location, options?: {
|
|
230
|
+
duration?: number;
|
|
231
|
+
}): Promise<Point>;
|
|
206
232
|
/** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. */
|
|
207
233
|
swipe(direction: SwipeDirection, options?: {
|
|
208
234
|
from?: Location;
|
|
209
235
|
}): Promise<void>;
|
|
210
|
-
/** Presses
|
|
211
|
-
|
|
236
|
+
/** Presses at one point or element, moves to another and releases. */
|
|
237
|
+
drag(from: Location, to: Location): Promise<void>;
|
|
238
|
+
/** Presses a key on the focused element, with modifiers held on Web. */
|
|
239
|
+
pressKey(key: Key | string, options?: {
|
|
240
|
+
modifiers?: KeyModifier[];
|
|
241
|
+
}): Promise<void>;
|
|
212
242
|
/** Goes back: browser history on Web, the back button on Android. Not on iOS. */
|
|
213
243
|
back(): Promise<void>;
|
|
244
|
+
/** Goes to the home screen on iOS and Android. */
|
|
245
|
+
goHome(): Promise<void>;
|
|
214
246
|
/** Opens a URL or deep link on a device, or loads a URL in a browser. */
|
|
215
247
|
openUrl(url: string): Promise<void>;
|
|
216
248
|
/** Launches an installed app by bundle ID or package name. Devices only. */
|
|
@@ -245,3 +277,4 @@ export declare function pngSize(bytes: Uint8Array): {
|
|
|
245
277
|
width: number;
|
|
246
278
|
height: number;
|
|
247
279
|
} | undefined;
|
|
280
|
+
export declare function checkLocation(at: Location): Location;
|
package/dist/session.js
CHANGED
|
@@ -6,7 +6,7 @@ import { apiError, edgeBlockedError, parseJson, responseDetail, responseErrorCod
|
|
|
6
6
|
import { trackSession, untrackSession } from './cleanup.js';
|
|
7
7
|
import { VerifyError, errorMessage, isVerifyError } from './errors.js';
|
|
8
8
|
import { screenHasContent } from './screen-readiness.js';
|
|
9
|
-
import { runLocalStep } from './agent/loop.js';
|
|
9
|
+
import { WEB_KEYS, checkKey, runLocalStep } from './agent/loop.js';
|
|
10
10
|
import { chooseAgent } from './agent/providers.js';
|
|
11
11
|
const POLL_INTERVAL_MS = 2_000;
|
|
12
12
|
const DEVICE_CONNECT_TIMEOUT_MS = 90_000;
|
|
@@ -441,28 +441,61 @@ export class DeviceSession {
|
|
|
441
441
|
await this.readScreenshotSize();
|
|
442
442
|
return this.toScreenshot((await this.perform({ kind: 'type', at: location, text, clear: options.clear !== false })));
|
|
443
443
|
}
|
|
444
|
+
/** Types into the field that already has focus, without tapping, replacing its content unless clear is false. Web only. */
|
|
445
|
+
async typeFocused(text, options = {}) {
|
|
446
|
+
if (this.platform !== 'web')
|
|
447
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'typeFocused() is available only on web sessions; on iOS and Android, use type(location, text), which taps the field first.');
|
|
448
|
+
if (typeof text !== 'string' || text.length > 16_384)
|
|
449
|
+
throw new VerifyError('INVALID_ARGUMENT', 'typeFocused() takes text of at most 16384 characters.');
|
|
450
|
+
await this.perform({ kind: 'type_focused', text, clear: options.clear !== false });
|
|
451
|
+
}
|
|
452
|
+
/** Double-taps a point in screenshot pixels, or an element Revyl locates from a description. Returns the point, in screenshot pixels. */
|
|
453
|
+
async doubleTap(at) {
|
|
454
|
+
const location = await this.toDevice(checkLocation(at));
|
|
455
|
+
if (this.platform === 'ios')
|
|
456
|
+
await this.readScreenshotSize();
|
|
457
|
+
return this.toScreenshot((await this.perform({ kind: 'double_tap', at: location })));
|
|
458
|
+
}
|
|
459
|
+
/** Presses and holds a point or element for duration milliseconds (1 to 5000, default 1500). Returns the point, in screenshot pixels. */
|
|
460
|
+
async longPress(at, options = {}) {
|
|
461
|
+
const { duration } = options;
|
|
462
|
+
if (duration !== undefined && (!Number.isInteger(duration) || duration < 1 || duration > 5_000))
|
|
463
|
+
throw new VerifyError('INVALID_ARGUMENT', 'longPress() duration is whole milliseconds from 1 to 5000.');
|
|
464
|
+
const location = await this.toDevice(checkLocation(at));
|
|
465
|
+
if (this.platform === 'ios')
|
|
466
|
+
await this.readScreenshotSize();
|
|
467
|
+
return this.toScreenshot((await this.perform({ kind: 'long_press', at: location, ...(duration !== undefined ? { durationMs: duration } : {}) })));
|
|
468
|
+
}
|
|
444
469
|
/** Swipes from a point or the screen's center. 'up' moves the finger up, which scrolls content down. */
|
|
445
470
|
async swipe(direction, options = {}) {
|
|
446
471
|
if (!['up', 'down', 'left', 'right'].includes(direction))
|
|
447
472
|
throw new VerifyError('INVALID_ARGUMENT', "swipe() takes 'up', 'down', 'left' or 'right'.");
|
|
448
473
|
await this.perform({ kind: 'swipe', direction, ...(options.from ? { from: await this.toDevice(checkLocation(options.from)) } : {}) });
|
|
449
474
|
}
|
|
450
|
-
/** Presses
|
|
451
|
-
async
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
475
|
+
/** Presses at one point or element, moves to another and releases. */
|
|
476
|
+
async drag(from, to) {
|
|
477
|
+
const [start, end] = [checkLocation(from), checkLocation(to)];
|
|
478
|
+
await this.perform({ kind: 'drag', from: await this.toDevice(start), to: await this.toDevice(end) });
|
|
479
|
+
}
|
|
480
|
+
/** Presses a key on the focused element, with modifiers held on Web. */
|
|
481
|
+
async pressKey(key, options = {}) {
|
|
482
|
+
const checked = checkKey(this.platform, key, options.modifiers ?? []);
|
|
483
|
+
await this.perform({ kind: 'key', key: checked.key, ...(checked.modifiers.length ? { modifiers: checked.modifiers } : {}) });
|
|
455
484
|
}
|
|
456
485
|
/** Goes back: browser history on Web, the back button on Android. Not on iOS. */
|
|
457
486
|
async back() {
|
|
458
487
|
await this.perform({ kind: 'back' });
|
|
459
488
|
}
|
|
489
|
+
/** Goes to the home screen on iOS and Android. */
|
|
490
|
+
async goHome() {
|
|
491
|
+
if (this.platform === 'web')
|
|
492
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'goHome() is available only on iOS and Android sessions; on web, use openUrl() or navigate() to load a page.');
|
|
493
|
+
await this.perform({ kind: 'go_home' });
|
|
494
|
+
}
|
|
460
495
|
/** Opens a URL or deep link on a device, or loads a URL in a browser. */
|
|
461
496
|
async openUrl(url) {
|
|
462
497
|
if (typeof url !== 'string' || url.length === 0 || url.length > 8_192)
|
|
463
498
|
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
499
|
await this.perform({ kind: 'open_url', url });
|
|
467
500
|
}
|
|
468
501
|
/** Launches an installed app by bundle ID or package name. Devices only. */
|
|
@@ -577,11 +610,30 @@ export class DeviceSession {
|
|
|
577
610
|
await this.act('tap', pointBody(point, action.at), signal);
|
|
578
611
|
return { x: point.x, y: point.y };
|
|
579
612
|
}
|
|
613
|
+
case 'double_tap': {
|
|
614
|
+
const point = await this.resolve(action.at, screenToken, signal);
|
|
615
|
+
await this.act('double_tap', pointBody(point, action.at), signal);
|
|
616
|
+
return { x: point.x, y: point.y };
|
|
617
|
+
}
|
|
618
|
+
case 'long_press': {
|
|
619
|
+
const point = await this.resolve(action.at, screenToken, signal);
|
|
620
|
+
await this.act('longpress', { ...pointBody(point, action.at), ...(action.durationMs !== undefined ? { duration_ms: action.durationMs } : {}) }, signal);
|
|
621
|
+
return { x: point.x, y: point.y };
|
|
622
|
+
}
|
|
580
623
|
case 'type': {
|
|
581
624
|
const point = await this.resolve(action.at, screenToken, signal);
|
|
582
625
|
await this.act('input', { ...pointBody(point, action.at), text: action.text, clear_first: action.clear }, signal);
|
|
583
626
|
return { x: point.x, y: point.y };
|
|
584
627
|
}
|
|
628
|
+
case 'type_focused':
|
|
629
|
+
await this.act('input', { text: action.text, clear_first: action.clear }, signal);
|
|
630
|
+
return undefined;
|
|
631
|
+
case 'drag': {
|
|
632
|
+
const from = await this.resolve(action.from, screenToken, signal);
|
|
633
|
+
const to = await this.resolve(action.to, screenToken, signal);
|
|
634
|
+
await this.act('drag', { start_x: from.x, start_y: from.y, end_x: to.x, end_y: to.y }, signal);
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
585
637
|
case 'swipe': {
|
|
586
638
|
const point = action.from ? await this.resolve(action.from, screenToken, signal) : await this.readScreenSize(signal).then((size) => ({ x: Math.floor(size.width / 2), y: Math.floor(size.height / 2) }));
|
|
587
639
|
const direction = this.platform === 'web' ? { up: 'down', down: 'up', left: 'right', right: 'left' }[action.direction] : action.direction;
|
|
@@ -589,7 +641,10 @@ export class DeviceSession {
|
|
|
589
641
|
return undefined;
|
|
590
642
|
}
|
|
591
643
|
case 'key':
|
|
592
|
-
await this.act('key', { key: this.platform === 'web' ? (action.key
|
|
644
|
+
await this.act('key', { key: this.platform === 'web' ? (WEB_KEYS[action.key] ?? action.key) : action.key.toUpperCase(), ...(action.modifiers?.length ? { modifiers: action.modifiers } : {}) }, signal);
|
|
645
|
+
return undefined;
|
|
646
|
+
case 'go_home':
|
|
647
|
+
await this.act('go_home', undefined, signal);
|
|
593
648
|
return undefined;
|
|
594
649
|
case 'back':
|
|
595
650
|
if (this.platform === 'ios')
|
|
@@ -598,6 +653,8 @@ export class DeviceSession {
|
|
|
598
653
|
return undefined;
|
|
599
654
|
case 'open_url':
|
|
600
655
|
signal?.throwIfAborted();
|
|
656
|
+
if (this.platform === 'web' && !/^https?:/i.test(action.url))
|
|
657
|
+
throw new VerifyError('INVALID_ARGUMENT', 'On web, openUrl() takes an http(s) URL; deep links such as myapp://orders/42 open only on iOS and Android.');
|
|
601
658
|
if (this.platform === 'web')
|
|
602
659
|
await this.navigateTo(action.url, signal);
|
|
603
660
|
else
|
|
@@ -822,7 +879,7 @@ export function pngSize(bytes) {
|
|
|
822
879
|
const height = view.getUint32(20);
|
|
823
880
|
return width > 0 && height > 0 ? { width, height } : undefined;
|
|
824
881
|
}
|
|
825
|
-
function checkLocation(at) {
|
|
882
|
+
export function checkLocation(at) {
|
|
826
883
|
if (at && typeof at === 'object' && 'target' in at) {
|
|
827
884
|
if (typeof at.target !== 'string' || at.target.trim() === '' || at.target.length > 500)
|
|
828
885
|
throw new VerifyError('INVALID_ARGUMENT', 'A target is a description of 1 to 500 characters.');
|
package/docs/README.md
CHANGED
|
@@ -10,7 +10,7 @@ These files ship inside the npm package at `node_modules/@revyl/verify/docs/`, s
|
|
|
10
10
|
| [configuration.md](configuration.md) | Look up every option in `verify.config.ts`, the environment variables, and the CLI |
|
|
11
11
|
| [web.md](web.md) | Open a web app in a Revyl cloud browser, and act on it with exact actions |
|
|
12
12
|
| [mobile.md](mobile.md) | Open an iOS or Android app on a Revyl cloud device, and act on it with exact actions |
|
|
13
|
-
| [computers.md](computers.md) | Open a Mac computer for commands, files, processes, screenshots, and optional recording metadata |
|
|
13
|
+
| [computers.md](computers.md) | Open a Mac computer for commands, files, processes, screenshots, clicks and keys on its screen, and optional recording metadata |
|
|
14
14
|
| [ci.md](ci.md) | Call Verify from CI or from a test runner you already use |
|
|
15
15
|
| [own-model.md](own-model.md) | Run instructions and validations on your own model, such as Claude, GPT or a local model |
|
|
16
16
|
| [errors.md](errors.md) | Understand an error code and fix it, or troubleshoot a common problem |
|
package/docs/agents.md
CHANGED
|
@@ -24,11 +24,11 @@ This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that
|
|
|
24
24
|
|
|
25
25
|
## Exact actions and coordinates
|
|
26
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,
|
|
27
|
+
Use `instruction()` when the step needs judgement, and exact actions (`tap`, `doubleTap`, `longPress`, `type`, `swipe`, `drag`, `pressKey`, `locate`, `screenSize`) when you know exactly what to press. They work on Web, iOS and Android. On a Mac computer's screen, `tap({ x, y })`, `tap({ target })`, `screenSize()`, `typeFocused(text, { clear: false })` and `pressKey` work; see [computers.md](computers.md#screen). On Web and Mac computers, `typeFocused(text)` types into the field that has focus, and `pressKey` also takes keys such as `'escape'`, `'tab'` and arrows, with `modifiers`. Select all with `pressKey('a', { modifiers: ['meta'] })` on a Mac computer, where `'meta'` is Command, and with `pressKey('a', { modifiers: ['ctrl'] })` on Web, where `'meta'` is not the shortcut key. On iOS and Android, `goHome()` goes to the home screen, `pressKey` takes only `'enter'` and `'backspace'`, and you type with `type(location, text)`. A call the platform lacks throws `NOT_AVAILABLE_ON_PLATFORM` before anything is sent, and the message names what to use instead.
|
|
28
28
|
|
|
29
|
-
Prefer a description, such as `tap({ target: 'Sign in button' })`: Revyl finds the element on the current screen, and you never handle coordinates. Otherwise a point is `{ x, y }` in screenshot pixels on every platform: whole numbers from the top-left corner of the image `screenshot()` returns. So a position you read off a screenshot goes to `tap()`, `type()
|
|
29
|
+
Prefer a description, such as `tap({ target: 'Sign in button' })`: Revyl finds the element on the current screen, and you never handle coordinates. Otherwise a point is `{ x, y }` in screenshot pixels on every platform: whole numbers from the top-left corner of the image `screenshot()` returns. So a position you read off a screenshot goes to `tap()`, `type()`, `swipe()` and every other exact action unchanged. `screenSize()` returns the screenshot's width and height, and `locate()` and the points `tap()`, `doubleTap()`, `longPress()` and `type()` return use the same pixels. A point outside the screenshot is refused with `INVALID_ARGUMENT` before any action is sent, and the message names the valid range. On iOS, Verify converts the pixels to the screen's points for you. On Web they equal CSS pixels at the default device scale factor of 1, and you still pass screenshot pixels at any other. This holds from TypeScript 0.4.1, Python 0.2.1 and Go v0.3.1; earlier versions took iOS points. On a Mac computer a point is in screenshot pixels too, and `tap({ target })` returns the point it clicked in the same pixels. Verify refuses a negative or fractional point before sending it, and Revyl refuses one past the screen's edge without clicking; both throw `INVALID_ARGUMENT` naming the valid range.
|
|
30
30
|
|
|
31
|
-
`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.
|
|
31
|
+
`swipe('up')` moves the finger up, so content scrolls down, on every platform. Actions return once Revyl accepts them, so after a swipe, drag, `goHome`, `openUrl` or `launchApp`, wait for the screen to settle before you `locate` or tap by coordinates.
|
|
32
32
|
|
|
33
33
|
```ts
|
|
34
34
|
import { open } from '@revyl/verify';
|
|
@@ -44,18 +44,22 @@ try {
|
|
|
44
44
|
await session.swipe('up', { from: { x: 660, y: 2000 } }); // swipe from a screenshot position too
|
|
45
45
|
const { width, height } = await session.screenSize(); // the screenshot's size
|
|
46
46
|
console.log(`x is 0 to ${width - 1}, y is 0 to ${height - 1}`);
|
|
47
|
+
|
|
48
|
+
await session.longPress({ target: 'Wi-Fi row' }); // press and hold, 1.5 s by default
|
|
49
|
+
await session.drag({ x: 660, y: 2000 }, { x: 660, y: 800 }); // press, move, release: the list follows the finger
|
|
50
|
+
await session.goHome(); // the home screen, on iOS and Android
|
|
47
51
|
} finally {
|
|
48
52
|
await session.close();
|
|
49
53
|
}
|
|
50
54
|
```
|
|
51
55
|
|
|
52
|
-
The [Python](https://github.com/RevylAI/verify/blob/v0.
|
|
56
|
+
The [Python](https://github.com/RevylAI/verify/blob/v0.6.0/python/README.md#exact-actions) and [Go](https://github.com/RevylAI/verify/blob/v0.6.0/go/README.md#exact-actions) READMEs state the same rule with each language's names. [api.md](api.md#exact-actions) lists every call and its errors.
|
|
53
57
|
|
|
54
58
|
## Rules
|
|
55
59
|
|
|
56
60
|
Mac computers are early access. Ask support@revyl.ai about access before starting a computer; do not repeat an uncertain start/stop to probe availability.
|
|
57
61
|
|
|
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.
|
|
62
|
+
For computer work, use structured `exec(['program', 'arg'])`, `readFile`, `writeFile`, `stat`, and run-owned handles from `processes()`/`terminate(id)`. Check command output with ordinary assertions and close in `finally`; screenshots are supported, but instruction/validation are not. To use the screen, read a point off `screenshot()` and `tap({ x, y })` it, or describe it with `tap({ target })`, and type with `typeFocused(text, { clear: false })`. A described click takes several seconds, because Revyl takes a screenshot and finds the element first. It can pick a look-alike, and when the target isn't on the screen it can still click something else instead of failing. So check the point it returns or take a screenshot afterwards, and for anything that's hard to undo, read the point off `screenshot()` and use `tap({ x, y })`. After `ACTION_UNCERTAIN`, take a screenshot before you act again. Do not fall back to agent steps on computers or shell/file methods on mobile/web. See [computers.md](computers.md) for limits. Starting a computer is a paid external action and needs the same approval as starting a device.
|
|
59
63
|
|
|
60
64
|
Computers have a hard lifetime from create (two hours today), not from readiness or activity; `expiresAt` is the authoritative deadline. `expiresAt` is optional on older APIs; expiry does not prevent close. After terminal state and confirmed release, `recording()` can return private signed metadata or null, including after close. Never log or share its signed URL/query. Do not automatically wait for recordings or retry `COMPUTER_LIMIT` starts: queued work (including iOS) or the concurrency limit may be responsible.
|
|
61
65
|
|