@revyl/verify 0.1.0 → 0.3.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 +48 -3
- package/README.md +128 -61
- package/dist/api/client.d.ts +6 -4
- package/dist/api/client.js +64 -14
- package/dist/api/types.d.ts +7 -0
- package/dist/cli/init.js +2 -3
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +64 -0
- package/dist/cli/login.d.ts +6 -0
- package/dist/cli/login.js +200 -0
- package/dist/cli/main.js +48 -14
- package/dist/computer-session.d.ts +21 -5
- package/dist/computer-session.js +214 -35
- package/dist/config.d.ts +8 -1
- package/dist/config.js +16 -2
- package/dist/errors.d.ts +6 -1
- package/dist/errors.js +4 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +1 -1
- package/dist/open.d.ts +11 -3
- package/dist/open.js +37 -11
- package/dist/screen-readiness.d.ts +1 -0
- package/dist/screen-readiness.js +27 -0
- package/dist/session.d.ts +30 -2
- package/dist/session.js +165 -12
- package/docs/README.md +2 -2
- package/docs/agents.md +14 -9
- package/docs/api.md +86 -12
- package/docs/ci.md +1 -1
- package/docs/computers.md +28 -17
- package/docs/configuration.md +14 -12
- package/docs/end-to-end.md +105 -0
- package/docs/errors.md +31 -5
- package/docs/getting-started.md +49 -26
- package/docs/llms.txt +6 -6
- package/docs/mobile.md +6 -0
- package/docs/web.md +1 -7
- package/package.json +7 -4
- package/docs/linux-computers-contract.md +0 -107
package/dist/session.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RevylClient } from './api/client.js';
|
|
2
2
|
import type { LiveStepType, Platform } from './api/types.js';
|
|
3
|
-
import type { ResolvedDeviceTarget } from './config.js';
|
|
3
|
+
import type { HostedTargetConfig, ResolvedDeviceTarget } from './config.js';
|
|
4
4
|
export type StepType = LiveStepType;
|
|
5
5
|
/** The outcome of one instruction or validation, as Revyl's agent reported it. */
|
|
6
6
|
export interface StepResult {
|
|
@@ -45,6 +45,11 @@ export interface ComputerProcess {
|
|
|
45
45
|
state: 'running' | 'exited';
|
|
46
46
|
exitCode?: number;
|
|
47
47
|
}
|
|
48
|
+
export interface ComputerRecording {
|
|
49
|
+
url: string;
|
|
50
|
+
durationSeconds?: number;
|
|
51
|
+
startedAt?: Date;
|
|
52
|
+
}
|
|
48
53
|
/**
|
|
49
54
|
* One live Revyl session: a cloud browser, an iOS or Android device, or a computer.
|
|
50
55
|
* A call the session's platform does not offer throws NOT_AVAILABLE_ON_PLATFORM.
|
|
@@ -60,6 +65,8 @@ export interface Session {
|
|
|
60
65
|
/** Every instruction and validation run on this session, in order. */
|
|
61
66
|
readonly steps: StepResult[];
|
|
62
67
|
readonly closed: boolean;
|
|
68
|
+
readonly expiresAt?: Date;
|
|
69
|
+
recording(): Promise<ComputerRecording | null>;
|
|
63
70
|
/** Asks Revyl's agent to do something on the app. Browsers and devices only. */
|
|
64
71
|
instruction(text: string, options?: StepOptions): Promise<StepResult>;
|
|
65
72
|
/** Asks Revyl's agent whether something holds on screen. Browsers and devices only. */
|
|
@@ -84,9 +91,21 @@ export interface Session {
|
|
|
84
91
|
close(): Promise<void>;
|
|
85
92
|
[Symbol.asyncDispose](): Promise<void>;
|
|
86
93
|
}
|
|
94
|
+
export interface OpenedSession extends Session {
|
|
95
|
+
report(): Promise<Record<string, unknown>>;
|
|
96
|
+
navigate(url: string): Promise<void>;
|
|
97
|
+
open(target: HostedTargetConfig): Promise<OpenedSession>;
|
|
98
|
+
}
|
|
87
99
|
export interface SessionOptions {
|
|
88
100
|
stepTimeout: number;
|
|
89
101
|
log: ((line: string) => void) | undefined;
|
|
102
|
+
appUrl?: string;
|
|
103
|
+
}
|
|
104
|
+
export interface HostingParent {
|
|
105
|
+
id: string;
|
|
106
|
+
signal: AbortSignal;
|
|
107
|
+
expiresAt?: Date;
|
|
108
|
+
register(session: Session): void;
|
|
90
109
|
}
|
|
91
110
|
/** A Revyl cloud browser or iOS or Android device. */
|
|
92
111
|
export declare class DeviceSession implements Session {
|
|
@@ -104,8 +123,12 @@ export declare class DeviceSession implements Session {
|
|
|
104
123
|
private workflowRunId;
|
|
105
124
|
private closePromise;
|
|
106
125
|
private stepRunning;
|
|
126
|
+
private parent?;
|
|
127
|
+
private readySignal?;
|
|
128
|
+
get expiresAt(): Date | undefined;
|
|
107
129
|
private constructor();
|
|
108
|
-
static start(client: RevylClient, target: ResolvedDeviceTarget, options: SessionOptions): Promise<DeviceSession>;
|
|
130
|
+
static start(client: RevylClient, target: ResolvedDeviceTarget, options: SessionOptions, parent?: HostingParent): Promise<DeviceSession>;
|
|
131
|
+
private static startHosted;
|
|
109
132
|
get closed(): boolean;
|
|
110
133
|
/** 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. */
|
|
111
134
|
instruction(text: string, options?: StepOptions): Promise<StepResult>;
|
|
@@ -118,7 +141,11 @@ export declare class DeviceSession implements Session {
|
|
|
118
141
|
screenshot(options?: {
|
|
119
142
|
path?: string;
|
|
120
143
|
}): Promise<Uint8Array>;
|
|
144
|
+
report(): Promise<Record<string, unknown>>;
|
|
145
|
+
navigate(url: string): Promise<void>;
|
|
121
146
|
exec(): Promise<ExecResult>;
|
|
147
|
+
open(): Promise<never>;
|
|
148
|
+
recording(): Promise<never>;
|
|
122
149
|
readFile(): Promise<Buffer>;
|
|
123
150
|
writeFile(): Promise<void>;
|
|
124
151
|
stat(): Promise<FileStat>;
|
|
@@ -130,6 +157,7 @@ export declare class DeviceSession implements Session {
|
|
|
130
157
|
private step;
|
|
131
158
|
private runStep;
|
|
132
159
|
private launch;
|
|
160
|
+
private waitForScreenContent;
|
|
133
161
|
private log;
|
|
134
162
|
private computerOnly;
|
|
135
163
|
private worker;
|
package/dist/session.js
CHANGED
|
@@ -2,9 +2,10 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
-
import { apiError, parseJson, responseDetail, responseErrorCode, sessionViewerUrl, } from './api/client.js';
|
|
5
|
+
import { apiError, edgeBlockedError, parseJson, responseDetail, responseErrorCode, responseRefusalReason, sessionViewerUrl, } from './api/client.js';
|
|
6
6
|
import { trackSession, untrackSession } from './cleanup.js';
|
|
7
7
|
import { VerifyError, errorMessage, isVerifyError } from './errors.js';
|
|
8
|
+
import { screenHasContent } from './screen-readiness.js';
|
|
8
9
|
const POLL_INTERVAL_MS = 2_000;
|
|
9
10
|
const DEVICE_CONNECT_TIMEOUT_MS = 90_000;
|
|
10
11
|
const STEP_POLL_BASE_MS = 500;
|
|
@@ -31,6 +32,11 @@ export class DeviceSession {
|
|
|
31
32
|
workflowRunId;
|
|
32
33
|
closePromise;
|
|
33
34
|
stepRunning = false;
|
|
35
|
+
parent;
|
|
36
|
+
readySignal;
|
|
37
|
+
get expiresAt() {
|
|
38
|
+
return this.parent?.expiresAt;
|
|
39
|
+
}
|
|
34
40
|
constructor(client, target, id, workflowRunId, options) {
|
|
35
41
|
this.client = client;
|
|
36
42
|
this.options = options;
|
|
@@ -38,10 +44,12 @@ export class DeviceSession {
|
|
|
38
44
|
this.target = target.name;
|
|
39
45
|
this.id = id;
|
|
40
46
|
this.workflowRunId = workflowRunId;
|
|
41
|
-
this.viewerUrl = sessionViewerUrl(id);
|
|
47
|
+
this.viewerUrl = sessionViewerUrl(id, undefined, options.appUrl);
|
|
42
48
|
}
|
|
43
|
-
static async start(client, target, options) {
|
|
44
|
-
const deadline = Date.now() + target.startTimeout;
|
|
49
|
+
static async start(client, target, options, parent) {
|
|
50
|
+
const deadline = Math.min(Date.now() + target.startTimeout, parent?.expiresAt?.getTime() ?? Infinity);
|
|
51
|
+
if (parent)
|
|
52
|
+
return DeviceSession.startHosted(client, target, options, parent, deadline);
|
|
45
53
|
if (target.platform === 'web') {
|
|
46
54
|
const buildId = await resolveWebBuildId(client, target);
|
|
47
55
|
const sessionId = randomUUID();
|
|
@@ -94,9 +102,77 @@ export class DeviceSession {
|
|
|
94
102
|
await session.waitForDeviceReady(deadline);
|
|
95
103
|
if (app.bundleId)
|
|
96
104
|
await session.launch(app.bundleId);
|
|
105
|
+
await session.waitForScreenContent(deadline);
|
|
97
106
|
});
|
|
98
107
|
return session;
|
|
99
108
|
}
|
|
109
|
+
static async startHosted(client, target, options, parent, deadline) {
|
|
110
|
+
const id = randomUUID();
|
|
111
|
+
const session = new DeviceSession(client, target, id, '', options);
|
|
112
|
+
session.parent = parent;
|
|
113
|
+
session.readySignal = AbortSignal.any([parent.signal, AbortSignal.timeout(Math.max(0, deadline - Date.now()))]);
|
|
114
|
+
const request = {
|
|
115
|
+
platform: target.platform,
|
|
116
|
+
session_id: id,
|
|
117
|
+
computer_session_id: parent.id,
|
|
118
|
+
app_id: target.app.appId,
|
|
119
|
+
build_id: target.app.buildId,
|
|
120
|
+
idle_timeout_seconds: target.idleTimeoutSeconds,
|
|
121
|
+
...(target.platform === 'web' ? { web: {
|
|
122
|
+
...(target.viewport ? { viewport: { width: target.viewport.width, height: target.viewport.height, device_scale_factor: target.viewport.deviceScaleFactor } } : {}),
|
|
123
|
+
...(target.signIn ? { sign_in: { path: target.signIn.path, secret_name: target.signIn.secret, persona: target.signIn.persona } } : {}),
|
|
124
|
+
} } : { is_simulation: true }),
|
|
125
|
+
};
|
|
126
|
+
let submitted = false;
|
|
127
|
+
let refused = false;
|
|
128
|
+
try {
|
|
129
|
+
session.readySignal.throwIfAborted();
|
|
130
|
+
submitted = true;
|
|
131
|
+
const response = await client.send('POST', '/api/v1/execution/start_device', { body: request, retry: false, signal: session.readySignal, timeoutMs: 120_000 });
|
|
132
|
+
if (response.status < 200 || response.status >= 300) {
|
|
133
|
+
const reason = responseRefusalReason(response);
|
|
134
|
+
refused = [400, 401, 403, 404, 405, 409, 422, 429].includes(response.status) || (response.status === 503 && reason !== undefined && reason !== 'request_timeout' && reason !== 'protocol_error');
|
|
135
|
+
const blocked = edgeBlockedError(response);
|
|
136
|
+
if (blocked)
|
|
137
|
+
throw blocked;
|
|
138
|
+
const code = response.status === 401 || response.status === 403 ? 'AUTH_REJECTED'
|
|
139
|
+
: reason === 'computer_not_found' || response.status === 404 ? 'NOT_FOUND'
|
|
140
|
+
: reason === 'computer_not_running' ? 'SESSION_ENDED'
|
|
141
|
+
: 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'
|
|
143
|
+
: response.status === 422 || response.status === 400 ? 'INVALID_ARGUMENT'
|
|
144
|
+
: response.status === 409 ? 'SESSION_BUSY'
|
|
145
|
+
: response.status === 408 || response.status >= 500 ? 'ACTION_UNCERTAIN' : 'API_ERROR';
|
|
146
|
+
throw new VerifyError(code, `Revyl refused the hosted session start (${reason ?? `HTTP ${response.status}`}).`, { reason, httpStatus: response.status });
|
|
147
|
+
}
|
|
148
|
+
const start = parseJson(response, 'hosted session start');
|
|
149
|
+
if (!start || typeof start !== 'object' || Array.isArray(start))
|
|
150
|
+
throw new VerifyError('API_ERROR', 'Revyl returned an invalid hosted session response.');
|
|
151
|
+
if (start.session_id !== id || typeof start.workflow_run_id !== 'string' || !/^[A-Za-z0-9_-]{1,255}$/.test(start.workflow_run_id) || (start.platform !== undefined && start.platform !== target.platform))
|
|
152
|
+
throw new VerifyError('SESSION_START_FAILED', 'Revyl did not acknowledge the hosted session identity.');
|
|
153
|
+
session.workflowRunId = start.workflow_run_id;
|
|
154
|
+
parent.register(session);
|
|
155
|
+
await session.untilReady(async () => {
|
|
156
|
+
if (target.platform === 'web')
|
|
157
|
+
await session.waitForWebReady(deadline);
|
|
158
|
+
else {
|
|
159
|
+
await session.waitForDeviceReady(deadline);
|
|
160
|
+
await session.waitForScreenContent(deadline);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
session.readySignal.throwIfAborted();
|
|
164
|
+
return session;
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (submitted && !refused) {
|
|
168
|
+
parent.register(session);
|
|
169
|
+
await session.close().catch(() => undefined);
|
|
170
|
+
}
|
|
171
|
+
if (session.readySignal.aborted)
|
|
172
|
+
throw new VerifyError('TIMEOUT', `Hosted startup was cancelled or exceeded its deadline. See ${session.viewerUrl}`, { cause: error });
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
100
176
|
get closed() {
|
|
101
177
|
return this.closePromise !== undefined;
|
|
102
178
|
}
|
|
@@ -131,9 +207,44 @@ export class DeviceSession {
|
|
|
131
207
|
}
|
|
132
208
|
return response.body;
|
|
133
209
|
}
|
|
210
|
+
async report() {
|
|
211
|
+
if (!this.id)
|
|
212
|
+
throw new VerifyError('NOT_FOUND', 'Cannot report a session without a known session ID.');
|
|
213
|
+
const report = await this.client.getDeviceSession(this.id);
|
|
214
|
+
if (!report || typeof report !== 'object' || Array.isArray(report))
|
|
215
|
+
throw new VerifyError('API_ERROR', 'Revyl returned an invalid session report.');
|
|
216
|
+
return report;
|
|
217
|
+
}
|
|
218
|
+
async navigate(url) {
|
|
219
|
+
if (this.platform !== 'web')
|
|
220
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'navigate() is available only on web sessions.');
|
|
221
|
+
if (typeof url !== 'string' || url.length > 2048 || /[\x00-\x20\x7f-\x9f\s\\]/.test(url))
|
|
222
|
+
throw new VerifyError('INVALID_ARGUMENT', 'navigate() requires an http(s) URL without credentials, whitespace, or backslashes.');
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = new URL(url);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
throw new VerifyError('INVALID_ARGUMENT', 'navigate() requires a valid http(s) URL.');
|
|
229
|
+
}
|
|
230
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password)
|
|
231
|
+
throw new VerifyError('INVALID_ARGUMENT', 'navigate() requires an http(s) URL without credentials.');
|
|
232
|
+
const response = await this.worker('POST', 'open_url', { body: { url }, retry: false });
|
|
233
|
+
const result = parseJson(response, 'open_url');
|
|
234
|
+
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
|
+
throw new VerifyError('API_ERROR', 'Revyl returned an invalid navigation response.');
|
|
236
|
+
if (!result.success)
|
|
237
|
+
throw new VerifyError('ACTION_FAILED', `Revyl did not confirm navigation. See ${this.viewerUrl}`);
|
|
238
|
+
}
|
|
134
239
|
exec() {
|
|
135
240
|
return this.computerOnly('exec');
|
|
136
241
|
}
|
|
242
|
+
open() {
|
|
243
|
+
return this.computerOnly('open');
|
|
244
|
+
}
|
|
245
|
+
recording() {
|
|
246
|
+
return this.computerOnly('recording');
|
|
247
|
+
}
|
|
137
248
|
readFile() {
|
|
138
249
|
return this.computerOnly('readFile');
|
|
139
250
|
}
|
|
@@ -154,7 +265,7 @@ export class DeviceSession {
|
|
|
154
265
|
this.closePromise ??= (async () => {
|
|
155
266
|
try {
|
|
156
267
|
if (this.id)
|
|
157
|
-
await this.client.stopDeviceSession(this.id);
|
|
268
|
+
await this.client.stopDeviceSession(this.id, this.parent === undefined);
|
|
158
269
|
else
|
|
159
270
|
await this.client.cancelDevice(this.workflowRunId);
|
|
160
271
|
this.log('closed');
|
|
@@ -261,6 +372,33 @@ export class DeviceSession {
|
|
|
261
372
|
throw new VerifyError('SESSION_START_FAILED', `Could not launch ${bundleId} on the ${this.target} device: ${result.error ?? 'no reason given'}`);
|
|
262
373
|
}
|
|
263
374
|
}
|
|
375
|
+
async waitForScreenContent(startDeadline) {
|
|
376
|
+
const deadline = Math.min(startDeadline, Date.now() + 10_000);
|
|
377
|
+
while (Date.now() < deadline) {
|
|
378
|
+
try {
|
|
379
|
+
const response = await this.client.worker(this.workflowRunId, 'screenshot', {
|
|
380
|
+
method: 'GET', retry: false, signal: AbortSignal.any([AbortSignal.timeout(Math.max(1, deadline - Date.now())), ...(this.readySignal ? [this.readySignal] : [])]),
|
|
381
|
+
});
|
|
382
|
+
if (response.status === 200) {
|
|
383
|
+
const content = screenHasContent(response.body);
|
|
384
|
+
if (content === true)
|
|
385
|
+
return;
|
|
386
|
+
if (content === undefined)
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
else if (response.status !== 409 && response.status !== 503) {
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
const remaining = deadline - Date.now();
|
|
397
|
+
if (remaining > 0)
|
|
398
|
+
await delay(Math.min(500, remaining));
|
|
399
|
+
}
|
|
400
|
+
this.log('debug: best-effort first-screen wait ended without non-blank content; the runtime has no rendered-readiness signal');
|
|
401
|
+
}
|
|
264
402
|
log(line) {
|
|
265
403
|
this.options.log?.(`${this.target}: ${line}`);
|
|
266
404
|
}
|
|
@@ -268,11 +406,24 @@ export class DeviceSession {
|
|
|
268
406
|
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${call}() runs on computer targets (platform 'mac' or 'linux'), not on ${this.platform} sessions.`));
|
|
269
407
|
}
|
|
270
408
|
async worker(method, action, options) {
|
|
271
|
-
if (this.closePromise)
|
|
272
|
-
throw new VerifyError('SESSION_ENDED', `The ${this.target} session was closed.`);
|
|
273
|
-
const
|
|
409
|
+
if (this.closePromise || this.parent?.signal.aborted || (this.parent?.expiresAt && Date.now() >= this.parent.expiresAt.getTime()))
|
|
410
|
+
throw new VerifyError('SESSION_ENDED', `The ${this.target} session was closed or its parent expired.`);
|
|
411
|
+
const signal = this.parent ? AbortSignal.any([
|
|
412
|
+
this.parent.signal,
|
|
413
|
+
...(this.parent.expiresAt ? [AbortSignal.timeout(Math.max(0, this.parent.expiresAt.getTime() - Date.now()))] : []),
|
|
414
|
+
...(options.signal ? [options.signal] : []),
|
|
415
|
+
]) : options.signal;
|
|
416
|
+
const response = await this.client.worker(this.workflowRunId, action, { method, ...options, signal });
|
|
274
417
|
if (response.status >= 200 && response.status < 300)
|
|
275
418
|
return response;
|
|
419
|
+
if (action === 'open_url') {
|
|
420
|
+
const error = this.workerError(method, action, response);
|
|
421
|
+
const reason = responseRefusalReason(response);
|
|
422
|
+
const message = error.code === 'ACTION_UNCERTAIN' && reason === 'request_timeout'
|
|
423
|
+
? `Web navigation did not confirm within the action budget and may still complete. The session is still open; Verify did not replay the request. See ${this.viewerUrl}`
|
|
424
|
+
: `Web navigation failed (HTTP ${response.status}). See ${this.viewerUrl}`;
|
|
425
|
+
throw new VerifyError(error.code, message, { reason, httpStatus: response.status });
|
|
426
|
+
}
|
|
276
427
|
throw this.workerError(method, action, response);
|
|
277
428
|
}
|
|
278
429
|
workerError(method, action, response) {
|
|
@@ -316,13 +467,14 @@ export class DeviceSession {
|
|
|
316
467
|
}
|
|
317
468
|
async waitForWebReady(deadline) {
|
|
318
469
|
for (;;) {
|
|
319
|
-
|
|
470
|
+
this.readySignal?.throwIfAborted();
|
|
471
|
+
const detail = await this.client.getDeviceSession(this.id, this.readySignal);
|
|
320
472
|
if (ENDED_STATUSES.has(detail.status)) {
|
|
321
473
|
throw new VerifyError('SESSION_START_FAILED', `The ${this.target} web session ended before it was ready (${detail.status}): ${detail.error_message ?? 'no reason given'}. See ${this.viewerUrl}`);
|
|
322
474
|
}
|
|
323
475
|
if (detail.status === 'running' && detail.workflow_run_id) {
|
|
324
476
|
this.workflowRunId = detail.workflow_run_id;
|
|
325
|
-
const response = await this.client.worker(this.workflowRunId, 'screenshot', { method: 'GET' });
|
|
477
|
+
const response = await this.client.worker(this.workflowRunId, 'screenshot', { method: 'GET', signal: this.readySignal });
|
|
326
478
|
if (response.status === 200 && pngSize(response.body))
|
|
327
479
|
return;
|
|
328
480
|
if (response.status !== 503 && response.status !== 409 && responseErrorCode(response) !== 'not_ready') {
|
|
@@ -334,7 +486,8 @@ export class DeviceSession {
|
|
|
334
486
|
}
|
|
335
487
|
async waitForDeviceReady(deadline) {
|
|
336
488
|
for (;;) {
|
|
337
|
-
|
|
489
|
+
this.readySignal?.throwIfAborted();
|
|
490
|
+
const connection = await this.client.getWorkerConnection(this.workflowRunId, this.readySignal);
|
|
338
491
|
if (connection.status === 'ready' && connection.worker_ws_url)
|
|
339
492
|
break;
|
|
340
493
|
if (connection.status === 'cancelled' || connection.status === 'stopped' || connection.status === 'failed') {
|
|
@@ -344,7 +497,7 @@ export class DeviceSession {
|
|
|
344
497
|
}
|
|
345
498
|
const connectDeadline = Math.min(deadline, Date.now() + DEVICE_CONNECT_TIMEOUT_MS);
|
|
346
499
|
for (;;) {
|
|
347
|
-
const response = await this.client.worker(this.workflowRunId, 'health', { method: 'GET', retry: true });
|
|
500
|
+
const response = await this.client.worker(this.workflowRunId, 'health', { method: 'GET', retry: true, signal: this.readySignal });
|
|
348
501
|
if (response.status === 200 && parseJson(response, 'health').device_connected)
|
|
349
502
|
return;
|
|
350
503
|
if (response.status !== 200 && response.status !== 503)
|
package/docs/README.md
CHANGED
|
@@ -4,13 +4,13 @@ These files ship inside the npm package at `node_modules/@revyl/verify/docs/`, s
|
|
|
4
4
|
|
|
5
5
|
| File | Read it when you want to |
|
|
6
6
|
| --- | --- |
|
|
7
|
+
| [end-to-end.md](end-to-end.md) | Review live verification scope, observed outcomes, and evidence limits |
|
|
7
8
|
| [getting-started.md](getting-started.md) | Install Verify, authenticate, and run a first script |
|
|
8
9
|
| [api.md](api.md) | Look up session lifecycle, agent steps, screenshots, commands, files, and processes |
|
|
9
10
|
| [configuration.md](configuration.md) | Look up every option in `verify.config.ts`, the environment variables, and the CLI |
|
|
10
11
|
| [web.md](web.md) | Open a web app in a Revyl cloud browser |
|
|
11
12
|
| [mobile.md](mobile.md) | Open an iOS or Android app on a Revyl cloud device |
|
|
12
|
-
| [computers.md](computers.md) | Open a Mac computer for commands, files, processes, and
|
|
13
|
-
| [linux-computers-contract.md](linux-computers-contract.md) | Read the deferred Linux request/response contract and unconfirmed assumptions |
|
|
13
|
+
| [computers.md](computers.md) | Open a Mac or Linux 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
|
| [errors.md](errors.md) | Understand an error code and fix it |
|
|
16
16
|
| [agents.md](agents.md) | Use Verify as a coding agent |
|
package/docs/agents.md
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
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 computer. The rest of this folder describes the API in full; this page is the workflow.
|
|
3
|
+
This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that use Verify to check an app on a cloud browser/device or run commands and files on a Mac or Linux computer. The rest of this folder describes the API in full; this page is the workflow.
|
|
4
4
|
|
|
5
5
|
## Workflow
|
|
6
6
|
|
|
7
7
|
1. Read `verify.config.ts` to learn the target names and their platforms. If there is none, run `npx @revyl/verify init --platform <web|ios|android> --app-id <id>`, or `npx @revyl/verify init --platform mac` without an app id. You can also pass a target object to `open()`.
|
|
8
|
-
2. Check the credentials once with `npx verify whoami`. It starts no session.
|
|
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
|
|
11
11
|
import { open } from '@revyl/verify';
|
|
12
12
|
|
|
13
13
|
const session = await open('ios');
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
try {
|
|
15
|
+
await session.instruction('Sign in as the demo user');
|
|
16
|
+
await session.validation('The dashboard shows at least one project card');
|
|
17
|
+
await session.screenshot({ path: '.verify/dashboard.png' });
|
|
18
|
+
} finally {
|
|
19
|
+
await session.close();
|
|
20
|
+
}
|
|
18
21
|
```
|
|
19
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.
|
|
20
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.
|
|
21
24
|
|
|
22
25
|
## Rules
|
|
23
26
|
|
|
24
|
-
Mac computers are early access
|
|
27
|
+
Mac and Linux computers are early access. Ask support@revyl.ai about access before starting a computer; do not repeat an uncertain start/stop to probe availability.
|
|
25
28
|
|
|
26
|
-
For
|
|
29
|
+
For computer work, use structured `exec(['program', 'arg'])`, `readFile`, `writeFile`, `stat`, and run-owned handles from `processes()`/`terminate(id)`. Check command output with ordinary assertions and close in `finally`; screenshots are supported, but instruction/validation are not. Do not fall back to agent steps on computers or shell/file methods on mobile/web. See [computers.md](computers.md) for limits. Linux uses inline targets or config entries; Linux init is intentionally unavailable. Starting a computer is a paid external action and needs the same approval as starting a device.
|
|
30
|
+
|
|
31
|
+
Computers have a hard 60-minute lifetime from create, not from readiness or activity. `expiresAt` is optional on older APIs; expiry does not prevent close. After terminal state and confirmed release, `recording()` can return private signed metadata or null, including after close. Never log or share its signed URL/query. Do not automatically wait for recordings or retry `COMPUTER_LIMIT` starts: queued work (including iOS) or the concurrency limit may be responsible.
|
|
27
32
|
|
|
28
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.
|
|
29
34
|
- Use `instruction` for doing and `validation` for checking. Do not ask an instruction to check something, or a validation to change anything.
|
|
@@ -33,6 +38,6 @@ For Mac work, use structured `exec(['program', 'arg'])`, `readFile`, `writeFile`
|
|
|
33
38
|
- Await each step. Two steps at once on one session throw `INVALID_ARGUMENT`.
|
|
34
39
|
- Do not catch `VerifyError` to make a check pass. A `NOT_AVAILABLE_ON_PLATFORM` error means the call is not offered on that platform; see [web.md](web.md) and [computers.md](computers.md). A `STEP_FAILED` error means the step did not run to an outcome, not that the app is wrong.
|
|
35
40
|
- Never automatically repeat a computer mutation after `ACTION_UNCERTAIN` or a timeout: the command may have run, and timeout does not prove termination or rollback. Do not log argv, stdin, file contents, or credentials.
|
|
36
|
-
- Always close what you open
|
|
41
|
+
- Always close what you open in `try/finally`. Each open session is paid. Automatic cleanup is best effort with a 15-second bound and skips signal/error events that already have listeners. Device close acknowledges stop, while computer close requires confirmed terminal release.
|
|
37
42
|
- Never put secrets in scripts or config. Credentials come from `REVYL_API_KEY` or a Revyl CLI login; web sign-in uses the **name** of a Revyl organization secret.
|
|
38
43
|
- Step descriptions are sent to Revyl and logged to stderr by default. Do not include private credentials or customer data in instruction or validation text; use test-only sign-in and synthetic data instead. `log: false` suppresses local progress, not remote processing or reports.
|
package/docs/api.md
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
# API
|
|
2
2
|
|
|
3
3
|
```ts
|
|
4
|
-
import { open, defineConfig, VerifyError, isVerifyError } from '@revyl/verify';
|
|
4
|
+
import { attach, open, defineConfig, VerifyError, isVerifyError } from '@revyl/verify';
|
|
5
5
|
```
|
|
6
6
|
|
|
7
7
|
## open(target, options?)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Linux targets are supported **since 0.2.0**; 0.1.0 includes the target type but rejects Linux locally.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Starts a Revyl cloud browser, device, or Mac or Linux computer. Mobile first-screen readiness is [best-effort](mobile.md#steps), not a foreground/rendered-readiness guarantee. Computer starts retain a 10-minute default timeout; an unclaimed start may fail after 120 seconds with no-host context.
|
|
12
|
+
|
|
13
|
+
Both computer operating systems are early access. Ask support@revyl.ai about access.
|
|
12
14
|
|
|
13
15
|
```ts
|
|
14
16
|
const web = await open('web'); // a target named in verify.config.ts
|
|
15
17
|
const ios = await open({ platform: 'ios', app: { appId: '…' } }); // or a target object, no config needed
|
|
16
18
|
const mac = await open({ platform: 'mac' }); // computer, no app needed
|
|
19
|
+
const linux = await open({ platform: 'linux' }); // same computer surface
|
|
17
20
|
```
|
|
18
21
|
|
|
19
22
|
- `target`: a target name from `verify.config.ts`, or a target object with the same shape as a config entry (see [configuration.md](configuration.md)). Verify reads the nearest `verify.config.ts` at or above the current folder.
|
|
@@ -22,6 +25,68 @@ const mac = await open({ platform: 'mac' }); // computer,
|
|
|
22
25
|
|
|
23
26
|
`open` needs credentials: `REVYL_API_KEY` or a Revyl CLI login. It throws `AUTH_MISSING` without them, `CONFIG_NOT_FOUND` or `CONFIG_INVALID` for config problems, and `SESSION_START_FAILED` or `TIMEOUT` when the browser or device does not start.
|
|
24
27
|
|
|
28
|
+
## Inside a revyl-macos GitHub Actions job
|
|
29
|
+
|
|
30
|
+
`currentComputer(options?)` attaches to `REVYL_COMPUTER_ID` using normal credential resolution. It is non-owning by default: close cleans up hosted children, while the job's owner releases the computer. It never creates a computer and throws `COMPUTER_ID_MISSING` when the ID is unset or blank. Invalid IDs fail with `INVALID_ARGUMENT`.
|
|
31
|
+
|
|
32
|
+
This helper is included in the TypeScript 0.3.0 release and requires a runner configured to inject `REVYL_COMPUTER_ID` and a short-lived `REVYL_API_KEY`. A workflow can supply its own `REVYL_API_KEY` instead. After installing the project dependencies and selecting Node.js ≥22.18, add this step to a job with `runs-on: revyl-macos`:
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
- name: Run Verify
|
|
36
|
+
run: |
|
|
37
|
+
node --input-type=module <<'JS'
|
|
38
|
+
import { currentComputer } from '@revyl/verify';
|
|
39
|
+
const computer = await currentComputer();
|
|
40
|
+
try {
|
|
41
|
+
await computer.screenshot({ path: 'computer.png' });
|
|
42
|
+
} finally {
|
|
43
|
+
await computer.close();
|
|
44
|
+
}
|
|
45
|
+
JS
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The helper accepts the same options as `attach`, including explicit `backendUrl` and `appUrl`; it does not read URL environment variables. `stopOnClose: true` explicitly takes ownership and can end the job's computer when closed. Use `computer.open(target)` with an exact app/build pair for hosted app sessions.
|
|
49
|
+
|
|
50
|
+
## attach(computerId, options?)
|
|
51
|
+
|
|
52
|
+
The attachment, hosted-child and navigation APIs below are included in the TypeScript 0.3.0 release.
|
|
53
|
+
|
|
54
|
+
`open()` and `computer.open()` return the extended `OpenedSession` interface. The original `Session` interface keeps its existing required members, so applications implementing or mocking it do not need to add the new methods. Use `OpenedSession` when explicitly annotating a value that calls `report`, `navigate` or `open`.
|
|
55
|
+
|
|
56
|
+
Attaches to an active Mac or Linux computer in your organization without creating a new one. `options.log` controls progress. The returned computer owns cleanup by default: `close()` stops it, and automatic cleanup also applies. Attaching requires a valid expiry and does not extend the lifetime. Invalid, inaccessible, inactive or expired computers fail without sending a stop request.
|
|
57
|
+
|
|
58
|
+
When attaching to a computer you do not own, such as a CI runner, pass `attach(computerId, { stopOnClose: false })`. The attachment is not registered for automatic cleanup. Its `close()` still cancels pending child starts and gives hosted children the same five-second cleanup phase, but never stops the computer; its owner must release it. Child cleanup failures remain observable. This option is explicit and is never inferred from the environment.
|
|
59
|
+
|
|
60
|
+
## computer.open(target)
|
|
61
|
+
|
|
62
|
+
Starts a child app session inside this computer, returning the normal session API. It inherits the parent's authenticated client, logging and step timeout; callers cannot route a child to another backend. Both `target.app.appId` and `target.app.buildId` are required. Web accepts an existing external HTTPS build, plus optional viewport/sign-in settings; native builds use the computer's fixed device profile and accept no bundle, artifact or device override. Named targets and initial URL overrides are not supported here.
|
|
63
|
+
|
|
64
|
+
Mac supports Web and iOS children. Linux supports Android and may support Web when its runtime advertises that capability; Linux Web currently has a runtime limitation, so do not rely on it without confirming availability. The server remains authoritative for access, capability and capacity. Children require and share the parent's known deadline. Closing a child leaves the parent running; closing the parent cancels pending starts and gives child cleanup at most five seconds total before requesting confirmed computer release. The parent stop also stops any remaining children, so a slow child cannot prevent that request. A failed child start never closes the parent.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const computer = await attach(existingComputerId, { log: false });
|
|
68
|
+
try {
|
|
69
|
+
const web = await computer.open({
|
|
70
|
+
platform: 'web',
|
|
71
|
+
app: { appId: webAppId, buildId: externalBuildId },
|
|
72
|
+
});
|
|
73
|
+
await web.navigate('http://localhost:3000');
|
|
74
|
+
await web.validation('The home page is visible');
|
|
75
|
+
} finally {
|
|
76
|
+
await computer.close();
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
This requires an already-running service on the computer. The initial Web build still uses HTTPS; navigation after start can reach the computer's localhost. No localhost-start option is implied.
|
|
81
|
+
|
|
82
|
+
## session.navigate(url)
|
|
83
|
+
|
|
84
|
+
Web only, hosted or normal. Sends one deterministic `open_url` action and checks the returned navigation outcome. Accepts an HTTP(S) URL without credentials, whitespace, control characters or backslashes. Loopback addresses are accepted on every Web session: a hosted browser can reach its parent computer's loopback address; a normal cloud browser's localhost is its own runtime, not your machine. A cold development server may need a warm-up request before navigation succeeds; after an uncertain timeout, re-navigating is the caller's choice because the original request may still commit. The runtime is responsible for enforcing its navigation network policy. Navigation is not replayed by Verify and does not run an agent step. It resolves with no value, or throws a typed error. Computer and native sessions reject it locally.
|
|
85
|
+
|
|
86
|
+
## session.report()
|
|
87
|
+
|
|
88
|
+
Returns authenticated JSON metadata: `/execution/device-sessions/{id}` for normal and hosted app sessions, or the computer report for computers. It works after close, but fails locally with `NOT_FOUND` when no session ID is known. It does not download recordings or turn a device stop acknowledgment into proof of physical release. Treat returned metadata as private.
|
|
89
|
+
|
|
25
90
|
## session.instruction(text, options?)
|
|
26
91
|
|
|
27
92
|
Available on web/iOS/Android only. Computers throw `NOT_AVAILABLE_ON_PLATFORM` without sending an agent request.
|
|
@@ -58,19 +123,27 @@ interface StepResult {
|
|
|
58
123
|
|
|
59
124
|
`session.steps` lists every step run on the session, in order.
|
|
60
125
|
|
|
126
|
+
When inspecting `revyl device report --session-id <id>`, read validation verdicts from the step's `effective_status` and `validation_result`, with `validations_passed` / `total_validations` for the validation summary. `passed_steps` / `failed_steps` and transport `status` are not enough: a successfully executed validation can have a false verdict. The SDK's `StepResult.passed` reflects that verdict, not just transport success.
|
|
127
|
+
|
|
61
128
|
## session.screenshot(options?)
|
|
62
129
|
|
|
63
130
|
Resolves with a PNG of the screen as a `Uint8Array`. With `options.path`, also writes it there, creating folders as needed.
|
|
64
131
|
|
|
65
132
|
## session.close()
|
|
66
133
|
|
|
67
|
-
|
|
134
|
+
Requests that Revyl stop the session. For web/mobile, success acknowledges the stop request rather than proving physical release. It is safe to call more than once, and steps after it throw `SESSION_ENDED`. A `Session` also works with `await using`, which closes it at the end of the block.
|
|
68
135
|
|
|
69
|
-
For computers, cleanup must be confirmed; a terminal state alone does not prove physical release. Uncertain release throws `ACTION_UNCERTAIN`.
|
|
136
|
+
For owning computers, cleanup must be confirmed; a terminal state alone does not prove physical release. Uncertain release throws `ACTION_UNCERTAIN`. A non-owning attachment closes its children and local handle without requesting computer release.
|
|
70
137
|
|
|
71
138
|
## Computer commands, files, and processes
|
|
72
139
|
|
|
73
|
-
Mac sessions provide `exec(argv: string[], options?)`, `readFile(path, options?)` returning `Buffer`, `writeFile(path, string | Uint8Array, options?)`, `stat(path, options?)` returning `{ kind, size }`, `processes(options?)` returning `{ id, state, exitCode? }[]`, and `terminate(id, options?)`. They throw `NOT_AVAILABLE_ON_PLATFORM` on web/iOS/Android. See [computers.md](computers.md) for complete signatures, limits, timeouts, and examples.
|
|
140
|
+
Mac and Linux sessions provide `exec(argv: string[], options?)`, `readFile(path, options?)` returning `Buffer`, `writeFile(path, string | Uint8Array, options?)`, `stat(path, options?)` returning `{ kind, size }`, `processes(options?)` returning `{ id, state, exitCode? }[]`, and `terminate(id, options?)`. They throw `NOT_AVAILABLE_ON_PLATFORM` on web/iOS/Android. See [computers.md](computers.md) for complete signatures, limits, timeouts, and examples. Both support `screenshot()`; instruction/validation do not run on computers.
|
|
141
|
+
|
|
142
|
+
### `session.recording(): Promise<ComputerRecording | null>`
|
|
143
|
+
|
|
144
|
+
**Since 0.2.0.** This method is not available in 0.1.0.
|
|
145
|
+
|
|
146
|
+
Computers only. First reads the report and requires terminal state plus confirmed release, otherwise throws `SESSION_BUSY`. Returns `{ url: string, durationSeconds?: number, startedAt?: Date }` or null for an unavailable recording. Nullable optional wire fields are omitted. This method works after `close()`, does not wait for upload, and does not download or fetch the URL. Keep the signed HTTPS URL and its query private. Request fresh metadata when the URL expires. The normal report has no recording URL. Devices reject this call locally with `NOT_AVAILABLE_ON_PLATFORM`.
|
|
74
147
|
|
|
75
148
|
## Other session properties
|
|
76
149
|
|
|
@@ -78,7 +151,8 @@ Mac sessions provide `exec(argv: string[], options?)`, `readFile(path, options?)
|
|
|
78
151
|
| --- | --- |
|
|
79
152
|
| `viewerUrl` | The session in Revyl: a live view while it runs, its recording/report after. Use `revyl device report --session-id <id>` for web/mobile, or `revyl computer report <id>` for computers. |
|
|
80
153
|
| `id` | Revyl's session id |
|
|
81
|
-
| `platform` | `'web'`, `'ios'`, `'android'`, `'mac'`, or
|
|
154
|
+
| `platform` | `'web'`, `'ios'`, `'android'`, `'mac'`, or `'linux'` |
|
|
155
|
+
| `expiresAt` | **Since 0.2.0.** Validated API expiry as a `Date`, or undefined for devices and older APIs. Computers have a hard 60-minute lifetime from create. Operations/screenshot observation are bounded by expiry; close and recording remain possible. |
|
|
82
156
|
| `target` | The target name, or the platform for a target object |
|
|
83
157
|
| `closed` | Whether `close()` was called |
|
|
84
158
|
|
|
@@ -91,13 +165,13 @@ Mac sessions provide `exec(argv: string[], options?)`, `readFile(path, options?)
|
|
|
91
165
|
|
|
92
166
|
## Cleanup
|
|
93
167
|
|
|
94
|
-
While a session is open, Verify
|
|
168
|
+
While a session is open, Verify attempts best-effort cleanup with a 15-second budget:
|
|
95
169
|
|
|
96
|
-
- on Ctrl+C or SIGTERM: it
|
|
97
|
-
- on an uncaught error, such as a failed validation that nothing catches: it prints the error,
|
|
98
|
-
- when the script finishes without calling `close()`: it
|
|
170
|
+
- on Ctrl+C or SIGTERM: it attempts to close open sessions, then exits with 130 or 143 (a second Ctrl+C exits at once);
|
|
171
|
+
- on an uncaught error, such as a failed validation that nothing catches: it prints the error, attempts cleanup, then exits with 1;
|
|
172
|
+
- when the script finishes without calling `close()`: it attempts cleanup and prints a reminder.
|
|
99
173
|
|
|
100
|
-
Verify
|
|
174
|
+
Verify skips installing a signal or error handler when that event already has listeners, so test runners and apps that manage their own shutdown keep control of it. Always close in `try/finally` or an `after` hook; automatic cleanup cannot guarantee success. A process that is killed outright, or calls `process.exit()` with sessions open, leaves browser/mobile sessions running until their idle timeout (10 minutes by default). Computers have a server-bounded one-hour lifetime instead; no idle timeout is configurable.
|
|
101
175
|
|
|
102
176
|
## defineConfig(config)
|
|
103
177
|
|
package/docs/ci.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CI and test runners
|
|
2
2
|
|
|
3
|
-
Verify is a library, so it runs wherever your code runs. Browsers, devices, and computers run in Revyl's cloud, so the runner needs
|
|
3
|
+
Verify is a library, so it runs wherever your code runs. Browsers, devices, and computers run in Revyl's cloud, so the runner needs Node.js and an API key. Install it with `npm install @revyl/verify`; in CI, `npm ci` and a `REVYL_API_KEY` secret are enough.
|
|
4
4
|
|
|
5
5
|
## A script in GitHub Actions
|
|
6
6
|
|