@revyl/verify 0.1.0 → 0.3.0

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