@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/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 parseTarget(raw, name, path, fail) {
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
@@ -1,3 +1,3 @@
1
- export { open } from './open.js';
1
+ export { attach, currentComputer, open } from './open.js';
2
2
  export { defineConfig } from './config.js';
3
3
  export { VerifyError, isVerifyError } from './errors.js';
package/dist/open.d.ts CHANGED
@@ -1,13 +1,21 @@
1
1
  import { type TargetConfig } from './config.js';
2
- import { type Session } from './session.js';
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<Session>;
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 resolveCredentials();
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 response = await this.client.worker(this.workflowRunId, action, { method, ...options });
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
- const detail = await this.client.getDeviceSession(this.id);
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
- const connection = await this.client.getWorkerConnection(this.workflowRunId);
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 and not yet available in Revyl production. They work only on Revyl staging for organizations enabled for the requested OS through the optional `REVYL_BACKEND_URL` override with matching staging credentials. Read [availability and errors](computers.md#availability-and-errors) before starting a computer; do not try production or repeat an uncertain start/stop to probe availability.
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 and staging setup. 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.
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 on staging only for organizations enabled for the requested OS, not yet available in Revyl production. Use the optional `REVYL_BACKEND_URL` override and staging credentials described in [computer availability](computers.md#availability-and-errors).
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 nothing but Node.js and an API key. Both Mac and Linux computer targets work from a Linux runner; no local guest runtime is needed. Use the same staging URL overrides and organization/OS enablement as [computers.md](computers.md).
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