@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/CHANGELOG.md +66 -0
- package/README.md +63 -113
- 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/install.js +14 -9
- package/dist/cli/login.js +7 -7
- package/dist/cli/main.js +89 -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 +71 -2
- package/dist/session.js +230 -12
- package/docs/README.md +5 -4
- package/docs/agents.md +5 -5
- package/docs/api.md +89 -13
- package/docs/ci.md +4 -0
- package/docs/computers.md +9 -11
- package/docs/configuration.md +11 -7
- package/docs/errors.md +15 -5
- package/docs/getting-started.md +41 -18
- package/docs/llms.txt +6 -5
- package/docs/mobile.md +46 -0
- package/docs/own-model.md +216 -0
- package/docs/web.md +56 -1
- package/package.json +4 -4
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
417
|
-
|
|
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
|
|
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
|
-
| [
|
|
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
|
|
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
|
|
@@ -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
|
|
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.
|
|
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
|
|
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.
|