@revyl/verify 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -1
- package/README.md +103 -66
- package/dist/api/types.d.ts +6 -0
- package/dist/cli/init.js +2 -3
- package/dist/cli/main.js +5 -5
- package/dist/computer-session.d.ts +8 -2
- package/dist/computer-session.js +117 -21
- package/dist/config.d.ts +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/open.d.ts +1 -1
- package/dist/open.js +2 -5
- package/dist/screen-readiness.d.ts +1 -0
- package/dist/screen-readiness.js +27 -0
- package/dist/session.d.ts +9 -0
- package/dist/session.js +32 -0
- package/docs/README.md +3 -2
- package/docs/agents.md +14 -9
- package/docs/api.md +22 -10
- package/docs/ci.md +1 -1
- package/docs/computers.md +22 -12
- package/docs/configuration.md +11 -10
- package/docs/end-to-end.md +105 -0
- package/docs/errors.md +6 -5
- package/docs/getting-started.md +21 -12
- package/docs/linux-computers-contract.md +34 -88
- package/docs/llms.txt +5 -4
- package/docs/mobile.md +6 -0
- package/docs/web.md +1 -7
- package/package.json +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Add a standard-library-only Go SDK for web, iOS, Android, and early-access Mac and Linux computers. Computer commands, binary files, process handles, screenshots, recording metadata, and confirmed-release cleanup require an enabled staging organization and are not yet available in production. Capacity refusals have a distinct `COMPUTER_LIMIT` code; no start or operation is automatically replayed.
|
|
6
|
+
|
|
7
|
+
## 0.2.0
|
|
8
|
+
|
|
9
|
+
Prepared for release; see npm for publication status.
|
|
10
|
+
|
|
11
|
+
- Enable Linux named and inline computer targets with the shared Mac command, file, process, screenshot, and confirmed-release cleanup surface. Both operating systems remain early access on staging for enabled organizations/OSes, not yet available in production.
|
|
12
|
+
- Add `COMPUTER_LIMIT` for queued work (including iOS) or concurrency admission refusals, without retrying or stopping a definitely refused start. Preserve `SESSION_BUSY` for operation limits.
|
|
13
|
+
- Explain the 120-second unclaimed-start failure without shortening the default 10-minute start timeout.
|
|
14
|
+
- Expose optional `expiresAt` and bound operation/screenshot observation by the hard 60-minute lifetime from create, while keeping explicit close possible.
|
|
15
|
+
- Add computer `recording()` metadata retrieval after terminal state and confirmed release, including after close. Missing recordings return null; signed URLs remain private, with no automatic waiting or download.
|
|
16
|
+
- Keep Linux CLI init intentionally unavailable; use inline targets or config entries. Computer instruction/validation remain locally unavailable.
|
|
17
|
+
- Observe mobile first-screen content best-effort for up to 10 seconds after connection/launch, without pretending the runtime provides rendered-readiness guarantees. Generated mobile examples begin with an instruction to wait for the first screen before validation.
|
|
18
|
+
- Retry structured computer screen-not-ready responses with bounded backoff during the fixed startup window; do not replay mutations or unrelated failures.
|
|
19
|
+
- Skip CI publication when the exact version is already on npm, require explicit administrator opt-in for new CI publications, fail closed on registry lookup errors, and document manual Mac publication, optional trusted publishing, and independent Go module tags.
|
|
20
|
+
|
|
21
|
+
Regression validation uses fake API responses. The [end-to-end record](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/end-to-end.md) distinguishes baseline live runs from startup fixes still awaiting live re-verification.
|
|
22
|
+
|
|
3
23
|
## 0.1.0
|
|
4
24
|
|
|
5
|
-
|
|
25
|
+
Published manually from a maintainer's Mac without provenance.
|
|
6
26
|
|
|
7
27
|
- Open Revyl cloud web browsers, iOS simulators, and Android emulators from TypeScript.
|
|
8
28
|
- Run natural-language instruction and validation steps, with typed failures and optional soft validation results.
|
package/README.md
CHANGED
|
@@ -1,101 +1,138 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">Verify</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<em>Cloud browsers, mobile devices and computers from TypeScript and Go.</em>
|
|
5
|
+
</p>
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://github.com/RevylAI/verify/blob/v0.2.0/CHANGELOG.md"><img src="https://img.shields.io/badge/version-0.2.0-9D61FF" alt="Source version" /></a>
|
|
9
|
+
<a href="https://github.com/RevylAI/verify/blob/v0.2.0/LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License: Apache 2.0" /></a>
|
|
10
|
+
<a href="https://www.npmjs.com/package/@revyl/verify"><img src="https://img.shields.io/npm/v/@revyl/verify?color=9D61FF" alt="npm" /></a>
|
|
11
|
+
<a href="https://pkg.go.dev/github.com/RevylAI/verify/go"><img src="https://img.shields.io/badge/Go-reference-00ADD8?logo=go&logoColor=white" alt="Go reference" /></a>
|
|
12
|
+
<a href="https://github.com/RevylAI/verify/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/RevylAI/verify/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
|
13
|
+
</p>
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
Verify is a TypeScript and Go library for [Revyl](https://revyl.com). Open a cloud browser, iOS simulator or Android emulator for natural-language instructions and validations, or an early-access Mac/Linux computer for commands, files, processes and screenshots. Call it from a script, coding agent or your existing test runner.
|
|
18
|
+
|
|
19
|
+
## For coding agents
|
|
20
|
+
|
|
21
|
+
Run these from the project's directory with Node.js ≥22.18. The authentication command requires the [Revyl CLI](https://github.com/RevylAI/revyl-cli#install); if `REVYL_API_KEY` is already supplied securely, skip login.
|
|
13
22
|
|
|
14
23
|
```bash
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
npm install --save-dev @revyl/verify
|
|
25
|
+
npx @revyl/verify init --platform ios
|
|
26
|
+
revyl auth login
|
|
27
|
+
npx @revyl/verify whoami
|
|
17
28
|
```
|
|
18
29
|
|
|
19
|
-
|
|
30
|
+
If login prints an approval URL, share it privately with the user so they can approve it in their browser. For unattended runs, supply `REVYL_API_KEY` through a secret manager; never put credentials in argv, source or logs. Configure the generated `verify.config.ts` before running `node verify/example.ts`. Starting a cloud session requires an authorized account and workload.
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
- **A coding agent.** An agent can check its own change on a real device in five lines, then read a one-line outcome per step and a link to the recording.
|
|
23
|
-
- **CI.** Run the script in a job. The browsers and devices run in Revyl's cloud, so the runner needs nothing but Node and an API key.
|
|
24
|
-
- **Your test runner.** Call it inside node:test, Vitest, Jest, or Playwright Test, next to the tests you already have.
|
|
32
|
+
## Install
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
### npm
|
|
27
35
|
|
|
28
|
-
|
|
36
|
+
Use Node.js ≥22.18:
|
|
29
37
|
|
|
30
38
|
```bash
|
|
31
|
-
npx @revyl/verify init
|
|
32
39
|
npm install --save-dev @revyl/verify
|
|
33
|
-
export REVYL_API_KEY=... # or sign in with the Revyl CLI: revyl auth login
|
|
34
|
-
node verify/example.ts
|
|
35
40
|
```
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
### Go
|
|
43
|
+
|
|
44
|
+
Use Go ≥1.26.9 in an existing Go module:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
go get github.com/RevylAI/verify/go@main
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
There is no Go release tag yet. `@main` resolves to a pseudo-version; keep the generated `go.mod` and `go.sum` to pin it. Go tags are independent of npm versions. The SDK uses only the standard library.
|
|
51
|
+
|
|
52
|
+
## Authenticate
|
|
53
|
+
|
|
54
|
+
Both SDKs use this credential order:
|
|
38
55
|
|
|
39
|
-
|
|
56
|
+
1. A usable stored CLI login marked `local_auth_override`.
|
|
57
|
+
2. `REVYL_API_KEY` from the environment.
|
|
58
|
+
3. Another usable CLI login stored by `revyl auth login`.
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
| --- | --- |
|
|
43
|
-
| `open(target)` | Starts a Revyl cloud browser, device, or Mac computer, from a target name in `verify.config.ts` or a target object, and waits until it is ready |
|
|
44
|
-
| `session.exec(argv, options?)` | Runs structured argv on a Mac computer; returns `{ exitCode, stdout, stderr }` |
|
|
45
|
-
| `session.readFile(path)`, `writeFile(path, data)`, `stat(path)` | Reads a `Buffer`, writes a string or bytes, or returns `{ kind, size }` on a computer |
|
|
46
|
-
| `session.processes()`, `terminate(id)` | Lists run-owned process handles or terminates one on a computer |
|
|
47
|
-
| `session.instruction(text)` | Revyl's agent does what the text says. Throws `INSTRUCTION_FAILED` if it cannot. |
|
|
48
|
-
| `session.validation(text)` | Revyl's agent checks that the text holds on screen. Throws `VALIDATION_FAILED` if it does not, or returns the result with `{ soft: true }`. |
|
|
49
|
-
| `session.screenshot({ path })` | A PNG of the screen |
|
|
50
|
-
| `session.viewerUrl` | The session in Revyl: live while it runs, its recording after |
|
|
51
|
-
| `session.close()` | Stops the session. Verify also closes open sessions on Ctrl+C, on an uncaught error, and when a script ends without closing. |
|
|
60
|
+
An expired access token can fall back to its stored API key. Set environment keys through a secret manager, or sign in interactively:
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
```bash
|
|
63
|
+
revyl auth login
|
|
64
|
+
```
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
See [configuration](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/configuration.md) for credential and endpoint options.
|
|
56
67
|
|
|
57
|
-
|
|
58
|
-
| --- | --- | --- | --- | --- | --- |
|
|
59
|
-
| Runs on | Revyl cloud browser | Revyl iOS simulator | Revyl Android emulator | Revyl macOS computer | Contract only; open unavailable |
|
|
60
|
-
| Opens | A public URL, or a web build in Revyl | A build in Revyl, a build URL, or an installed app | A build in Revyl, a build URL, or an installed app | A workspace, no app id | Nothing in this release |
|
|
61
|
-
| `instruction`, `validation` | Yes | Yes | Yes | Unavailable | Unavailable |
|
|
62
|
-
| Commands, files, processes | Unavailable | Unavailable | Unavailable | Early access on staging only for enabled organizations; not yet available in Revyl production | Contract only |
|
|
63
|
-
| `screenshot`, `viewerUrl` | Yes | Yes | Yes | Yes | Unconfirmed |
|
|
68
|
+
## Quick Start
|
|
64
69
|
|
|
65
|
-
|
|
70
|
+
### TypeScript
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
Run `npx @revyl/verify init --platform ios`. For this Settings example, replace the generated `app` block with `app: { bundleId: 'com.apple.Preferences' }`. Save the following as `verify/example.ts` and run `node verify/example.ts`. For your own uploaded app, set `app.appId` and adapt the steps.
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
```ts
|
|
75
|
+
import { open } from '@revyl/verify';
|
|
76
|
+
const session = await open('ios');
|
|
77
|
+
try {
|
|
78
|
+
await session.instruction('Open General, then About');
|
|
79
|
+
await session.validation('The screen shows the operating system version');
|
|
80
|
+
} finally {
|
|
81
|
+
await session.close();
|
|
82
|
+
}
|
|
83
|
+
```
|
|
70
84
|
|
|
71
|
-
|
|
85
|
+
For web, use `init --platform web` with a Revyl web app ID and public HTTPS URL. See [getting started](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/getting-started.md).
|
|
86
|
+
|
|
87
|
+
### Go
|
|
88
|
+
|
|
89
|
+
Inside a function returning `error`, with a bounded `ctx` and imports for `context`, `time`, and `verify "github.com/RevylAI/verify/go"`:
|
|
90
|
+
|
|
91
|
+
```go
|
|
92
|
+
session, err := verify.Open(ctx, verify.Target{Platform: verify.IOS, BundleID: "com.apple.Preferences"})
|
|
93
|
+
if err != nil {
|
|
94
|
+
return err
|
|
95
|
+
}
|
|
96
|
+
defer func() {
|
|
97
|
+
cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
98
|
+
defer cancel()
|
|
99
|
+
_ = session.Close(cleanup)
|
|
100
|
+
}()
|
|
101
|
+
if _, err := session.Instruction(ctx, "Open General, then About"); err != nil {
|
|
102
|
+
return err
|
|
103
|
+
}
|
|
104
|
+
_, err = session.Validation(ctx, "The screen shows the operating system version")
|
|
105
|
+
return err
|
|
106
|
+
```
|
|
72
107
|
|
|
73
|
-
|
|
74
|
-
- [Configuration and CLI](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/configuration.md)
|
|
75
|
-
- [Web](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/web.md) and [iOS and Android](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/mobile.md)
|
|
76
|
-
- [Mac computers](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md) and the [deferred Linux contract](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/linux-computers-contract.md)
|
|
77
|
-
- [CI and test runners](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/ci.md) and [error codes](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/errors.md)
|
|
78
|
-
- [For coding agents](https://github.com/RevylAI/verify/blob/main/packages/verify/docs/agents.md)
|
|
108
|
+
This sketch ignores cleanup errors; the [complete Go examples](https://github.com/RevylAI/verify/blob/v0.2.0/go/README.md) handle them. Go has no process-exit handlers. TypeScript's automatic cleanup is best effort, bounded to 15 seconds, and skips signal/error handlers that already have listeners. Always close explicitly: device close acknowledges a stop request; computer close requires terminal state and confirmed release.
|
|
79
109
|
|
|
80
|
-
|
|
110
|
+
### Platforms
|
|
81
111
|
|
|
82
|
-
|
|
112
|
+
| Platform | Instructions / validations | Commands / files / processes | PNG screenshot | Availability |
|
|
113
|
+
| --- | :---: | :---: | :---: | --- |
|
|
114
|
+
| Web | Yes | — | Yes | Production; selected backend must support web steps |
|
|
115
|
+
| iOS simulator | Yes | — | Yes | Production |
|
|
116
|
+
| Android emulator | Yes | — | Yes | Production |
|
|
117
|
+
| Mac computer | — | Yes | Yes | Staging-only early access; not in production |
|
|
118
|
+
| Linux computer | — | Yes | Yes | Staging-only early access; not in production |
|
|
83
119
|
|
|
84
|
-
|
|
120
|
+
Computers require enablement for your organization and requested OS, plus approved `REVYL_BACKEND_URL`, matching `REVYL_APP_URL` and credentials. They have a hard 60-minute lifetime from create. Mac supports `init --platform mac`; Linux init is unavailable, so use `open({ platform: 'linux' })` or a config target. See [computers](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/computers.md).
|
|
85
121
|
|
|
86
|
-
|
|
122
|
+
## Documentation
|
|
87
123
|
|
|
88
|
-
|
|
124
|
+
- [Documentation index](https://github.com/RevylAI/verify/tree/v0.2.0/packages/verify/docs/) · [API](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/api.md) · [CI](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/ci.md) · [Coding agents](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/agents.md)
|
|
125
|
+
- [End-to-end results and limitations](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/end-to-end.md)
|
|
126
|
+
- [Examples](https://github.com/RevylAI/verify/tree/v0.2.0/examples/): [Web store](https://github.com/RevylAI/verify/tree/v0.2.0/examples/web-store/) · [Mobile Settings](https://github.com/RevylAI/verify/tree/v0.2.0/examples/mobile-settings/) · [Mac workspace](https://github.com/RevylAI/verify/tree/v0.2.0/examples/mac-workspace/) · [Go](https://github.com/RevylAI/verify/blob/v0.2.0/go/README.md)
|
|
127
|
+
- [Contributing](https://github.com/RevylAI/verify/blob/v0.2.0/CONTRIBUTING.md) · [Security](https://github.com/RevylAI/verify/blob/v0.2.0/SECURITY.md) · [Releasing](https://github.com/RevylAI/verify/blob/v0.2.0/RELEASING.md)
|
|
89
128
|
|
|
90
|
-
|
|
91
|
-
npm install
|
|
92
|
-
npm run build
|
|
93
|
-
npm test # unit tests against a mocked Revyl API; no account needed
|
|
94
|
-
npm run typecheck
|
|
95
|
-
```
|
|
129
|
+
## Troubleshooting
|
|
96
130
|
|
|
97
|
-
|
|
131
|
+
- **`NOT_AVAILABLE_ON_PLATFORM`:** computer agent steps and device command/file methods are unavailable. Computers also need staging enablement; web steps need backend support. Check the [platform contract](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/errors.md).
|
|
132
|
+
- **`COMPUTER_LIMIT`:** admission was refused because of queued work or a concurrency limit. Wait for capacity; Verify does not retry creation or stop a definitely refused start.
|
|
133
|
+
- **Blank first screen:** mobile startup makes a bounded, best-effort blank-frame observation, not a rendered/foreground-readiness guarantee. Begin with an instruction that reaches the screen you need. Computer capture retries structured startup screen-not-ready responses for a fixed 30-second window; Linux screenshot retry is tests-only, not proven live.
|
|
134
|
+
- **A report's counters look successful but validation failed:** inspect `effective_status`, `validation_result` and `validations_passed` against `total_validations`, not transport counts. Failed SDK validations throw by default. See [reading the verdict](https://github.com/RevylAI/verify/blob/v0.2.0/packages/verify/docs/end-to-end.md#reading-the-verdict).
|
|
98
135
|
|
|
99
136
|
## License
|
|
100
137
|
|
|
101
|
-
[Apache 2.0](https://github.com/RevylAI/verify/blob/
|
|
138
|
+
[Apache 2.0](https://github.com/RevylAI/verify/blob/v0.2.0/LICENSE).
|
package/dist/api/types.d.ts
CHANGED
|
@@ -130,6 +130,12 @@ export interface ComputerReport {
|
|
|
130
130
|
state: ComputerState;
|
|
131
131
|
release: 'pending' | 'confirmed' | 'unknown';
|
|
132
132
|
failure_reason?: string | null;
|
|
133
|
+
expires_at?: string | null;
|
|
134
|
+
}
|
|
135
|
+
export interface ComputerRecordingResponse {
|
|
136
|
+
url: string;
|
|
137
|
+
duration_seconds?: number | null;
|
|
138
|
+
started_at?: string | null;
|
|
133
139
|
}
|
|
134
140
|
export interface ComputerProcessHandle {
|
|
135
141
|
computer_session_id: string;
|
package/dist/cli/init.js
CHANGED
|
@@ -8,7 +8,7 @@ const CONFIG_FILE = 'verify.config.ts';
|
|
|
8
8
|
const EXAMPLE_SCRIPT = join('verify', 'example.ts');
|
|
9
9
|
export function validateInitAnswers(answers) {
|
|
10
10
|
if (!['web', 'ios', 'android', 'mac'].includes(answers.platform)) {
|
|
11
|
-
throw new VerifyError('INVALID_ARGUMENT', "--platform must be 'web', 'ios', 'android', or 'mac'. Linux
|
|
11
|
+
throw new VerifyError('INVALID_ARGUMENT', "--platform must be 'web', 'ios', 'android', or 'mac'. Linux init is unavailable; use open({ platform: 'linux' }) or a Linux config target.");
|
|
12
12
|
}
|
|
13
13
|
if (answers.platform === 'mac' && answers.appId !== undefined) {
|
|
14
14
|
throw new VerifyError('INVALID_ARGUMENT', '--app-id does not apply to Mac computers.');
|
|
@@ -101,8 +101,7 @@ try {
|
|
|
101
101
|
const steps = platform === 'web'
|
|
102
102
|
? `await session.instruction('Follow the link on the page');
|
|
103
103
|
await session.validation('A page about domains reserved for documentation is shown');`
|
|
104
|
-
:
|
|
105
|
-
// await session.instruction('Sign in as the demo user and open Settings');
|
|
104
|
+
: `await session.instruction('Wait for the app to finish opening and show its first screen');
|
|
106
105
|
await session.validation('The app has opened and shows its first screen without an error');`;
|
|
107
106
|
return `import { open } from '${PACKAGE_NAME}';
|
|
108
107
|
|
package/dist/cli/main.js
CHANGED
|
@@ -6,18 +6,18 @@ import { missingCredentialsMessage, resolveCredentials } from '../auth.js';
|
|
|
6
6
|
import { VerifyError, errorMessage, isVerifyError } from '../errors.js';
|
|
7
7
|
import { PACKAGE_NAME, VERSION } from '../version.js';
|
|
8
8
|
import { initProject, validateInitAnswers } from './init.js';
|
|
9
|
-
const HELP = `verify ${VERSION}: open Revyl cloud browsers, devices, and
|
|
9
|
+
const HELP = `verify ${VERSION}: open Revyl cloud browsers, devices, and computers from your code
|
|
10
10
|
|
|
11
11
|
Usage
|
|
12
|
-
|
|
12
|
+
npx ${PACKAGE_NAME} init [--platform web|ios|android|mac] [--app-id <id>] [--url <https-url>] [--force]
|
|
13
13
|
Write verify.config.ts and an example script, verify/example.ts
|
|
14
|
-
|
|
14
|
+
npx ${PACKAGE_NAME} whoami
|
|
15
15
|
Check which Revyl account your credentials belong to
|
|
16
16
|
|
|
17
17
|
Authentication
|
|
18
18
|
REVYL_API_KEY, or a login from the Revyl CLI ('revyl auth login').
|
|
19
19
|
|
|
20
|
-
Docs: https://github.com/RevylAI/verify/tree/
|
|
20
|
+
Docs: https://github.com/RevylAI/verify/tree/v${VERSION}/packages/verify/docs
|
|
21
21
|
`;
|
|
22
22
|
const USAGE_CODES = new Set(['INVALID_ARGUMENT', 'AUTH_MISSING', 'AUTH_REJECTED']);
|
|
23
23
|
export async function main(argv) {
|
|
@@ -70,7 +70,7 @@ async function initCommand(args) {
|
|
|
70
70
|
const steps = [
|
|
71
71
|
`npm install --save-dev ${PACKAGE_NAME}`,
|
|
72
72
|
"Authenticate: export REVYL_API_KEY=<key from Revyl settings>, or run 'revyl auth login'",
|
|
73
|
-
...(answers.platform === 'mac' ? [
|
|
73
|
+
...(answers.platform === 'mac' ? [`Mac computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations; use the optional REVYL_BACKEND_URL override with staging credentials. Ask support@revyl.ai for access. See https://github.com/RevylAI/verify/blob/v${VERSION}/packages/verify/docs/computers.md#availability-and-errors`] : []),
|
|
74
74
|
...(answers.platform === 'mac' || answers.appId ? [] : ['Put your Revyl app id in verify.config.ts (or set REVYL_APP_ID)']),
|
|
75
75
|
'node verify/example.ts',
|
|
76
76
|
];
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { RevylClient } from './api/client.js';
|
|
2
|
+
import type { ComputerPlatform } from './api/types.js';
|
|
2
3
|
import type { ResolvedComputerTarget } from './config.js';
|
|
3
|
-
import { type ComputerProcess, type ExecOptions, type ExecResult, type FileStat, type OperationOptions, type Session, type SessionOptions, type StepResult } from './session.js';
|
|
4
|
+
import { type ComputerProcess, type ComputerRecording, type ExecOptions, type ExecResult, type FileStat, type OperationOptions, type Session, type SessionOptions, type StepResult } from './session.js';
|
|
4
5
|
export declare class ComputerSession implements Session {
|
|
5
6
|
private readonly client;
|
|
6
7
|
private readonly options;
|
|
7
|
-
readonly platform:
|
|
8
|
+
readonly platform: ComputerPlatform;
|
|
8
9
|
readonly target: string;
|
|
9
10
|
readonly id: `${string}-${string}-${string}-${string}-${string}`;
|
|
10
11
|
readonly viewerUrl: string;
|
|
11
12
|
readonly steps: StepResult[];
|
|
12
13
|
private readonly lifetime;
|
|
13
14
|
private closePromise;
|
|
15
|
+
private expiresAtMs;
|
|
16
|
+
private screenReadyUntilMs;
|
|
14
17
|
private constructor();
|
|
15
18
|
static start(client: RevylClient, target: ResolvedComputerTarget, options: SessionOptions): Promise<ComputerSession>;
|
|
16
19
|
get closed(): boolean;
|
|
20
|
+
get expiresAt(): Date | undefined;
|
|
21
|
+
recording(): Promise<ComputerRecording | null>;
|
|
17
22
|
instruction(): Promise<never>;
|
|
18
23
|
validation(): Promise<never>;
|
|
19
24
|
screenshot(options?: {
|
|
@@ -31,6 +36,7 @@ export declare class ComputerSession implements Session {
|
|
|
31
36
|
private request;
|
|
32
37
|
private report;
|
|
33
38
|
private assertOpen;
|
|
39
|
+
private observationSignal;
|
|
34
40
|
private unavailable;
|
|
35
41
|
private log;
|
|
36
42
|
}
|
package/dist/computer-session.js
CHANGED
|
@@ -2,54 +2,62 @@ import { createHash, 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 { responseDetail, sessionViewerUrl } from './api/client.js';
|
|
5
|
+
import { responseDetail, responseErrorCode, sessionViewerUrl } from './api/client.js';
|
|
6
6
|
import { trackSession, untrackSession } from './cleanup.js';
|
|
7
7
|
import { VerifyError, isVerifyError } from './errors.js';
|
|
8
8
|
import { pngSize } from './session.js';
|
|
9
9
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
10
|
const STATES = new Set(['starting', 'active', 'closing', 'stopped', 'failed', 'owner_lost']);
|
|
11
11
|
const MAX_OUTPUT_BYTES = 1_048_576;
|
|
12
|
-
const
|
|
12
|
+
const COMPUTER_AVAILABILITY = 'Computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations and operating systems. See https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md#availability-and-errors';
|
|
13
|
+
const COMPUTER_CAPACITY = 'Computer admission was refused because of queued work (including iOS) or the concurrency limit. Verify will not retry the start.';
|
|
13
14
|
const ERROR_CODES = new Set(['computer_not_enabled', 'run_closed', 'inaccessible', 'invalid_handle', 'operation_not_found', 'owner_unavailable', 'invalid_request', 'unauthenticated', 'forbidden', 'runtime_unavailable', 'start_failed', 'computer_limit', 'request_conflict', 'run_conflict', 'evidence_conflict', 'evidence_unavailable', 'authentication_unavailable', 'operation_limit', 'screen_starting', 'screen_unavailable']);
|
|
14
15
|
export class ComputerSession {
|
|
15
16
|
client;
|
|
16
17
|
options;
|
|
17
|
-
platform
|
|
18
|
+
platform;
|
|
18
19
|
target;
|
|
19
20
|
id = randomUUID();
|
|
20
21
|
viewerUrl;
|
|
21
22
|
steps = [];
|
|
22
23
|
lifetime = new AbortController();
|
|
23
24
|
closePromise;
|
|
25
|
+
expiresAtMs;
|
|
26
|
+
screenReadyUntilMs = 0;
|
|
24
27
|
constructor(client, target, options) {
|
|
25
28
|
this.client = client;
|
|
26
29
|
this.options = options;
|
|
27
30
|
this.target = target.name;
|
|
31
|
+
this.platform = target.platform;
|
|
28
32
|
this.viewerUrl = sessionViewerUrl(this.id, 'computer');
|
|
29
33
|
}
|
|
30
34
|
static async start(client, target, options) {
|
|
31
|
-
if (target.platform !== 'mac')
|
|
32
|
-
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'Linux computers are planned, but are not available in Verify yet.');
|
|
33
35
|
const session = new ComputerSession(client, target, options);
|
|
34
36
|
const signal = AbortSignal.any([AbortSignal.timeout(target.startTimeout), session.lifetime.signal]);
|
|
37
|
+
let createAcknowledged = false;
|
|
35
38
|
trackSession(session);
|
|
36
39
|
try {
|
|
37
|
-
session.log(`starting
|
|
38
|
-
|
|
40
|
+
session.log(`starting computer ${session.viewerUrl}. ${COMPUTER_AVAILABILITY}`);
|
|
41
|
+
const response = await session.request('POST', '', { computer_session_id: session.id, os: target.platform === 'mac' ? 'macos' : 'linux' }, signal);
|
|
42
|
+
createAcknowledged = true;
|
|
43
|
+
let report = session.report(response);
|
|
39
44
|
for (;;) {
|
|
40
45
|
if (report.state === 'active')
|
|
41
46
|
break;
|
|
42
|
-
if (report.state !== 'starting')
|
|
43
|
-
|
|
47
|
+
if (report.state !== 'starting') {
|
|
48
|
+
const reason = report.failure_reason === 'no_host' ? ' No host claimed the start within the 120-second unclaimed-start window.' : report.failure_reason === 'device_limit' ? ` ${COMPUTER_CAPACITY}` : '';
|
|
49
|
+
throw new VerifyError('SESSION_START_FAILED', `The ${session.target} computer ended before it was ready (${report.state}).${reason} See ${session.viewerUrl}`);
|
|
50
|
+
}
|
|
44
51
|
await delay(2_000, undefined, { signal });
|
|
45
52
|
report = session.report(await session.request('GET', '/report', undefined, signal));
|
|
46
53
|
}
|
|
54
|
+
session.screenReadyUntilMs = Date.now() + 30_000;
|
|
47
55
|
session.log('ready');
|
|
48
56
|
return session;
|
|
49
57
|
}
|
|
50
58
|
catch (error) {
|
|
51
59
|
const timedOut = signal.aborted;
|
|
52
|
-
if (isVerifyError(error) && ['AUTH_REJECTED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT'].includes(error.code)) {
|
|
60
|
+
if (!createAcknowledged && isVerifyError(error) && ['AUTH_REJECTED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT', 'COMPUTER_LIMIT'].includes(error.code)) {
|
|
53
61
|
session.lifetime.abort();
|
|
54
62
|
untrackSession(session);
|
|
55
63
|
}
|
|
@@ -57,13 +65,40 @@ export class ComputerSession {
|
|
|
57
65
|
await session.close().catch(() => undefined);
|
|
58
66
|
}
|
|
59
67
|
if (timedOut)
|
|
60
|
-
throw new VerifyError('TIMEOUT', `The ${session.target} computer was not ready within ${target.startTimeout} ms. A possible cause is that no
|
|
68
|
+
throw new VerifyError('TIMEOUT', `The ${session.target} computer was not ready within ${target.startTimeout} ms. A possible cause is that no computer could be assigned. ${COMPUTER_AVAILABILITY}. Inspect ${session.viewerUrl}`);
|
|
61
69
|
throw error;
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
72
|
get closed() {
|
|
65
73
|
return this.closePromise !== undefined;
|
|
66
74
|
}
|
|
75
|
+
get expiresAt() {
|
|
76
|
+
return this.expiresAtMs === undefined ? undefined : new Date(this.expiresAtMs);
|
|
77
|
+
}
|
|
78
|
+
async recording() {
|
|
79
|
+
const signal = AbortSignal.timeout(30_000);
|
|
80
|
+
const report = this.report(await this.request('GET', '/report', undefined, signal));
|
|
81
|
+
if (!['stopped', 'failed', 'owner_lost'].includes(report.state) || report.release !== 'confirmed') {
|
|
82
|
+
throw new VerifyError('SESSION_BUSY', `The computer recording can be requested only after terminal state and confirmed release. See ${this.viewerUrl}`);
|
|
83
|
+
}
|
|
84
|
+
const response = await this.request('GET', '/recording', undefined, signal);
|
|
85
|
+
if (response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
|
|
86
|
+
return null;
|
|
87
|
+
const raw = parseComputerJson(response);
|
|
88
|
+
if (!raw || typeof raw.url !== 'string' || !/^https:\/\//i.test(raw.url) || /[\x00-\x20\x7f]/.test(raw.url))
|
|
89
|
+
throw invalidResponse();
|
|
90
|
+
let url;
|
|
91
|
+
try {
|
|
92
|
+
url = new URL(raw.url);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw invalidResponse();
|
|
96
|
+
}
|
|
97
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.hash || (raw.duration_seconds != null && (typeof raw.duration_seconds !== 'number' || !Number.isFinite(raw.duration_seconds) || raw.duration_seconds < 0)))
|
|
98
|
+
throw invalidResponse();
|
|
99
|
+
const startedAt = raw.started_at == null ? undefined : parseTimestamp(raw.started_at);
|
|
100
|
+
return { url: raw.url, ...(raw.duration_seconds != null ? { durationSeconds: raw.duration_seconds } : {}), ...(startedAt ? { startedAt } : {}) };
|
|
101
|
+
}
|
|
67
102
|
instruction() {
|
|
68
103
|
return this.unavailable('instruction');
|
|
69
104
|
}
|
|
@@ -72,7 +107,38 @@ export class ComputerSession {
|
|
|
72
107
|
}
|
|
73
108
|
async screenshot(options = {}) {
|
|
74
109
|
this.assertOpen();
|
|
75
|
-
const
|
|
110
|
+
const signal = this.observationSignal(30_000);
|
|
111
|
+
let retrySignal = signal;
|
|
112
|
+
let response;
|
|
113
|
+
let transient = false;
|
|
114
|
+
let backoffMs = 500;
|
|
115
|
+
const notReady = () => new VerifyError('SESSION_BUSY', `The computer desktop is not ready for screenshots after its 30-second startup window. Try again later. See ${this.viewerUrl}`);
|
|
116
|
+
try {
|
|
117
|
+
for (;;) {
|
|
118
|
+
this.assertOpen();
|
|
119
|
+
response = await this.request('GET', '/screen/screenshot', undefined, retrySignal);
|
|
120
|
+
if (response.status >= 200 && response.status < 300)
|
|
121
|
+
break;
|
|
122
|
+
const remainingMs = this.screenReadyUntilMs - Date.now();
|
|
123
|
+
if (remainingMs <= 0)
|
|
124
|
+
throw notReady();
|
|
125
|
+
if (!transient)
|
|
126
|
+
retrySignal = AbortSignal.any([signal, AbortSignal.timeout(remainingMs)]);
|
|
127
|
+
transient = true;
|
|
128
|
+
await delay(Math.min(backoffMs, remainingMs), undefined, { signal: retrySignal });
|
|
129
|
+
if (Date.now() >= this.screenReadyUntilMs)
|
|
130
|
+
throw notReady();
|
|
131
|
+
backoffMs = Math.min(backoffMs * 2, 2_000);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (transient && !this.lifetime.signal.aborted && (this.expiresAtMs === undefined || Date.now() < this.expiresAtMs) && retrySignal.aborted && Date.now() >= this.screenReadyUntilMs)
|
|
136
|
+
throw notReady();
|
|
137
|
+
if (retrySignal.aborted)
|
|
138
|
+
throw new VerifyError('TIMEOUT', `Computer screenshot observation was cancelled or exceeded its deadline. See ${this.viewerUrl}`);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
this.assertOpen();
|
|
76
142
|
const raw = parseComputerJson(response);
|
|
77
143
|
const bytes = decodeBytes(raw?.png, 128 * MAX_OUTPUT_BYTES);
|
|
78
144
|
const size = pngSize(bytes);
|
|
@@ -152,7 +218,7 @@ export class ComputerSession {
|
|
|
152
218
|
}
|
|
153
219
|
catch (error) {
|
|
154
220
|
if (isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
|
|
155
|
-
const message = `${error.message} A possible cause is that no
|
|
221
|
+
const message = `${error.message} A possible cause is that no computer could be assigned. ${COMPUTER_AVAILABILITY}`;
|
|
156
222
|
this.log(`close failed (ACTION_UNCERTAIN); ${message}`);
|
|
157
223
|
throw new VerifyError('ACTION_UNCERTAIN', message, { cause: error });
|
|
158
224
|
}
|
|
@@ -172,10 +238,12 @@ export class ComputerSession {
|
|
|
172
238
|
this.assertOpen();
|
|
173
239
|
const requestId = randomUUID();
|
|
174
240
|
const operationId = computerOperationId(this.id, requestId);
|
|
175
|
-
const signal =
|
|
241
|
+
const signal = this.observationSignal(310_000);
|
|
176
242
|
try {
|
|
177
243
|
let response = await this.request('POST', '/operations', { computer_session_id: this.id, request_id: requestId, command }, signal);
|
|
178
244
|
for (;;) {
|
|
245
|
+
if (signal.aborted)
|
|
246
|
+
throw signal.reason;
|
|
179
247
|
if (response.status !== 202) {
|
|
180
248
|
const body = parseComputerJson(response);
|
|
181
249
|
if (body?.computer_session_id !== this.id || body.operation_id !== operationId || !['recorded', 'pending', 'failed'].includes(body.evidence) || !body.execution)
|
|
@@ -224,7 +292,7 @@ export class ComputerSession {
|
|
|
224
292
|
const path = `/api/v1/computer-runs${suffix === '' ? '' : `/${this.id}${suffix}`}`;
|
|
225
293
|
let response;
|
|
226
294
|
try {
|
|
227
|
-
response = await this.client.send(method, path, { body, signal, timeoutMs: suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000, retry: method === 'GET', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
|
|
295
|
+
response = await this.client.send(method, path, { body, signal, timeoutMs: suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000, retry: method === 'GET' && suffix !== '/screen/screenshot', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
|
|
228
296
|
}
|
|
229
297
|
catch (error) {
|
|
230
298
|
if (method === 'POST' || suffix.startsWith('/operations/'))
|
|
@@ -235,10 +303,24 @@ export class ComputerSession {
|
|
|
235
303
|
}
|
|
236
304
|
if (response.status >= 200 && response.status < 300)
|
|
237
305
|
return response;
|
|
306
|
+
if (suffix === '/recording' && response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
|
|
307
|
+
return response;
|
|
238
308
|
const detail = responseDetail(response);
|
|
239
|
-
if (
|
|
240
|
-
|
|
309
|
+
if (suffix === '/screen/screenshot') {
|
|
310
|
+
let raw = null;
|
|
311
|
+
try {
|
|
312
|
+
raw = JSON.parse(new TextDecoder().decode(response.body));
|
|
313
|
+
}
|
|
314
|
+
catch { }
|
|
315
|
+
const code = raw?.detail ?? raw?.message ?? raw?.error;
|
|
316
|
+
if ((code === 'screen_unavailable' && (response.status === 409 || response.status === 503)) || (code === 'screen_starting' && response.status === 409))
|
|
317
|
+
return response;
|
|
241
318
|
}
|
|
319
|
+
if (detail === 'computer_not_enabled' || (response.status === 503 && detail === 'runtime_unavailable')) {
|
|
320
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Revyl refused the computer request (${detail}). ${COMPUTER_AVAILABILITY}. Ask support@revyl.ai about staging organization enablement. See ${this.viewerUrl}`);
|
|
321
|
+
}
|
|
322
|
+
if (response.status === 429 && detail === 'computer_limit')
|
|
323
|
+
throw new VerifyError('COMPUTER_LIMIT', `${COMPUTER_CAPACITY} See ${this.viewerUrl}`);
|
|
242
324
|
let code = 'API_ERROR';
|
|
243
325
|
if (detail === 'run_closed')
|
|
244
326
|
code = 'SESSION_ENDED';
|
|
@@ -250,7 +332,7 @@ export class ComputerSession {
|
|
|
250
332
|
code = 'INVALID_ARGUMENT';
|
|
251
333
|
else if (response.status === 401 || response.status === 403)
|
|
252
334
|
code = 'AUTH_REJECTED';
|
|
253
|
-
else if (detail === '
|
|
335
|
+
else if (detail === 'start_failed')
|
|
254
336
|
code = 'SESSION_START_FAILED';
|
|
255
337
|
else if (response.status === 409 || detail === 'operation_limit')
|
|
256
338
|
code = 'SESSION_BUSY';
|
|
@@ -265,14 +347,20 @@ export class ComputerSession {
|
|
|
265
347
|
const report = parseComputerJson(response);
|
|
266
348
|
if (!report || report.computer_session_id !== this.id || !STATES.has(report.state))
|
|
267
349
|
throw invalidResponse();
|
|
350
|
+
if (report.expires_at != null)
|
|
351
|
+
this.expiresAtMs = parseTimestamp(report.expires_at).getTime();
|
|
268
352
|
return report;
|
|
269
353
|
}
|
|
270
354
|
assertOpen() {
|
|
271
|
-
if (this.closed)
|
|
272
|
-
throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed.`);
|
|
355
|
+
if (this.closed || (this.expiresAtMs !== undefined && Date.now() >= this.expiresAtMs))
|
|
356
|
+
throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed or expired.`);
|
|
357
|
+
}
|
|
358
|
+
observationSignal(timeoutMs) {
|
|
359
|
+
const remainingMs = this.expiresAtMs === undefined ? timeoutMs : Math.min(timeoutMs, this.expiresAtMs - Date.now());
|
|
360
|
+
return AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(Math.max(0, remainingMs))]);
|
|
273
361
|
}
|
|
274
362
|
unavailable(method) {
|
|
275
|
-
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on
|
|
363
|
+
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on computers; use exec() and file operations. See ${this.viewerUrl}`));
|
|
276
364
|
}
|
|
277
365
|
log(line) {
|
|
278
366
|
this.options.log?.(`${this.target}: ${line}`);
|
|
@@ -312,6 +400,14 @@ function decodeBytes(value, maxBytes) {
|
|
|
312
400
|
function invalidResponse() {
|
|
313
401
|
return new VerifyError('API_ERROR', 'Revyl returned an invalid computer response.');
|
|
314
402
|
}
|
|
403
|
+
function parseTimestamp(value) {
|
|
404
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))
|
|
405
|
+
throw invalidResponse();
|
|
406
|
+
const calendarTime = new Date(`${value.slice(0, 19)}Z`);
|
|
407
|
+
if (!Number.isFinite(calendarTime.getTime()) || calendarTime.toISOString().slice(0, 19) !== value.slice(0, 19))
|
|
408
|
+
throw invalidResponse();
|
|
409
|
+
return new Date(value);
|
|
410
|
+
}
|
|
315
411
|
function parseComputerJson(response) {
|
|
316
412
|
try {
|
|
317
413
|
return JSON.parse(new TextDecoder().decode(response.body));
|
package/dist/config.d.ts
CHANGED
|
@@ -46,7 +46,7 @@ export interface MobileTargetConfig {
|
|
|
46
46
|
startTimeout?: number;
|
|
47
47
|
idleTimeoutSeconds?: number;
|
|
48
48
|
}
|
|
49
|
-
/** A Revyl computer
|
|
49
|
+
/** A Revyl computer that runs commands and files instead of agent steps. See docs/computers.md. */
|
|
50
50
|
export interface ComputerTargetConfig {
|
|
51
51
|
platform: ComputerPlatform;
|
|
52
52
|
startTimeout?: number;
|