@revyl/verify 0.0.0-stage → 0.1.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.
@@ -0,0 +1,9 @@
1
+ export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'AUTH_REJECTED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | 'NOT_AVAILABLE_ON_PLATFORM' | 'ACTION_FAILED' | 'ACTION_UNCERTAIN' | 'INSTRUCTION_FAILED' | 'VALIDATION_FAILED' | 'STEP_FAILED' | 'COMMAND_FAILED' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'TIMEOUT';
2
+ export declare class VerifyError extends Error {
3
+ readonly code: VerifyErrorCode;
4
+ constructor(code: VerifyErrorCode, message: string, options?: {
5
+ cause?: unknown;
6
+ });
7
+ }
8
+ export declare function isVerifyError(error: unknown): error is VerifyError;
9
+ export declare function errorMessage(error: unknown): string;
package/dist/errors.js ADDED
@@ -0,0 +1,14 @@
1
+ export class VerifyError extends Error {
2
+ code;
3
+ constructor(code, message, options) {
4
+ super(message, options);
5
+ this.name = 'VerifyError';
6
+ this.code = code;
7
+ }
8
+ }
9
+ export function isVerifyError(error) {
10
+ return error instanceof Error && typeof error.code === 'string' && error.name === 'VerifyError';
11
+ }
12
+ export function errorMessage(error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
@@ -0,0 +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 } from './session.js';
4
+ export { defineConfig } from './config.js';
5
+ export type { MobileAppConfig, MobileTargetConfig, ComputerTargetConfig, TargetConfig, VerifyConfig, WebAppConfig, WebTargetConfig } from './config.js';
6
+ export type { Platform, ComputerPlatform, MobilePlatform } from './api/types.js';
7
+ export { VerifyError, isVerifyError } from './errors.js';
8
+ export type { VerifyErrorCode } from './errors.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { open } from './open.js';
2
+ export { defineConfig } from './config.js';
3
+ export { VerifyError, isVerifyError } from './errors.js';
package/dist/open.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { type TargetConfig } from './config.js';
2
+ import { type Session } from './session.js';
3
+ export interface OpenOptions {
4
+ /** The config file to read target names from. Defaults to the nearest verify.config.ts at or above the current folder. */
5
+ config?: string;
6
+ /** Where progress lines go: the session link and each step's outcome. Defaults to stderr; false turns them off. */
7
+ log?: false | ((line: string) => void);
8
+ }
9
+ /**
10
+ * Starts a Revyl cloud browser, device, or Mac computer and resolves once it is ready.
11
+ * Pass a target name from verify.config.ts, such as open('web'), or a target object.
12
+ */
13
+ export declare function open(target: string | TargetConfig, options?: OpenOptions): Promise<Session>;
package/dist/open.js ADDED
@@ -0,0 +1,36 @@
1
+ import { RevylClient } from './api/client.js';
2
+ import { missingCredentialsMessage, resolveCredentials } from './auth.js';
3
+ import { DEFAULT_STEP_TIMEOUT_MS, loadConfig, validateTarget } from './config.js';
4
+ import { VerifyError } from './errors.js';
5
+ import { DeviceSession } from './session.js';
6
+ import { ComputerSession } from './computer-session.js';
7
+ /**
8
+ * Starts a Revyl cloud browser, device, or Mac computer and resolves once it is ready.
9
+ * Pass a target name from verify.config.ts, such as open('web'), or a target object.
10
+ */
11
+ export async function open(target, options = {}) {
12
+ let resolved;
13
+ let stepTimeout = DEFAULT_STEP_TIMEOUT_MS;
14
+ if (typeof target === 'string') {
15
+ const config = await loadConfig(process.cwd(), options.config);
16
+ resolved = config.targets[target];
17
+ if (!resolved) {
18
+ throw new VerifyError('INVALID_ARGUMENT', `open('${target}'): ${config.configFile} has no target named "${target}". Targets: ${Object.keys(config.targets).join(', ')}.`);
19
+ }
20
+ stepTimeout = config.stepTimeout;
21
+ }
22
+ else {
23
+ resolved = validateTarget(target);
24
+ }
25
+ if (resolved.platform === 'linux') {
26
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'Linux computers are planned, but are not available in Verify yet. See docs/computers.md.');
27
+ }
28
+ const credentials = await resolveCredentials();
29
+ if (!credentials)
30
+ throw new VerifyError('AUTH_MISSING', missingCredentialsMessage());
31
+ const log = options.log === false ? undefined : (options.log ?? ((line) => void process.stderr.write(`[verify] ${line}\n`)));
32
+ const client = new RevylClient({ token: credentials.token });
33
+ if (resolved.platform === 'mac')
34
+ return ComputerSession.start(client, resolved, { stepTimeout, log });
35
+ return DeviceSession.start(client, resolved, { stepTimeout, log });
36
+ }
@@ -0,0 +1,144 @@
1
+ import { RevylClient } from './api/client.js';
2
+ import type { LiveStepType, Platform } from './api/types.js';
3
+ import type { ResolvedDeviceTarget } from './config.js';
4
+ export type StepType = LiveStepType;
5
+ /** The outcome of one instruction or validation, as Revyl's agent reported it. */
6
+ export interface StepResult {
7
+ type: StepType;
8
+ text: string;
9
+ passed: boolean;
10
+ /** Revyl's explanation, when it gave one. */
11
+ reason?: string;
12
+ durationMs: number;
13
+ }
14
+ export interface StepOptions {
15
+ /** Milliseconds before the step is cancelled on Revyl. Defaults to stepTimeout from the config, or 180000. */
16
+ timeout?: number;
17
+ }
18
+ export interface ValidationOptions extends StepOptions {
19
+ /** Return a failed result instead of throwing VALIDATION_FAILED. */
20
+ soft?: boolean;
21
+ }
22
+ export interface OperationOptions {
23
+ /** Milliseconds Revyl lets the operation run on the computer, up to 300000. Defaults to 30000. */
24
+ timeout?: number;
25
+ }
26
+ export interface ExecOptions extends OperationOptions {
27
+ /** Throw COMMAND_FAILED when the command exits non-zero. Defaults to true; false returns the result instead. */
28
+ check?: boolean;
29
+ /** Bytes sent to the command's standard input. */
30
+ stdin?: string | Uint8Array;
31
+ }
32
+ /** The outcome of a command run on a computer. Revyl keeps the first 1 MiB of stdout and stderr together. */
33
+ export interface ExecResult {
34
+ exitCode: number;
35
+ stdout: string;
36
+ stderr: string;
37
+ }
38
+ export interface FileStat {
39
+ kind: 'file' | 'directory';
40
+ size: number;
41
+ }
42
+ /** A process a command on this computer started. Its id is Revyl's handle, not a PID. */
43
+ export interface ComputerProcess {
44
+ id: string;
45
+ state: 'running' | 'exited';
46
+ exitCode?: number;
47
+ }
48
+ /**
49
+ * One live Revyl session: a cloud browser, an iOS or Android device, or a computer.
50
+ * A call the session's platform does not offer throws NOT_AVAILABLE_ON_PLATFORM.
51
+ */
52
+ export interface Session {
53
+ readonly platform: Platform;
54
+ /** The target name: its key in verify.config.ts, or the platform for a target passed inline. */
55
+ readonly target: string;
56
+ /** Revyl's session id. */
57
+ readonly id: string;
58
+ /** The live view of the session in Revyl, and its recording and report once it ends. */
59
+ readonly viewerUrl: string;
60
+ /** Every instruction and validation run on this session, in order. */
61
+ readonly steps: StepResult[];
62
+ readonly closed: boolean;
63
+ /** Asks Revyl's agent to do something on the app. Browsers and devices only. */
64
+ instruction(text: string, options?: StepOptions): Promise<StepResult>;
65
+ /** Asks Revyl's agent whether something holds on screen. Browsers and devices only. */
66
+ validation(text: string, options?: ValidationOptions): Promise<StepResult>;
67
+ /** A PNG of the screen right now, also written to path when given. */
68
+ screenshot(options?: {
69
+ path?: string;
70
+ }): Promise<Uint8Array>;
71
+ /** Runs a program with its arguments, not a shell line. Computers only. */
72
+ exec(argv: string[], options?: ExecOptions): Promise<ExecResult>;
73
+ /** Up to the first 1 MiB of a file, by a path relative to the computer's workspace. Computers only. */
74
+ readFile(path: string, options?: OperationOptions): Promise<Buffer>;
75
+ /** Writes up to 64 KiB to a file, by a path relative to the computer's workspace. Computers only. */
76
+ writeFile(path: string, data: string | Uint8Array, options?: OperationOptions): Promise<void>;
77
+ /** Whether a path is a file or a directory, and its size. Computers only. */
78
+ stat(path: string, options?: OperationOptions): Promise<FileStat>;
79
+ /** The processes commands on this computer started. Computers only. */
80
+ processes(options?: OperationOptions): Promise<ComputerProcess[]>;
81
+ /** Stops a process from processes(). Computers only. */
82
+ terminate(processId: string, options?: OperationOptions): Promise<void>;
83
+ /** Stops the session on Revyl. Safe to call more than once. */
84
+ close(): Promise<void>;
85
+ [Symbol.asyncDispose](): Promise<void>;
86
+ }
87
+ export interface SessionOptions {
88
+ stepTimeout: number;
89
+ log: ((line: string) => void) | undefined;
90
+ }
91
+ /** A Revyl cloud browser or iOS or Android device. */
92
+ export declare class DeviceSession implements Session {
93
+ private readonly client;
94
+ private readonly options;
95
+ readonly platform: Platform;
96
+ /** The target name: its key in verify.config.ts, or the platform for a target passed inline. */
97
+ readonly target: string;
98
+ /** Revyl's session id. */
99
+ readonly id: string;
100
+ /** The live view of the session in Revyl, and its recording and report once it ends. */
101
+ readonly viewerUrl: string;
102
+ /** Every instruction and validation run on this session, in order. */
103
+ readonly steps: StepResult[];
104
+ private workflowRunId;
105
+ private closePromise;
106
+ private stepRunning;
107
+ private constructor();
108
+ static start(client: RevylClient, target: ResolvedDeviceTarget, options: SessionOptions): Promise<DeviceSession>;
109
+ get closed(): boolean;
110
+ /** 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
+ instruction(text: string, options?: StepOptions): Promise<StepResult>;
112
+ /**
113
+ * Asks Revyl's agent whether something holds on screen, such as "the cart shows 2 items". Throws VALIDATION_FAILED when it does not,
114
+ * unless soft is set, and STEP_FAILED when Revyl could not reach a verdict.
115
+ */
116
+ validation(text: string, options?: ValidationOptions): Promise<StepResult>;
117
+ /** A PNG of the screen right now, also written to path when given. */
118
+ screenshot(options?: {
119
+ path?: string;
120
+ }): Promise<Uint8Array>;
121
+ exec(): Promise<ExecResult>;
122
+ readFile(): Promise<Buffer>;
123
+ writeFile(): Promise<void>;
124
+ stat(): Promise<FileStat>;
125
+ processes(): Promise<ComputerProcess[]>;
126
+ terminate(): Promise<void>;
127
+ /** Stops the session on Revyl. Safe to call more than once. */
128
+ close(): Promise<void>;
129
+ [Symbol.asyncDispose](): Promise<void>;
130
+ private step;
131
+ private runStep;
132
+ private launch;
133
+ private log;
134
+ private computerOnly;
135
+ private worker;
136
+ private workerError;
137
+ private untilReady;
138
+ private waitForWebReady;
139
+ private waitForDeviceReady;
140
+ }
141
+ export declare function pngSize(bytes: Uint8Array): {
142
+ width: number;
143
+ height: number;
144
+ } | undefined;
@@ -0,0 +1,411 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { apiError, parseJson, responseDetail, responseErrorCode, sessionViewerUrl, } from './api/client.js';
6
+ import { trackSession, untrackSession } from './cleanup.js';
7
+ import { VerifyError, errorMessage, isVerifyError } from './errors.js';
8
+ const POLL_INTERVAL_MS = 2_000;
9
+ const DEVICE_CONNECT_TIMEOUT_MS = 90_000;
10
+ const STEP_POLL_BASE_MS = 500;
11
+ const STEP_POLL_MAX_MS = 5_000;
12
+ const STEP_CANCEL_TIMEOUT_MS = 5_000;
13
+ const MAX_STEP_TEXT_LENGTH = 4_000;
14
+ const ENDED_STATUSES = new Set(['completed', 'failed', 'cancelled', 'timeout', 'stopping']);
15
+ const STEP_TERMINAL_STATUSES = new Set(['completed', 'failed']);
16
+ const SESSION_NOT_RUNNING = /session is (not running|not active|stopping)/i;
17
+ const UNCERTAIN_ERROR_CODES = new Set(['request_timeout', 'connection_closed', 'protocol_error']);
18
+ /** A Revyl cloud browser or iOS or Android device. */
19
+ export class DeviceSession {
20
+ client;
21
+ options;
22
+ platform;
23
+ /** The target name: its key in verify.config.ts, or the platform for a target passed inline. */
24
+ target;
25
+ /** Revyl's session id. */
26
+ id;
27
+ /** The live view of the session in Revyl, and its recording and report once it ends. */
28
+ viewerUrl;
29
+ /** Every instruction and validation run on this session, in order. */
30
+ steps = [];
31
+ workflowRunId;
32
+ closePromise;
33
+ stepRunning = false;
34
+ constructor(client, target, id, workflowRunId, options) {
35
+ this.client = client;
36
+ this.options = options;
37
+ this.platform = target.platform;
38
+ this.target = target.name;
39
+ this.id = id;
40
+ this.workflowRunId = workflowRunId;
41
+ this.viewerUrl = sessionViewerUrl(id);
42
+ }
43
+ static async start(client, target, options) {
44
+ const deadline = Date.now() + target.startTimeout;
45
+ if (target.platform === 'web') {
46
+ const buildId = await resolveWebBuildId(client, target);
47
+ const sessionId = randomUUID();
48
+ const request = {
49
+ platform: 'web',
50
+ session_id: sessionId,
51
+ app_id: target.app.appId,
52
+ build_id: buildId,
53
+ idle_timeout_seconds: target.idleTimeoutSeconds,
54
+ web: {
55
+ ...(target.viewport
56
+ ? {
57
+ viewport: {
58
+ width: target.viewport.width,
59
+ height: target.viewport.height,
60
+ ...(target.viewport.deviceScaleFactor ? { device_scale_factor: target.viewport.deviceScaleFactor } : {}),
61
+ },
62
+ }
63
+ : {}),
64
+ ...(target.signIn
65
+ ? { sign_in: { path: target.signIn.path, secret_name: target.signIn.secret, ...(target.signIn.persona ? { persona: target.signIn.persona } : {}) } }
66
+ : {}),
67
+ },
68
+ };
69
+ const response = await client.startDevice(request);
70
+ if (response.session_id !== sessionId) {
71
+ throw new VerifyError('SESSION_START_FAILED', `Revyl did not start the ${target.name} web session: ${response.error ?? response.message ?? 'the start was not acknowledged'}`);
72
+ }
73
+ const session = new DeviceSession(client, target, sessionId, response.workflow_run_id ?? '', options);
74
+ await session.untilReady(() => session.waitForWebReady(deadline));
75
+ return session;
76
+ }
77
+ const { app } = target;
78
+ const request = {
79
+ platform: target.platform,
80
+ is_simulation: true,
81
+ idle_timeout_seconds: target.idleTimeoutSeconds,
82
+ ...(app.appId ? { app_id: app.appId } : {}),
83
+ ...(app.buildId ? { build_id: app.buildId } : {}),
84
+ ...(app.artifactUrl ? { app_url: app.artifactUrl } : {}),
85
+ ...(target.device?.model ? { device_model: target.device.model } : {}),
86
+ ...(target.device?.osVersion ? { os_version: target.device.osVersion } : {}),
87
+ };
88
+ const response = await client.startDevice(request);
89
+ if (!response.workflow_run_id) {
90
+ throw new VerifyError('SESSION_START_FAILED', `Revyl did not start the ${target.name} device: ${response.error ?? response.message ?? 'no workflow run was returned'}`);
91
+ }
92
+ const session = new DeviceSession(client, target, response.session_id ?? '', response.workflow_run_id, options);
93
+ await session.untilReady(async () => {
94
+ await session.waitForDeviceReady(deadline);
95
+ if (app.bundleId)
96
+ await session.launch(app.bundleId);
97
+ });
98
+ return session;
99
+ }
100
+ get closed() {
101
+ return this.closePromise !== undefined;
102
+ }
103
+ /** 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. */
104
+ async instruction(text, options = {}) {
105
+ const result = await this.step('instruction', text, options.timeout);
106
+ if (!result.passed) {
107
+ throw new VerifyError('INSTRUCTION_FAILED', `Instruction failed: "${result.text}". ${sentence(result.reason)} See ${this.viewerUrl}`);
108
+ }
109
+ return result;
110
+ }
111
+ /**
112
+ * Asks Revyl's agent whether something holds on screen, such as "the cart shows 2 items". Throws VALIDATION_FAILED when it does not,
113
+ * unless soft is set, and STEP_FAILED when Revyl could not reach a verdict.
114
+ */
115
+ async validation(text, options = {}) {
116
+ const result = await this.step('validation', text, options.timeout);
117
+ if (!result.passed && !options.soft) {
118
+ throw new VerifyError('VALIDATION_FAILED', `Validation failed: "${result.text}". ${sentence(result.reason)} See ${this.viewerUrl}`);
119
+ }
120
+ return result;
121
+ }
122
+ /** A PNG of the screen right now, also written to path when given. */
123
+ async screenshot(options = {}) {
124
+ const response = await this.worker('GET', 'screenshot', { retry: true });
125
+ if (!pngSize(response.body))
126
+ throw new VerifyError('ACTION_FAILED', 'Revyl returned a screenshot that is not a PNG image.');
127
+ if (options.path) {
128
+ const path = resolve(options.path);
129
+ await mkdir(dirname(path), { recursive: true });
130
+ await writeFile(path, response.body);
131
+ }
132
+ return response.body;
133
+ }
134
+ exec() {
135
+ return this.computerOnly('exec');
136
+ }
137
+ readFile() {
138
+ return this.computerOnly('readFile');
139
+ }
140
+ writeFile() {
141
+ return this.computerOnly('writeFile');
142
+ }
143
+ stat() {
144
+ return this.computerOnly('stat');
145
+ }
146
+ processes() {
147
+ return this.computerOnly('processes');
148
+ }
149
+ terminate() {
150
+ return this.computerOnly('terminate');
151
+ }
152
+ /** Stops the session on Revyl. Safe to call more than once. */
153
+ close() {
154
+ this.closePromise ??= (async () => {
155
+ try {
156
+ if (this.id)
157
+ await this.client.stopDeviceSession(this.id);
158
+ else
159
+ await this.client.cancelDevice(this.workflowRunId);
160
+ this.log('closed');
161
+ }
162
+ finally {
163
+ untrackSession(this);
164
+ }
165
+ })();
166
+ return this.closePromise;
167
+ }
168
+ [Symbol.asyncDispose]() {
169
+ return this.close();
170
+ }
171
+ async step(type, text, timeout) {
172
+ if (typeof text !== 'string' || text.trim() === '' || text.length > MAX_STEP_TEXT_LENGTH) {
173
+ throw new VerifyError('INVALID_ARGUMENT', `${type}() takes a description of 1 to ${MAX_STEP_TEXT_LENGTH} characters.`);
174
+ }
175
+ const timeoutMs = timeout ?? this.options.stepTimeout;
176
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000) {
177
+ throw new VerifyError('INVALID_ARGUMENT', `${type}() takes a timeout of at least 1000 ms.`);
178
+ }
179
+ if (this.stepRunning) {
180
+ throw new VerifyError('INVALID_ARGUMENT', 'Run one step at a time on a session: await each instruction or validation before starting the next.');
181
+ }
182
+ this.stepRunning = true;
183
+ const description = text.trim();
184
+ const startedAt = Date.now();
185
+ try {
186
+ const outcome = await this.runStep(type, description, timeoutMs);
187
+ const output = outcome.step_output ?? {};
188
+ const reason = output.status_reason ?? output.reasoning ?? undefined;
189
+ if (type === 'validation' && (outcome.success !== true || typeof output.validation_result !== 'boolean')) {
190
+ const why = outcome.success === true ? 'Revyl returned no verdict.' : sentence(reason);
191
+ throw new VerifyError('STEP_FAILED', `Validation did not reach a verdict: "${description}". ${why} See ${this.viewerUrl}`);
192
+ }
193
+ const passed = type === 'validation' ? output.validation_result === true : outcome.success === true && output.status !== 'failed';
194
+ const result = { type, text: description, passed, ...(reason ? { reason } : {}), durationMs: Date.now() - startedAt };
195
+ this.steps.push(result);
196
+ this.log(`${passed ? '✓' : '✗'} ${type} "${description}" (${formatDuration(result.durationMs)})${!passed && reason ? `: ${reason}` : ''}`);
197
+ return result;
198
+ }
199
+ catch (error) {
200
+ this.log(`✗ ${type} "${description}": ${isVerifyError(error) ? error.code : errorMessage(error)}`);
201
+ throw error;
202
+ }
203
+ finally {
204
+ this.stepRunning = false;
205
+ }
206
+ }
207
+ async runStep(type, description, timeoutMs) {
208
+ const signal = AbortSignal.timeout(timeoutMs);
209
+ let stepId;
210
+ try {
211
+ const response = await this.worker('POST', 'execute_step', {
212
+ body: {
213
+ step_type: type,
214
+ step_description: description,
215
+ ...(this.id ? { session_id: this.id } : {}),
216
+ workflow_run_id: this.workflowRunId,
217
+ },
218
+ signal,
219
+ timeoutMs,
220
+ });
221
+ const body = parseJson(response, 'execute_step');
222
+ if ('success' in body)
223
+ return body;
224
+ if (body.status !== 'accepted' || !body.step_id) {
225
+ throw new VerifyError('API_ERROR', 'Revyl answered execute_step without a result or an accepted step id.');
226
+ }
227
+ stepId = body.step_id;
228
+ for (let wait = STEP_POLL_BASE_MS;; wait = Math.min(wait * 2, STEP_POLL_MAX_MS)) {
229
+ await delay(wait, undefined, { signal });
230
+ const status = parseJson(await this.worker('GET', `step_status/${encodeURIComponent(stepId)}`, { signal, retry: true }), 'step_status');
231
+ if (status.status === 'running')
232
+ continue;
233
+ if (status.status === 'cancelled') {
234
+ throw new VerifyError('STEP_FAILED', `The ${type} was cancelled on Revyl before it finished: "${description}". See ${this.viewerUrl}`);
235
+ }
236
+ if (!STEP_TERMINAL_STATUSES.has(status.status) || !status.result) {
237
+ throw new VerifyError('API_ERROR', `Step ${stepId} reported status ${status.status} without a result Verify can read.`);
238
+ }
239
+ return status.result;
240
+ }
241
+ }
242
+ catch (error) {
243
+ if (!signal.aborted)
244
+ throw error;
245
+ if (stepId) {
246
+ await this.client
247
+ .worker(this.workflowRunId, `step_cancel/${encodeURIComponent(stepId)}`, {
248
+ method: 'POST',
249
+ body: {},
250
+ signal: AbortSignal.timeout(STEP_CANCEL_TIMEOUT_MS),
251
+ })
252
+ .catch(() => undefined);
253
+ }
254
+ throw new VerifyError('TIMEOUT', `The ${type} did not finish within ${timeoutMs} ms and was cancelled: "${description}". See ${this.viewerUrl}`);
255
+ }
256
+ }
257
+ async launch(bundleId) {
258
+ const response = await this.worker('POST', 'launch', { body: { bundle_id: bundleId } });
259
+ const result = parseJson(response, 'launch');
260
+ if (result.success !== true) {
261
+ throw new VerifyError('SESSION_START_FAILED', `Could not launch ${bundleId} on the ${this.target} device: ${result.error ?? 'no reason given'}`);
262
+ }
263
+ }
264
+ log(line) {
265
+ this.options.log?.(`${this.target}: ${line}`);
266
+ }
267
+ computerOnly(call) {
268
+ return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${call}() runs on computer targets (platform 'mac' or 'linux'), not on ${this.platform} sessions.`));
269
+ }
270
+ async worker(method, action, options) {
271
+ if (this.closePromise)
272
+ throw new VerifyError('SESSION_ENDED', `The ${this.target} session was closed.`);
273
+ const response = await this.client.worker(this.workflowRunId, action, { method, ...options });
274
+ if (response.status >= 200 && response.status < 300)
275
+ return response;
276
+ throw this.workerError(method, action, response);
277
+ }
278
+ workerError(method, action, response) {
279
+ const baseAction = action.split('/')[0] ?? action;
280
+ const code = responseErrorCode(response);
281
+ const detail = responseDetail(response);
282
+ const reason = detail ?? code ?? `HTTP ${response.status}`;
283
+ if (response.status === 401 || response.status === 403)
284
+ return apiError(method, `device-proxy/${baseAction}`, response);
285
+ if (response.status === 501 || code === 'not_available_on_platform' || code === 'step_output_not_available') {
286
+ if (baseAction === 'execute_step' || baseAction === 'step_status') {
287
+ return new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `The Revyl backend at ${this.client.backendUrl} does not run instruction and validation steps on ${this.platform} sessions (${code ?? 'HTTP 501'}).`);
288
+ }
289
+ return new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${baseAction} is not available on ${this.platform} sessions (${reason}).`);
290
+ }
291
+ if (response.status === 404 || response.status === 410) {
292
+ return new VerifyError('SESSION_ENDED', `The ${this.target} session is no longer reachable (${reason}). See ${this.viewerUrl}`);
293
+ }
294
+ if (response.status === 409) {
295
+ if (SESSION_NOT_RUNNING.test(reason)) {
296
+ return new VerifyError('SESSION_ENDED', `The ${this.target} session is no longer running (${reason}). See ${this.viewerUrl}`);
297
+ }
298
+ return new VerifyError('SESSION_BUSY', `The ${this.target} session is busy and refused ${baseAction}: ${reason}`);
299
+ }
300
+ if (method === 'POST' && (isExecutionUncertain(response) || (code && UNCERTAIN_ERROR_CODES.has(code)))) {
301
+ return new VerifyError('ACTION_UNCERTAIN', `${baseAction} may or may not have been applied (${reason}); Verify does not repeat it. Check the session at ${this.viewerUrl}`);
302
+ }
303
+ return new VerifyError('ACTION_FAILED', `${baseAction} failed on the ${this.target} session (HTTP ${response.status}): ${reason}`);
304
+ }
305
+ async untilReady(waitForReady) {
306
+ trackSession(this);
307
+ this.log(`starting ${this.platform} session ${this.viewerUrl}`);
308
+ try {
309
+ await waitForReady();
310
+ }
311
+ catch (error) {
312
+ await this.close().catch(() => undefined);
313
+ throw error;
314
+ }
315
+ this.log('ready');
316
+ }
317
+ async waitForWebReady(deadline) {
318
+ for (;;) {
319
+ const detail = await this.client.getDeviceSession(this.id);
320
+ if (ENDED_STATUSES.has(detail.status)) {
321
+ 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
+ }
323
+ if (detail.status === 'running' && detail.workflow_run_id) {
324
+ this.workflowRunId = detail.workflow_run_id;
325
+ const response = await this.client.worker(this.workflowRunId, 'screenshot', { method: 'GET' });
326
+ if (response.status === 200 && pngSize(response.body))
327
+ return;
328
+ if (response.status !== 503 && response.status !== 409 && responseErrorCode(response) !== 'not_ready') {
329
+ throw this.workerError('GET', 'screenshot', response);
330
+ }
331
+ }
332
+ await waitBeforeDeadline(deadline, `The ${this.target} web session was not ready in time. See ${this.viewerUrl}`);
333
+ }
334
+ }
335
+ async waitForDeviceReady(deadline) {
336
+ for (;;) {
337
+ const connection = await this.client.getWorkerConnection(this.workflowRunId);
338
+ if (connection.status === 'ready' && connection.worker_ws_url)
339
+ break;
340
+ if (connection.status === 'cancelled' || connection.status === 'stopped' || connection.status === 'failed') {
341
+ throw new VerifyError('SESSION_START_FAILED', `The ${this.target} device did not start (${connection.status}): ${connection.message ?? 'no reason given'}`);
342
+ }
343
+ await waitBeforeDeadline(deadline, `The ${this.target} device was not assigned in time.`);
344
+ }
345
+ const connectDeadline = Math.min(deadline, Date.now() + DEVICE_CONNECT_TIMEOUT_MS);
346
+ for (;;) {
347
+ const response = await this.client.worker(this.workflowRunId, 'health', { method: 'GET', retry: true });
348
+ if (response.status === 200 && parseJson(response, 'health').device_connected)
349
+ return;
350
+ if (response.status !== 200 && response.status !== 503)
351
+ throw this.workerError('GET', 'health', response);
352
+ await waitBeforeDeadline(connectDeadline, `The ${this.target} device did not connect in time.`);
353
+ }
354
+ }
355
+ }
356
+ async function resolveWebBuildId(client, target) {
357
+ const { app } = target;
358
+ if (app.buildId)
359
+ return app.buildId;
360
+ if (app.url) {
361
+ const build = await client.registerWebUrlBuild(app.appId, { url: app.url, ...(app.branch ? { branch_name: app.branch } : {}) });
362
+ if (!build.id)
363
+ throw new VerifyError('API_ERROR', `Revyl registered ${app.url} without returning a build id.`);
364
+ return build.id;
365
+ }
366
+ let branchId;
367
+ if (!app.branch) {
368
+ const detail = await client.getApp(app.appId);
369
+ if (detail.platform && detail.platform.toLowerCase() !== 'web') {
370
+ throw new VerifyError('CONFIG_INVALID', `Target ${target.name}: Revyl app ${app.appId} is a ${detail.platform} app, not a web app.`);
371
+ }
372
+ branchId = detail.default_branch_id ?? undefined;
373
+ }
374
+ const latest = await client.latestWebBuild(app.appId, { branchId, branchName: app.branch });
375
+ if (!latest?.id) {
376
+ throw new VerifyError('SESSION_START_FAILED', `Target ${target.name}: Revyl app ${app.appId} has no web build${app.branch ? ` on branch ${app.branch}` : ''}. ` +
377
+ "Set app.url to a deployed HTTPS URL, or build one with 'revyl build --platform web'.");
378
+ }
379
+ return latest.id;
380
+ }
381
+ async function waitBeforeDeadline(deadline, timeoutMessage) {
382
+ const remaining = deadline - Date.now();
383
+ if (remaining <= 0)
384
+ throw new VerifyError('TIMEOUT', timeoutMessage);
385
+ await delay(Math.min(POLL_INTERVAL_MS, remaining));
386
+ }
387
+ function isExecutionUncertain(response) {
388
+ try {
389
+ return JSON.parse(new TextDecoder().decode(response.body)).execution_uncertain === true;
390
+ }
391
+ catch {
392
+ return false;
393
+ }
394
+ }
395
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
396
+ export function pngSize(bytes) {
397
+ if (bytes.length < 24 || PNG_SIGNATURE.some((value, index) => bytes[index] !== value))
398
+ return undefined;
399
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
400
+ const width = view.getUint32(16);
401
+ const height = view.getUint32(20);
402
+ return width > 0 && height > 0 ? { width, height } : undefined;
403
+ }
404
+ function formatDuration(ms) {
405
+ return ms < 60_000 ? `${(ms / 1_000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1_000)}s`;
406
+ }
407
+ function sentence(reason) {
408
+ if (!reason)
409
+ return 'Revyl gave no reason.';
410
+ return /[.!?]$/.test(reason.trim()) ? reason.trim() : `${reason.trim()}.`;
411
+ }
@@ -0,0 +1,2 @@
1
+ export declare const VERSION: string;
2
+ export declare const PACKAGE_NAME: string;
@@ -0,0 +1,6 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ const manifestPath = fileURLToPath(new URL('../package.json', import.meta.url));
4
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
5
+ export const VERSION = manifest.version;
6
+ export const PACKAGE_NAME = manifest.name;