@revyl/verify 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -5
- package/README.md +103 -69
- package/dist/api/client.d.ts +6 -4
- package/dist/api/client.js +64 -14
- package/dist/api/types.d.ts +1 -0
- 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 +45 -11
- package/dist/computer-session.d.ts +14 -4
- package/dist/computer-session.js +106 -23
- package/dist/config.d.ts +7 -0
- 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 +10 -2
- package/dist/open.js +35 -6
- package/dist/session.d.ts +21 -2
- package/dist/session.js +134 -13
- package/docs/README.md +0 -1
- package/docs/agents.md +2 -2
- package/docs/api.md +65 -3
- package/docs/ci.md +1 -1
- package/docs/computers.md +14 -13
- package/docs/configuration.md +8 -7
- package/docs/end-to-end.md +3 -3
- package/docs/errors.md +27 -2
- package/docs/getting-started.md +33 -15
- package/docs/llms.txt +4 -5
- package/docs/web.md +3 -1
- package/package.json +1 -1
- package/docs/linux-computers-contract.md +0 -53
package/dist/config.js
CHANGED
|
@@ -71,7 +71,14 @@ export function validateTarget(raw) {
|
|
|
71
71
|
const platform = asRecord(raw, 'target', fail).platform;
|
|
72
72
|
return parseTarget(raw, typeof platform === 'string' ? platform : 'target', 'target', fail);
|
|
73
73
|
}
|
|
74
|
-
function
|
|
74
|
+
export function validateHostedTarget(raw) {
|
|
75
|
+
const fail = (path, problem) => { throw new VerifyError('INVALID_ARGUMENT', `computer.open(): ${path} ${problem}`); };
|
|
76
|
+
const target = parseTarget(raw, raw?.platform ?? 'target', 'target', fail, true);
|
|
77
|
+
if (target.platform === 'mac' || target.platform === 'linux')
|
|
78
|
+
fail('target.platform', 'must be web, ios, or android.');
|
|
79
|
+
return target;
|
|
80
|
+
}
|
|
81
|
+
function parseTarget(raw, name, path, fail, hosted = false) {
|
|
75
82
|
const target = asRecord(raw, path, fail);
|
|
76
83
|
const platform = target.platform;
|
|
77
84
|
if (typeof platform !== 'string' || !PLATFORMS.includes(platform)) {
|
|
@@ -88,6 +95,13 @@ function parseTarget(raw, name, path, fail) {
|
|
|
88
95
|
idleTimeoutSeconds: integer(target.idleTimeoutSeconds, `${path}.idleTimeoutSeconds`, 60, 3_600, 600, fail),
|
|
89
96
|
};
|
|
90
97
|
const app = asRecord(target.app, `${path}.app`, fail);
|
|
98
|
+
if (hosted) {
|
|
99
|
+
uuid(app.appId, `${path}.app.appId`, true, fail);
|
|
100
|
+
uuid(app.buildId, `${path}.app.buildId`, true, fail);
|
|
101
|
+
rejectUnknownKeys(app, ['appId', 'buildId'], `${path}.app`, fail);
|
|
102
|
+
if (target.device !== undefined)
|
|
103
|
+
fail(`${path}.device`, 'is fixed by the hosting computer.');
|
|
104
|
+
}
|
|
91
105
|
if (platform === 'web') {
|
|
92
106
|
rejectUnknownKeys(target, ['platform', 'app', 'viewport', 'signIn', 'startTimeout', 'idleTimeoutSeconds'], path, fail);
|
|
93
107
|
rejectUnknownKeys(app, ['appId', 'url', 'buildId', 'branch'], `${path}.app`, fail);
|
|
@@ -153,7 +167,7 @@ function parseTarget(raw, name, path, fail) {
|
|
|
153
167
|
fail(`${path}.app.artifactUrl`, 'must be an HTTPS URL.');
|
|
154
168
|
}
|
|
155
169
|
const sources = [app.appId, app.buildId, app.artifactUrl, app.bundleId].filter((value) => value !== undefined).length;
|
|
156
|
-
if (sources !== 1) {
|
|
170
|
+
if (!hosted && sources !== 1) {
|
|
157
171
|
fail(`${path}.app`, 'takes exactly one of appId, buildId, or artifactUrl to install an app, or bundleId to open one already on the device.');
|
|
158
172
|
}
|
|
159
173
|
let device;
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'AUTH_REJECTED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | 'COMPUTER_LIMIT' | 'NOT_AVAILABLE_ON_PLATFORM' | 'ACTION_FAILED' | 'ACTION_UNCERTAIN' | 'INSTRUCTION_FAILED' | 'VALIDATION_FAILED' | 'STEP_FAILED' | 'COMMAND_FAILED' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'TIMEOUT';
|
|
1
|
+
export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'COMPUTER_ID_MISSING' | 'AUTH_REJECTED' | 'EDGE_BLOCKED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | 'COMPUTER_LIMIT' | 'NOT_AVAILABLE_ON_PLATFORM' | 'ACTION_FAILED' | 'ACTION_UNCERTAIN' | 'INSTRUCTION_FAILED' | 'VALIDATION_FAILED' | 'STEP_FAILED' | 'COMMAND_FAILED' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'TIMEOUT';
|
|
2
|
+
export type RefusalReason = 'web_runtime_unavailable' | 'web_runtime_not_configured' | 'computer_limit' | 'runtime_unavailable' | 'computer_runtime_unavailable' | 'computer_at_capacity' | 'computer_not_running' | 'computer_cannot_host_platform' | 'computer_not_found' | 'web_build_required' | 'web_build_not_supported' | 'ios_build_required' | 'ios_build_not_supported' | 'android_build_required' | 'android_build_not_supported' | 'web_session_conflict' | 'ios_session_conflict' | 'android_session_conflict' | 'not_available_on_platform' | 'request_timeout' | 'protocol_error';
|
|
2
3
|
export declare class VerifyError extends Error {
|
|
3
4
|
readonly code: VerifyErrorCode;
|
|
5
|
+
readonly reason?: RefusalReason;
|
|
6
|
+
readonly httpStatus?: number;
|
|
4
7
|
constructor(code: VerifyErrorCode, message: string, options?: {
|
|
5
8
|
cause?: unknown;
|
|
9
|
+
reason?: RefusalReason;
|
|
10
|
+
httpStatus?: number;
|
|
6
11
|
});
|
|
7
12
|
}
|
|
8
13
|
export declare function isVerifyError(error: unknown): error is VerifyError;
|
package/dist/errors.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export class VerifyError extends Error {
|
|
2
2
|
code;
|
|
3
|
+
reason;
|
|
4
|
+
httpStatus;
|
|
3
5
|
constructor(code, message, options) {
|
|
4
6
|
super(message, options);
|
|
5
7
|
this.name = 'VerifyError';
|
|
6
8
|
this.code = code;
|
|
9
|
+
this.reason = options?.reason;
|
|
10
|
+
this.httpStatus = options?.httpStatus;
|
|
7
11
|
}
|
|
8
12
|
}
|
|
9
13
|
export function isVerifyError(error) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { open } from './open.js';
|
|
2
|
-
export type { OpenOptions } from './open.js';
|
|
3
|
-
export type { Session, StepOptions, StepResult, StepType, ValidationOptions, OperationOptions, ExecOptions, ExecResult, FileStat, ComputerProcess, ComputerRecording } from './session.js';
|
|
1
|
+
export { attach, currentComputer, open } from './open.js';
|
|
2
|
+
export type { AttachOptions, OpenOptions } from './open.js';
|
|
3
|
+
export type { OpenedSession, Session, StepOptions, StepResult, StepType, ValidationOptions, OperationOptions, ExecOptions, ExecResult, FileStat, ComputerProcess, ComputerRecording } from './session.js';
|
|
4
4
|
export { defineConfig } from './config.js';
|
|
5
|
-
export type { MobileAppConfig, MobileTargetConfig, ComputerTargetConfig, TargetConfig, VerifyConfig, WebAppConfig, WebTargetConfig } from './config.js';
|
|
5
|
+
export type { HostedTargetConfig, MobileAppConfig, MobileTargetConfig, ComputerTargetConfig, TargetConfig, VerifyConfig, WebAppConfig, WebTargetConfig } from './config.js';
|
|
6
6
|
export type { Platform, ComputerPlatform, MobilePlatform } from './api/types.js';
|
|
7
7
|
export { VerifyError, isVerifyError } from './errors.js';
|
|
8
|
-
export type { VerifyErrorCode } from './errors.js';
|
|
8
|
+
export type { RefusalReason, VerifyErrorCode } from './errors.js';
|
package/dist/index.js
CHANGED
package/dist/open.d.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { type TargetConfig } from './config.js';
|
|
2
|
-
import { type
|
|
2
|
+
import { type OpenedSession } from './session.js';
|
|
3
|
+
import { ComputerSession } from './computer-session.js';
|
|
3
4
|
export interface OpenOptions {
|
|
4
5
|
/** The config file to read target names from. Defaults to the nearest verify.config.ts at or above the current folder. */
|
|
5
6
|
config?: string;
|
|
6
7
|
/** Where progress lines go: the session link and each step's outcome. Defaults to stderr; false turns them off. */
|
|
7
8
|
log?: false | ((line: string) => void);
|
|
9
|
+
backendUrl?: string;
|
|
10
|
+
appUrl?: string;
|
|
8
11
|
}
|
|
12
|
+
export interface AttachOptions extends Pick<OpenOptions, 'log' | 'backendUrl' | 'appUrl'> {
|
|
13
|
+
stopOnClose?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function attach(computerId: string, options?: AttachOptions): Promise<ComputerSession>;
|
|
16
|
+
export declare function currentComputer(options?: AttachOptions): Promise<ComputerSession>;
|
|
9
17
|
/**
|
|
10
18
|
* Starts a Revyl cloud browser, device, or computer. Mobile first-screen readiness is best-effort.
|
|
11
19
|
* Pass a target name from verify.config.ts, such as open('web'), or a target object.
|
|
12
20
|
*/
|
|
13
|
-
export declare function open(target: string | TargetConfig, options?: OpenOptions): Promise<
|
|
21
|
+
export declare function open(target: string | TargetConfig, options?: OpenOptions): Promise<OpenedSession>;
|
package/dist/open.js
CHANGED
|
@@ -1,9 +1,40 @@
|
|
|
1
1
|
import { RevylClient } from './api/client.js';
|
|
2
2
|
import { missingCredentialsMessage, resolveCredentials } from './auth.js';
|
|
3
|
+
import { signIn } from './cli/login.js';
|
|
3
4
|
import { DEFAULT_STEP_TIMEOUT_MS, loadConfig, validateTarget } from './config.js';
|
|
4
5
|
import { VerifyError } from './errors.js';
|
|
5
6
|
import { DeviceSession } from './session.js';
|
|
6
7
|
import { ComputerSession } from './computer-session.js';
|
|
8
|
+
const signIns = new Map();
|
|
9
|
+
async function credentialsForUse(options) {
|
|
10
|
+
const credentials = await resolveCredentials();
|
|
11
|
+
if (credentials)
|
|
12
|
+
return credentials;
|
|
13
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY || process.env.CI)
|
|
14
|
+
throw new VerifyError('AUTH_MISSING', missingCredentialsMessage());
|
|
15
|
+
const key = JSON.stringify([options.backendUrl, options.appUrl]);
|
|
16
|
+
let pending = signIns.get(key);
|
|
17
|
+
if (!pending) {
|
|
18
|
+
pending = signIn(options).finally(() => signIns.delete(key));
|
|
19
|
+
signIns.set(key, pending);
|
|
20
|
+
}
|
|
21
|
+
return pending;
|
|
22
|
+
}
|
|
23
|
+
export async function attach(computerId, options = {}) {
|
|
24
|
+
if (typeof computerId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(computerId))
|
|
25
|
+
throw new VerifyError('INVALID_ARGUMENT', 'attach() requires a computer session UUID.');
|
|
26
|
+
if (options.stopOnClose !== undefined && typeof options.stopOnClose !== 'boolean')
|
|
27
|
+
throw new VerifyError('INVALID_ARGUMENT', 'stopOnClose must be a boolean.');
|
|
28
|
+
const credentials = await credentialsForUse(options);
|
|
29
|
+
const log = options.log === false ? undefined : (options.log ?? ((line) => void process.stderr.write(`[verify] ${line}\n`)));
|
|
30
|
+
return ComputerSession.attach(new RevylClient({ token: credentials.token, backendUrl: options.backendUrl }), computerId, { stepTimeout: DEFAULT_STEP_TIMEOUT_MS, log, appUrl: options.appUrl, stopOnClose: options.stopOnClose });
|
|
31
|
+
}
|
|
32
|
+
export async function currentComputer(options = {}) {
|
|
33
|
+
const computerId = process.env.REVYL_COMPUTER_ID?.trim();
|
|
34
|
+
if (!computerId)
|
|
35
|
+
throw new VerifyError('COMPUTER_ID_MISSING', 'No current Revyl computer is available. Run this job on a revyl-macos runner.');
|
|
36
|
+
return attach(computerId, { ...options, stopOnClose: options.stopOnClose === undefined ? false : options.stopOnClose });
|
|
37
|
+
}
|
|
7
38
|
/**
|
|
8
39
|
* Starts a Revyl cloud browser, device, or computer. Mobile first-screen readiness is best-effort.
|
|
9
40
|
* Pass a target name from verify.config.ts, such as open('web'), or a target object.
|
|
@@ -22,12 +53,10 @@ export async function open(target, options = {}) {
|
|
|
22
53
|
else {
|
|
23
54
|
resolved = validateTarget(target);
|
|
24
55
|
}
|
|
25
|
-
const credentials = await
|
|
26
|
-
if (!credentials)
|
|
27
|
-
throw new VerifyError('AUTH_MISSING', missingCredentialsMessage());
|
|
56
|
+
const credentials = await credentialsForUse(options);
|
|
28
57
|
const log = options.log === false ? undefined : (options.log ?? ((line) => void process.stderr.write(`[verify] ${line}\n`)));
|
|
29
|
-
const client = new RevylClient({ token: credentials.token });
|
|
58
|
+
const client = new RevylClient({ token: credentials.token, backendUrl: options.backendUrl });
|
|
30
59
|
if (resolved.platform === 'mac' || resolved.platform === 'linux')
|
|
31
|
-
return ComputerSession.start(client, resolved, { stepTimeout, log });
|
|
32
|
-
return DeviceSession.start(client, resolved, { stepTimeout, log });
|
|
60
|
+
return ComputerSession.start(client, resolved, { stepTimeout, log, appUrl: options.appUrl });
|
|
61
|
+
return DeviceSession.start(client, resolved, { stepTimeout, log, appUrl: options.appUrl });
|
|
33
62
|
}
|
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 {
|
|
@@ -91,9 +91,21 @@ export interface Session {
|
|
|
91
91
|
close(): Promise<void>;
|
|
92
92
|
[Symbol.asyncDispose](): Promise<void>;
|
|
93
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
|
+
}
|
|
94
99
|
export interface SessionOptions {
|
|
95
100
|
stepTimeout: number;
|
|
96
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;
|
|
97
109
|
}
|
|
98
110
|
/** A Revyl cloud browser or iOS or Android device. */
|
|
99
111
|
export declare class DeviceSession implements Session {
|
|
@@ -111,8 +123,12 @@ export declare class DeviceSession implements Session {
|
|
|
111
123
|
private workflowRunId;
|
|
112
124
|
private closePromise;
|
|
113
125
|
private stepRunning;
|
|
126
|
+
private parent?;
|
|
127
|
+
private readySignal?;
|
|
128
|
+
get expiresAt(): Date | undefined;
|
|
114
129
|
private constructor();
|
|
115
|
-
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;
|
|
116
132
|
get closed(): boolean;
|
|
117
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. */
|
|
118
134
|
instruction(text: string, options?: StepOptions): Promise<StepResult>;
|
|
@@ -125,7 +141,10 @@ export declare class DeviceSession implements Session {
|
|
|
125
141
|
screenshot(options?: {
|
|
126
142
|
path?: string;
|
|
127
143
|
}): Promise<Uint8Array>;
|
|
144
|
+
report(): Promise<Record<string, unknown>>;
|
|
145
|
+
navigate(url: string): Promise<void>;
|
|
128
146
|
exec(): Promise<ExecResult>;
|
|
147
|
+
open(): Promise<never>;
|
|
129
148
|
recording(): Promise<never>;
|
|
130
149
|
readFile(): Promise<Buffer>;
|
|
131
150
|
writeFile(): Promise<void>;
|
package/dist/session.js
CHANGED
|
@@ -2,7 +2,7 @@ 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
8
|
import { screenHasContent } from './screen-readiness.js';
|
|
@@ -32,6 +32,11 @@ export class DeviceSession {
|
|
|
32
32
|
workflowRunId;
|
|
33
33
|
closePromise;
|
|
34
34
|
stepRunning = false;
|
|
35
|
+
parent;
|
|
36
|
+
readySignal;
|
|
37
|
+
get expiresAt() {
|
|
38
|
+
return this.parent?.expiresAt;
|
|
39
|
+
}
|
|
35
40
|
constructor(client, target, id, workflowRunId, options) {
|
|
36
41
|
this.client = client;
|
|
37
42
|
this.options = options;
|
|
@@ -39,10 +44,12 @@ export class DeviceSession {
|
|
|
39
44
|
this.target = target.name;
|
|
40
45
|
this.id = id;
|
|
41
46
|
this.workflowRunId = workflowRunId;
|
|
42
|
-
this.viewerUrl = sessionViewerUrl(id);
|
|
47
|
+
this.viewerUrl = sessionViewerUrl(id, undefined, options.appUrl);
|
|
43
48
|
}
|
|
44
|
-
static async start(client, target, options) {
|
|
45
|
-
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);
|
|
46
53
|
if (target.platform === 'web') {
|
|
47
54
|
const buildId = await resolveWebBuildId(client, target);
|
|
48
55
|
const sessionId = randomUUID();
|
|
@@ -99,6 +106,73 @@ export class DeviceSession {
|
|
|
99
106
|
});
|
|
100
107
|
return session;
|
|
101
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
|
+
}
|
|
102
176
|
get closed() {
|
|
103
177
|
return this.closePromise !== undefined;
|
|
104
178
|
}
|
|
@@ -133,9 +207,41 @@ export class DeviceSession {
|
|
|
133
207
|
}
|
|
134
208
|
return response.body;
|
|
135
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
|
+
}
|
|
136
239
|
exec() {
|
|
137
240
|
return this.computerOnly('exec');
|
|
138
241
|
}
|
|
242
|
+
open() {
|
|
243
|
+
return this.computerOnly('open');
|
|
244
|
+
}
|
|
139
245
|
recording() {
|
|
140
246
|
return this.computerOnly('recording');
|
|
141
247
|
}
|
|
@@ -159,7 +265,7 @@ export class DeviceSession {
|
|
|
159
265
|
this.closePromise ??= (async () => {
|
|
160
266
|
try {
|
|
161
267
|
if (this.id)
|
|
162
|
-
await this.client.stopDeviceSession(this.id);
|
|
268
|
+
await this.client.stopDeviceSession(this.id, this.parent === undefined);
|
|
163
269
|
else
|
|
164
270
|
await this.client.cancelDevice(this.workflowRunId);
|
|
165
271
|
this.log('closed');
|
|
@@ -271,7 +377,7 @@ export class DeviceSession {
|
|
|
271
377
|
while (Date.now() < deadline) {
|
|
272
378
|
try {
|
|
273
379
|
const response = await this.client.worker(this.workflowRunId, 'screenshot', {
|
|
274
|
-
method: 'GET', retry: false, signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
|
|
380
|
+
method: 'GET', retry: false, signal: AbortSignal.any([AbortSignal.timeout(Math.max(1, deadline - Date.now())), ...(this.readySignal ? [this.readySignal] : [])]),
|
|
275
381
|
});
|
|
276
382
|
if (response.status === 200) {
|
|
277
383
|
const content = screenHasContent(response.body);
|
|
@@ -300,11 +406,24 @@ export class DeviceSession {
|
|
|
300
406
|
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${call}() runs on computer targets (platform 'mac' or 'linux'), not on ${this.platform} sessions.`));
|
|
301
407
|
}
|
|
302
408
|
async worker(method, action, options) {
|
|
303
|
-
if (this.closePromise)
|
|
304
|
-
throw new VerifyError('SESSION_ENDED', `The ${this.target} session was closed.`);
|
|
305
|
-
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 });
|
|
306
417
|
if (response.status >= 200 && response.status < 300)
|
|
307
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
|
+
}
|
|
308
427
|
throw this.workerError(method, action, response);
|
|
309
428
|
}
|
|
310
429
|
workerError(method, action, response) {
|
|
@@ -348,13 +467,14 @@ export class DeviceSession {
|
|
|
348
467
|
}
|
|
349
468
|
async waitForWebReady(deadline) {
|
|
350
469
|
for (;;) {
|
|
351
|
-
|
|
470
|
+
this.readySignal?.throwIfAborted();
|
|
471
|
+
const detail = await this.client.getDeviceSession(this.id, this.readySignal);
|
|
352
472
|
if (ENDED_STATUSES.has(detail.status)) {
|
|
353
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}`);
|
|
354
474
|
}
|
|
355
475
|
if (detail.status === 'running' && detail.workflow_run_id) {
|
|
356
476
|
this.workflowRunId = detail.workflow_run_id;
|
|
357
|
-
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 });
|
|
358
478
|
if (response.status === 200 && pngSize(response.body))
|
|
359
479
|
return;
|
|
360
480
|
if (response.status !== 503 && response.status !== 409 && responseErrorCode(response) !== 'not_ready') {
|
|
@@ -366,7 +486,8 @@ export class DeviceSession {
|
|
|
366
486
|
}
|
|
367
487
|
async waitForDeviceReady(deadline) {
|
|
368
488
|
for (;;) {
|
|
369
|
-
|
|
489
|
+
this.readySignal?.throwIfAborted();
|
|
490
|
+
const connection = await this.client.getWorkerConnection(this.workflowRunId, this.readySignal);
|
|
370
491
|
if (connection.status === 'ready' && connection.worker_ws_url)
|
|
371
492
|
break;
|
|
372
493
|
if (connection.status === 'cancelled' || connection.status === 'stopped' || connection.status === 'failed') {
|
|
@@ -376,7 +497,7 @@ export class DeviceSession {
|
|
|
376
497
|
}
|
|
377
498
|
const connectDeadline = Math.min(deadline, Date.now() + DEVICE_CONNECT_TIMEOUT_MS);
|
|
378
499
|
for (;;) {
|
|
379
|
-
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 });
|
|
380
501
|
if (response.status === 200 && parseJson(response, 'health').device_connected)
|
|
381
502
|
return;
|
|
382
503
|
if (response.status !== 200 && response.status !== 503)
|
package/docs/README.md
CHANGED
|
@@ -11,7 +11,6 @@ These files ship inside the npm package at `node_modules/@revyl/verify/docs/`, s
|
|
|
11
11
|
| [web.md](web.md) | Open a web app in a Revyl cloud browser |
|
|
12
12
|
| [mobile.md](mobile.md) | Open an iOS or Android app on a Revyl cloud device |
|
|
13
13
|
| [computers.md](computers.md) | Open a Mac or Linux computer for commands, files, processes, screenshots, and optional recording metadata |
|
|
14
|
-
| [linux-computers-contract.md](linux-computers-contract.md) | Read the implemented shared Linux computer contract |
|
|
15
14
|
| [ci.md](ci.md) | Call Verify from CI or from a test runner you already use |
|
|
16
15
|
| [errors.md](errors.md) | Understand an error code and fix it |
|
|
17
16
|
| [agents.md](agents.md) | Use Verify as a coding agent |
|
package/docs/agents.md
CHANGED
|
@@ -24,9 +24,9 @@ This page is for AI coding agents (Claude Code, Cursor, Codex, and others) that
|
|
|
24
24
|
|
|
25
25
|
## Rules
|
|
26
26
|
|
|
27
|
-
Mac and Linux computers are early access
|
|
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.
|
|
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. 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
30
|
|
|
31
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.
|
|
32
32
|
|
package/docs/api.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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?)
|
|
@@ -10,7 +10,7 @@ Linux targets are supported **since 0.2.0**; 0.1.0 includes the target type but
|
|
|
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
12
|
|
|
13
|
-
Both computer operating systems are early access
|
|
13
|
+
Both computer operating systems are early access. Ask support@revyl.ai about access.
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
16
|
const web = await open('web'); // a target named in verify.config.ts
|
|
@@ -25,6 +25,68 @@ const linux = await open({ platform: 'linux' }); // same comp
|
|
|
25
25
|
|
|
26
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.
|
|
27
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.1 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.1 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
|
+
|
|
28
90
|
## session.instruction(text, options?)
|
|
29
91
|
|
|
30
92
|
Available on web/iOS/Android only. Computers throw `NOT_AVAILABLE_ON_PLATFORM` without sending an agent request.
|
|
@@ -71,7 +133,7 @@ Resolves with a PNG of the screen as a `Uint8Array`. With `options.path`, also w
|
|
|
71
133
|
|
|
72
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.
|
|
73
135
|
|
|
74
|
-
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.
|
|
75
137
|
|
|
76
138
|
## Computer commands, files, and processes
|
|
77
139
|
|
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
|
|