@revyl/verify 0.0.0-stage → 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 +35 -0
- package/LICENSE +201 -0
- package/README.md +137 -2
- package/SECURITY.md +13 -0
- package/dist/api/client.d.ts +63 -0
- package/dist/api/client.js +201 -0
- package/dist/api/types.d.ts +222 -0
- package/dist/api/types.js +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +65 -0
- package/dist/cleanup.d.ts +13 -0
- package/dist/cleanup.js +72 -0
- package/dist/cli/init.d.ts +14 -0
- package/dist/cli/init.js +115 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.js +127 -0
- package/dist/computer-session.d.ts +43 -0
- package/dist/computer-session.js +418 -0
- package/dist/config.d.ts +88 -0
- package/dist/config.js +212 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +14 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +3 -0
- package/dist/open.d.ts +13 -0
- package/dist/open.js +33 -0
- package/dist/screen-readiness.d.ts +1 -0
- package/dist/screen-readiness.js +27 -0
- package/dist/session.d.ts +153 -0
- package/dist/session.js +443 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +6 -0
- package/docs/README.md +19 -0
- package/docs/agents.md +43 -0
- package/docs/api.md +120 -0
- package/docs/ci.md +70 -0
- package/docs/computers.md +83 -0
- package/docs/configuration.md +92 -0
- package/docs/end-to-end.md +105 -0
- package/docs/errors.md +39 -0
- package/docs/getting-started.md +88 -0
- package/docs/linux-computers-contract.md +53 -0
- package/docs/llms.txt +19 -0
- package/docs/mobile.md +49 -0
- package/docs/web.md +37 -0
- package/package.json +71 -4
package/dist/config.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { createJiti } from 'jiti';
|
|
4
|
+
import { VerifyError } from './errors.js';
|
|
5
|
+
import { PACKAGE_NAME } from './version.js';
|
|
6
|
+
export const CONFIG_FILE_NAMES = ['verify.config.ts', 'verify.config.mts', 'verify.config.js', 'verify.config.mjs'];
|
|
7
|
+
export const DEFAULT_STEP_TIMEOUT_MS = 180_000;
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
const WEB_URL = /^https:\/\/[^/?#@\s]+(\/[^?#\s]*)?$/;
|
|
10
|
+
const TARGET_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
11
|
+
const PLATFORMS = ['web', 'ios', 'android', 'mac', 'linux'];
|
|
12
|
+
export function defineConfig(config) {
|
|
13
|
+
return config;
|
|
14
|
+
}
|
|
15
|
+
/** The nearest verify.config file in the folder or any folder above it. */
|
|
16
|
+
export function findConfigFile(startDir) {
|
|
17
|
+
for (let dir = resolve(startDir);; dir = dirname(dir)) {
|
|
18
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
19
|
+
const candidate = resolve(dir, name);
|
|
20
|
+
if (existsSync(candidate))
|
|
21
|
+
return candidate;
|
|
22
|
+
}
|
|
23
|
+
if (dirname(dir) === dir)
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function loadConfig(cwd, explicitPath) {
|
|
28
|
+
const configFile = explicitPath ? resolve(cwd, explicitPath) : findConfigFile(cwd);
|
|
29
|
+
if (!configFile || !existsSync(configFile)) {
|
|
30
|
+
throw new VerifyError('CONFIG_NOT_FOUND', explicitPath
|
|
31
|
+
? `Config file not found: ${explicitPath}`
|
|
32
|
+
: `No verify.config.ts in ${cwd} or any folder above it. Run 'npx ${PACKAGE_NAME} init' to create one, or pass a target object to open().`);
|
|
33
|
+
}
|
|
34
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false, interopDefault: true });
|
|
35
|
+
let loaded;
|
|
36
|
+
try {
|
|
37
|
+
loaded = await jiti.import(configFile, { default: true });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new VerifyError('CONFIG_INVALID', `Could not load ${configFile}: ${error.message}`, { cause: error });
|
|
41
|
+
}
|
|
42
|
+
return validateConfig(loaded, configFile);
|
|
43
|
+
}
|
|
44
|
+
export function validateConfig(raw, configFile) {
|
|
45
|
+
const fail = (path, problem) => {
|
|
46
|
+
throw new VerifyError('CONFIG_INVALID', `${configFile}: ${path} ${problem}`);
|
|
47
|
+
};
|
|
48
|
+
const config = asRecord(raw, 'the default export', fail);
|
|
49
|
+
rejectUnknownKeys(config, ['targets', 'stepTimeout'], '', fail);
|
|
50
|
+
const rawTargets = asRecord(config.targets, 'targets', fail);
|
|
51
|
+
const names = Object.keys(rawTargets);
|
|
52
|
+
if (names.length === 0)
|
|
53
|
+
fail('targets', 'must name at least one target, such as { web: { platform: \'web\', ... } }.');
|
|
54
|
+
const targets = {};
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (!TARGET_NAME.test(name))
|
|
57
|
+
fail(`targets.${name}`, 'must be named with 1-64 letters, digits, dots, dashes, or underscores.');
|
|
58
|
+
targets[name] = parseTarget(rawTargets[name], name, `targets.${name}`, fail);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
configFile,
|
|
62
|
+
targets,
|
|
63
|
+
stepTimeout: integer(config.stepTimeout, 'stepTimeout', 1_000, 3_600_000, DEFAULT_STEP_TIMEOUT_MS, fail),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Validates a target passed straight to open(); it is named after its platform. */
|
|
67
|
+
export function validateTarget(raw) {
|
|
68
|
+
const fail = (path, problem) => {
|
|
69
|
+
throw new VerifyError('INVALID_ARGUMENT', `open(): ${path} ${problem}`);
|
|
70
|
+
};
|
|
71
|
+
const platform = asRecord(raw, 'target', fail).platform;
|
|
72
|
+
return parseTarget(raw, typeof platform === 'string' ? platform : 'target', 'target', fail);
|
|
73
|
+
}
|
|
74
|
+
function parseTarget(raw, name, path, fail) {
|
|
75
|
+
const target = asRecord(raw, path, fail);
|
|
76
|
+
const platform = target.platform;
|
|
77
|
+
if (typeof platform !== 'string' || !PLATFORMS.includes(platform)) {
|
|
78
|
+
fail(`${path}.platform`, "must be 'web', 'ios', 'android', 'mac', or 'linux'.");
|
|
79
|
+
}
|
|
80
|
+
const startTimeout = integer(target.startTimeout, `${path}.startTimeout`, 10_000, 1_800_000, 600_000, fail);
|
|
81
|
+
if (platform === 'mac' || platform === 'linux') {
|
|
82
|
+
rejectUnknownKeys(target, ['platform', 'startTimeout'], path, fail);
|
|
83
|
+
return { name, startTimeout, platform };
|
|
84
|
+
}
|
|
85
|
+
const common = {
|
|
86
|
+
name,
|
|
87
|
+
startTimeout,
|
|
88
|
+
idleTimeoutSeconds: integer(target.idleTimeoutSeconds, `${path}.idleTimeoutSeconds`, 60, 3_600, 600, fail),
|
|
89
|
+
};
|
|
90
|
+
const app = asRecord(target.app, `${path}.app`, fail);
|
|
91
|
+
if (platform === 'web') {
|
|
92
|
+
rejectUnknownKeys(target, ['platform', 'app', 'viewport', 'signIn', 'startTimeout', 'idleTimeoutSeconds'], path, fail);
|
|
93
|
+
rejectUnknownKeys(app, ['appId', 'url', 'buildId', 'branch'], `${path}.app`, fail);
|
|
94
|
+
uuid(app.appId, `${path}.app.appId`, true, fail);
|
|
95
|
+
uuid(app.buildId, `${path}.app.buildId`, false, fail);
|
|
96
|
+
if (app.url !== undefined && (typeof app.url !== 'string' || app.url.length > 2048 || !WEB_URL.test(app.url))) {
|
|
97
|
+
fail(`${path}.app.url`, 'must be a public HTTPS URL without credentials, query, or fragment. Revyl cannot reach localhost.');
|
|
98
|
+
}
|
|
99
|
+
if (app.url !== undefined && app.buildId !== undefined)
|
|
100
|
+
fail(`${path}.app`, 'takes url or buildId, not both.');
|
|
101
|
+
optionalString(app.branch, `${path}.app.branch`, 255, fail);
|
|
102
|
+
if (app.branch !== undefined && app.buildId !== undefined)
|
|
103
|
+
fail(`${path}.app`, 'takes branch or buildId, not both.');
|
|
104
|
+
let viewport;
|
|
105
|
+
if (target.viewport !== undefined) {
|
|
106
|
+
const raw = asRecord(target.viewport, `${path}.viewport`, fail);
|
|
107
|
+
rejectUnknownKeys(raw, ['width', 'height', 'deviceScaleFactor'], `${path}.viewport`, fail);
|
|
108
|
+
viewport = {
|
|
109
|
+
width: integer(raw.width, `${path}.viewport.width`, 320, 3840, undefined, fail),
|
|
110
|
+
height: integer(raw.height, `${path}.viewport.height`, 240, 2160, undefined, fail),
|
|
111
|
+
};
|
|
112
|
+
if (raw.deviceScaleFactor !== undefined) {
|
|
113
|
+
const factor = raw.deviceScaleFactor;
|
|
114
|
+
if (typeof factor !== 'number' || !(factor >= 1 && factor <= 3))
|
|
115
|
+
fail(`${path}.viewport.deviceScaleFactor`, 'must be from 1 to 3.');
|
|
116
|
+
viewport.deviceScaleFactor = factor;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let signIn;
|
|
120
|
+
if (target.signIn !== undefined) {
|
|
121
|
+
const raw = asRecord(target.signIn, `${path}.signIn`, fail);
|
|
122
|
+
rejectUnknownKeys(raw, ['path', 'secret', 'persona'], `${path}.signIn`, fail);
|
|
123
|
+
if (typeof raw.path !== 'string' || !/^\/(?!\/)[^?#\\\s]{0,511}$/.test(raw.path)) {
|
|
124
|
+
fail(`${path}.signIn.path`, 'must be a path starting with one / and no query, fragment, or whitespace.');
|
|
125
|
+
}
|
|
126
|
+
if (typeof raw.secret !== 'string' || raw.secret.trim() === '') {
|
|
127
|
+
fail(`${path}.signIn.secret`, 'must name an organization secret launch variable (its name, never its value).');
|
|
128
|
+
}
|
|
129
|
+
if (raw.persona !== undefined && (typeof raw.persona !== 'string' || !/^[a-z0-9_-]{1,32}$/.test(raw.persona))) {
|
|
130
|
+
fail(`${path}.signIn.persona`, 'must match [a-z0-9_-]{1,32}.');
|
|
131
|
+
}
|
|
132
|
+
signIn = { path: raw.path, secret: raw.secret, ...(raw.persona ? { persona: raw.persona } : {}) };
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
...common,
|
|
136
|
+
platform: 'web',
|
|
137
|
+
app: {
|
|
138
|
+
appId: app.appId,
|
|
139
|
+
...(app.url !== undefined ? { url: app.url } : {}),
|
|
140
|
+
...(app.buildId !== undefined ? { buildId: app.buildId } : {}),
|
|
141
|
+
...(app.branch !== undefined ? { branch: app.branch } : {}),
|
|
142
|
+
},
|
|
143
|
+
...(viewport ? { viewport } : {}),
|
|
144
|
+
...(signIn ? { signIn } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
rejectUnknownKeys(target, ['platform', 'app', 'device', 'startTimeout', 'idleTimeoutSeconds'], path, fail);
|
|
148
|
+
rejectUnknownKeys(app, ['appId', 'buildId', 'artifactUrl', 'bundleId'], `${path}.app`, fail);
|
|
149
|
+
uuid(app.appId, `${path}.app.appId`, false, fail);
|
|
150
|
+
uuid(app.buildId, `${path}.app.buildId`, false, fail);
|
|
151
|
+
optionalString(app.bundleId, `${path}.app.bundleId`, 255, fail);
|
|
152
|
+
if (app.artifactUrl !== undefined && (typeof app.artifactUrl !== 'string' || !/^https:\/\//.test(app.artifactUrl))) {
|
|
153
|
+
fail(`${path}.app.artifactUrl`, 'must be an HTTPS URL.');
|
|
154
|
+
}
|
|
155
|
+
const sources = [app.appId, app.buildId, app.artifactUrl, app.bundleId].filter((value) => value !== undefined).length;
|
|
156
|
+
if (sources !== 1) {
|
|
157
|
+
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
|
+
}
|
|
159
|
+
let device;
|
|
160
|
+
if (target.device !== undefined) {
|
|
161
|
+
const raw = asRecord(target.device, `${path}.device`, fail);
|
|
162
|
+
rejectUnknownKeys(raw, ['model', 'osVersion'], `${path}.device`, fail);
|
|
163
|
+
optionalString(raw.model, `${path}.device.model`, 128, fail);
|
|
164
|
+
optionalString(raw.osVersion, `${path}.device.osVersion`, 64, fail);
|
|
165
|
+
device = {
|
|
166
|
+
...(raw.model !== undefined ? { model: raw.model } : {}),
|
|
167
|
+
...(raw.osVersion !== undefined ? { osVersion: raw.osVersion } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
...common,
|
|
172
|
+
platform: platform,
|
|
173
|
+
app: {
|
|
174
|
+
...(app.appId !== undefined ? { appId: app.appId } : {}),
|
|
175
|
+
...(app.buildId !== undefined ? { buildId: app.buildId } : {}),
|
|
176
|
+
...(app.artifactUrl !== undefined ? { artifactUrl: app.artifactUrl } : {}),
|
|
177
|
+
...(app.bundleId !== undefined ? { bundleId: app.bundleId } : {}),
|
|
178
|
+
},
|
|
179
|
+
...(device ? { device } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function asRecord(raw, path, fail) {
|
|
183
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
184
|
+
fail(path, 'must be an object.');
|
|
185
|
+
return raw;
|
|
186
|
+
}
|
|
187
|
+
function rejectUnknownKeys(record, allowed, path, fail) {
|
|
188
|
+
for (const key of Object.keys(record)) {
|
|
189
|
+
if (!allowed.includes(key))
|
|
190
|
+
fail(path ? `${path}.${key}` : key, `is not a known option. Known: ${allowed.join(', ')}.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function integer(raw, path, min, max, fallback, fail) {
|
|
194
|
+
if (raw === undefined && fallback !== undefined)
|
|
195
|
+
return fallback;
|
|
196
|
+
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < min || raw > max)
|
|
197
|
+
fail(path, `must be an integer from ${min} to ${max}.`);
|
|
198
|
+
return raw;
|
|
199
|
+
}
|
|
200
|
+
function uuid(raw, path, required, fail) {
|
|
201
|
+
if (raw === undefined && !required)
|
|
202
|
+
return;
|
|
203
|
+
if (typeof raw !== 'string' || !UUID.test(raw)) {
|
|
204
|
+
fail(path, `${required ? 'is required and ' : ''}must be a Revyl id (a UUID); copy it from the app's page in Revyl.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function optionalString(raw, path, maxLength, fail) {
|
|
208
|
+
if (raw === undefined)
|
|
209
|
+
return;
|
|
210
|
+
if (typeof raw !== 'string' || raw.trim() === '' || raw.length > maxLength)
|
|
211
|
+
fail(path, `must be a non-empty string of at most ${maxLength} characters.`);
|
|
212
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'AUTH_REJECTED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | '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 declare class VerifyError extends Error {
|
|
3
|
+
readonly code: VerifyErrorCode;
|
|
4
|
+
constructor(code: VerifyErrorCode, message: string, options?: {
|
|
5
|
+
cause?: unknown;
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
export declare function isVerifyError(error: unknown): error is VerifyError;
|
|
9
|
+
export declare function errorMessage(error: unknown): string;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class VerifyError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message, options) {
|
|
4
|
+
super(message, options);
|
|
5
|
+
this.name = 'VerifyError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function isVerifyError(error) {
|
|
10
|
+
return error instanceof Error && typeof error.code === 'string' && error.name === 'VerifyError';
|
|
11
|
+
}
|
|
12
|
+
export function errorMessage(error) {
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { open } from './open.js';
|
|
2
|
+
export type { OpenOptions } from './open.js';
|
|
3
|
+
export type { Session, StepOptions, StepResult, StepType, ValidationOptions, OperationOptions, ExecOptions, ExecResult, FileStat, ComputerProcess, ComputerRecording } from './session.js';
|
|
4
|
+
export { defineConfig } from './config.js';
|
|
5
|
+
export type { MobileAppConfig, MobileTargetConfig, ComputerTargetConfig, TargetConfig, VerifyConfig, WebAppConfig, WebTargetConfig } from './config.js';
|
|
6
|
+
export type { Platform, ComputerPlatform, MobilePlatform } from './api/types.js';
|
|
7
|
+
export { VerifyError, isVerifyError } from './errors.js';
|
|
8
|
+
export type { VerifyErrorCode } from './errors.js';
|
package/dist/index.js
ADDED
package/dist/open.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type TargetConfig } from './config.js';
|
|
2
|
+
import { type Session } from './session.js';
|
|
3
|
+
export interface OpenOptions {
|
|
4
|
+
/** The config file to read target names from. Defaults to the nearest verify.config.ts at or above the current folder. */
|
|
5
|
+
config?: string;
|
|
6
|
+
/** Where progress lines go: the session link and each step's outcome. Defaults to stderr; false turns them off. */
|
|
7
|
+
log?: false | ((line: string) => void);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Starts a Revyl cloud browser, device, or computer. Mobile first-screen readiness is best-effort.
|
|
11
|
+
* Pass a target name from verify.config.ts, such as open('web'), or a target object.
|
|
12
|
+
*/
|
|
13
|
+
export declare function open(target: string | TargetConfig, options?: OpenOptions): Promise<Session>;
|
package/dist/open.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { RevylClient } from './api/client.js';
|
|
2
|
+
import { missingCredentialsMessage, resolveCredentials } from './auth.js';
|
|
3
|
+
import { DEFAULT_STEP_TIMEOUT_MS, loadConfig, validateTarget } from './config.js';
|
|
4
|
+
import { VerifyError } from './errors.js';
|
|
5
|
+
import { DeviceSession } from './session.js';
|
|
6
|
+
import { ComputerSession } from './computer-session.js';
|
|
7
|
+
/**
|
|
8
|
+
* Starts a Revyl cloud browser, device, or computer. Mobile first-screen readiness is best-effort.
|
|
9
|
+
* Pass a target name from verify.config.ts, such as open('web'), or a target object.
|
|
10
|
+
*/
|
|
11
|
+
export async function open(target, options = {}) {
|
|
12
|
+
let resolved;
|
|
13
|
+
let stepTimeout = DEFAULT_STEP_TIMEOUT_MS;
|
|
14
|
+
if (typeof target === 'string') {
|
|
15
|
+
const config = await loadConfig(process.cwd(), options.config);
|
|
16
|
+
resolved = config.targets[target];
|
|
17
|
+
if (!resolved) {
|
|
18
|
+
throw new VerifyError('INVALID_ARGUMENT', `open('${target}'): ${config.configFile} has no target named "${target}". Targets: ${Object.keys(config.targets).join(', ')}.`);
|
|
19
|
+
}
|
|
20
|
+
stepTimeout = config.stepTimeout;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
resolved = validateTarget(target);
|
|
24
|
+
}
|
|
25
|
+
const credentials = await resolveCredentials();
|
|
26
|
+
if (!credentials)
|
|
27
|
+
throw new VerifyError('AUTH_MISSING', missingCredentialsMessage());
|
|
28
|
+
const log = options.log === false ? undefined : (options.log ?? ((line) => void process.stderr.write(`[verify] ${line}\n`)));
|
|
29
|
+
const client = new RevylClient({ token: credentials.token });
|
|
30
|
+
if (resolved.platform === 'mac' || resolved.platform === 'linux')
|
|
31
|
+
return ComputerSession.start(client, resolved, { stepTimeout, log });
|
|
32
|
+
return DeviceSession.start(client, resolved, { stepTimeout, log });
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function screenHasContent(bytes: Uint8Array): boolean | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PNG } from 'pngjs';
|
|
2
|
+
export function screenHasContent(bytes) {
|
|
3
|
+
if (bytes.length < 24 || bytes.length > 16 * 1024 * 1024)
|
|
4
|
+
return undefined;
|
|
5
|
+
const header = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
6
|
+
const width = header.readUInt32BE(16);
|
|
7
|
+
const height = header.readUInt32BE(20);
|
|
8
|
+
if (width === 0 || height === 0 || width * height > 16_000_000)
|
|
9
|
+
return undefined;
|
|
10
|
+
try {
|
|
11
|
+
const { data } = PNG.sync.read(header);
|
|
12
|
+
const left = Math.floor(width / 10);
|
|
13
|
+
const top = Math.floor(height / 10);
|
|
14
|
+
const first = (top * width + left) * 4;
|
|
15
|
+
for (let y = top; y < Math.ceil(height * 0.9); y += 1) {
|
|
16
|
+
for (let x = left; x < Math.ceil(width * 0.9); x += 1) {
|
|
17
|
+
const offset = (y * width + x) * 4;
|
|
18
|
+
if (data[offset] !== data[first] || data[offset + 1] !== data[first + 1] || data[offset + 2] !== data[first + 2])
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { RevylClient } from './api/client.js';
|
|
2
|
+
import type { LiveStepType, Platform } from './api/types.js';
|
|
3
|
+
import type { ResolvedDeviceTarget } from './config.js';
|
|
4
|
+
export type StepType = LiveStepType;
|
|
5
|
+
/** The outcome of one instruction or validation, as Revyl's agent reported it. */
|
|
6
|
+
export interface StepResult {
|
|
7
|
+
type: StepType;
|
|
8
|
+
text: string;
|
|
9
|
+
passed: boolean;
|
|
10
|
+
/** Revyl's explanation, when it gave one. */
|
|
11
|
+
reason?: string;
|
|
12
|
+
durationMs: number;
|
|
13
|
+
}
|
|
14
|
+
export interface StepOptions {
|
|
15
|
+
/** Milliseconds before the step is cancelled on Revyl. Defaults to stepTimeout from the config, or 180000. */
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ValidationOptions extends StepOptions {
|
|
19
|
+
/** Return a failed result instead of throwing VALIDATION_FAILED. */
|
|
20
|
+
soft?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface OperationOptions {
|
|
23
|
+
/** Milliseconds Revyl lets the operation run on the computer, up to 300000. Defaults to 30000. */
|
|
24
|
+
timeout?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface ExecOptions extends OperationOptions {
|
|
27
|
+
/** Throw COMMAND_FAILED when the command exits non-zero. Defaults to true; false returns the result instead. */
|
|
28
|
+
check?: boolean;
|
|
29
|
+
/** Bytes sent to the command's standard input. */
|
|
30
|
+
stdin?: string | Uint8Array;
|
|
31
|
+
}
|
|
32
|
+
/** The outcome of a command run on a computer. Revyl keeps the first 1 MiB of stdout and stderr together. */
|
|
33
|
+
export interface ExecResult {
|
|
34
|
+
exitCode: number;
|
|
35
|
+
stdout: string;
|
|
36
|
+
stderr: string;
|
|
37
|
+
}
|
|
38
|
+
export interface FileStat {
|
|
39
|
+
kind: 'file' | 'directory';
|
|
40
|
+
size: number;
|
|
41
|
+
}
|
|
42
|
+
/** A process a command on this computer started. Its id is Revyl's handle, not a PID. */
|
|
43
|
+
export interface ComputerProcess {
|
|
44
|
+
id: string;
|
|
45
|
+
state: 'running' | 'exited';
|
|
46
|
+
exitCode?: number;
|
|
47
|
+
}
|
|
48
|
+
export interface ComputerRecording {
|
|
49
|
+
url: string;
|
|
50
|
+
durationSeconds?: number;
|
|
51
|
+
startedAt?: Date;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One live Revyl session: a cloud browser, an iOS or Android device, or a computer.
|
|
55
|
+
* A call the session's platform does not offer throws NOT_AVAILABLE_ON_PLATFORM.
|
|
56
|
+
*/
|
|
57
|
+
export interface Session {
|
|
58
|
+
readonly platform: Platform;
|
|
59
|
+
/** The target name: its key in verify.config.ts, or the platform for a target passed inline. */
|
|
60
|
+
readonly target: string;
|
|
61
|
+
/** Revyl's session id. */
|
|
62
|
+
readonly id: string;
|
|
63
|
+
/** The live view of the session in Revyl, and its recording and report once it ends. */
|
|
64
|
+
readonly viewerUrl: string;
|
|
65
|
+
/** Every instruction and validation run on this session, in order. */
|
|
66
|
+
readonly steps: StepResult[];
|
|
67
|
+
readonly closed: boolean;
|
|
68
|
+
readonly expiresAt?: Date;
|
|
69
|
+
recording(): Promise<ComputerRecording | null>;
|
|
70
|
+
/** Asks Revyl's agent to do something on the app. Browsers and devices only. */
|
|
71
|
+
instruction(text: string, options?: StepOptions): Promise<StepResult>;
|
|
72
|
+
/** Asks Revyl's agent whether something holds on screen. Browsers and devices only. */
|
|
73
|
+
validation(text: string, options?: ValidationOptions): Promise<StepResult>;
|
|
74
|
+
/** A PNG of the screen right now, also written to path when given. */
|
|
75
|
+
screenshot(options?: {
|
|
76
|
+
path?: string;
|
|
77
|
+
}): Promise<Uint8Array>;
|
|
78
|
+
/** Runs a program with its arguments, not a shell line. Computers only. */
|
|
79
|
+
exec(argv: string[], options?: ExecOptions): Promise<ExecResult>;
|
|
80
|
+
/** Up to the first 1 MiB of a file, by a path relative to the computer's workspace. Computers only. */
|
|
81
|
+
readFile(path: string, options?: OperationOptions): Promise<Buffer>;
|
|
82
|
+
/** Writes up to 64 KiB to a file, by a path relative to the computer's workspace. Computers only. */
|
|
83
|
+
writeFile(path: string, data: string | Uint8Array, options?: OperationOptions): Promise<void>;
|
|
84
|
+
/** Whether a path is a file or a directory, and its size. Computers only. */
|
|
85
|
+
stat(path: string, options?: OperationOptions): Promise<FileStat>;
|
|
86
|
+
/** The processes commands on this computer started. Computers only. */
|
|
87
|
+
processes(options?: OperationOptions): Promise<ComputerProcess[]>;
|
|
88
|
+
/** Stops a process from processes(). Computers only. */
|
|
89
|
+
terminate(processId: string, options?: OperationOptions): Promise<void>;
|
|
90
|
+
/** Stops the session on Revyl. Safe to call more than once. */
|
|
91
|
+
close(): Promise<void>;
|
|
92
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
export interface SessionOptions {
|
|
95
|
+
stepTimeout: number;
|
|
96
|
+
log: ((line: string) => void) | undefined;
|
|
97
|
+
}
|
|
98
|
+
/** A Revyl cloud browser or iOS or Android device. */
|
|
99
|
+
export declare class DeviceSession implements Session {
|
|
100
|
+
private readonly client;
|
|
101
|
+
private readonly options;
|
|
102
|
+
readonly platform: Platform;
|
|
103
|
+
/** The target name: its key in verify.config.ts, or the platform for a target passed inline. */
|
|
104
|
+
readonly target: string;
|
|
105
|
+
/** Revyl's session id. */
|
|
106
|
+
readonly id: string;
|
|
107
|
+
/** The live view of the session in Revyl, and its recording and report once it ends. */
|
|
108
|
+
readonly viewerUrl: string;
|
|
109
|
+
/** Every instruction and validation run on this session, in order. */
|
|
110
|
+
readonly steps: StepResult[];
|
|
111
|
+
private workflowRunId;
|
|
112
|
+
private closePromise;
|
|
113
|
+
private stepRunning;
|
|
114
|
+
private constructor();
|
|
115
|
+
static start(client: RevylClient, target: ResolvedDeviceTarget, options: SessionOptions): Promise<DeviceSession>;
|
|
116
|
+
get closed(): boolean;
|
|
117
|
+
/** 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
|
+
instruction(text: string, options?: StepOptions): Promise<StepResult>;
|
|
119
|
+
/**
|
|
120
|
+
* Asks Revyl's agent whether something holds on screen, such as "the cart shows 2 items". Throws VALIDATION_FAILED when it does not,
|
|
121
|
+
* unless soft is set, and STEP_FAILED when Revyl could not reach a verdict.
|
|
122
|
+
*/
|
|
123
|
+
validation(text: string, options?: ValidationOptions): Promise<StepResult>;
|
|
124
|
+
/** A PNG of the screen right now, also written to path when given. */
|
|
125
|
+
screenshot(options?: {
|
|
126
|
+
path?: string;
|
|
127
|
+
}): Promise<Uint8Array>;
|
|
128
|
+
exec(): Promise<ExecResult>;
|
|
129
|
+
recording(): Promise<never>;
|
|
130
|
+
readFile(): Promise<Buffer>;
|
|
131
|
+
writeFile(): Promise<void>;
|
|
132
|
+
stat(): Promise<FileStat>;
|
|
133
|
+
processes(): Promise<ComputerProcess[]>;
|
|
134
|
+
terminate(): Promise<void>;
|
|
135
|
+
/** Stops the session on Revyl. Safe to call more than once. */
|
|
136
|
+
close(): Promise<void>;
|
|
137
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
138
|
+
private step;
|
|
139
|
+
private runStep;
|
|
140
|
+
private launch;
|
|
141
|
+
private waitForScreenContent;
|
|
142
|
+
private log;
|
|
143
|
+
private computerOnly;
|
|
144
|
+
private worker;
|
|
145
|
+
private workerError;
|
|
146
|
+
private untilReady;
|
|
147
|
+
private waitForWebReady;
|
|
148
|
+
private waitForDeviceReady;
|
|
149
|
+
}
|
|
150
|
+
export declare function pngSize(bytes: Uint8Array): {
|
|
151
|
+
width: number;
|
|
152
|
+
height: number;
|
|
153
|
+
} | undefined;
|