@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/CHANGELOG.md +48 -3
- package/README.md +128 -61
- package/dist/api/client.d.ts +6 -4
- package/dist/api/client.js +64 -14
- package/dist/api/types.d.ts +7 -0
- package/dist/cli/init.js +2 -3
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +64 -0
- package/dist/cli/login.d.ts +6 -0
- package/dist/cli/login.js +200 -0
- package/dist/cli/main.js +48 -14
- package/dist/computer-session.d.ts +21 -5
- package/dist/computer-session.js +214 -35
- package/dist/config.d.ts +8 -1
- package/dist/config.js +16 -2
- package/dist/errors.d.ts +6 -1
- package/dist/errors.js +4 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +1 -1
- package/dist/open.d.ts +11 -3
- package/dist/open.js +37 -11
- package/dist/screen-readiness.d.ts +1 -0
- package/dist/screen-readiness.js +27 -0
- package/dist/session.d.ts +30 -2
- package/dist/session.js +165 -12
- package/docs/README.md +2 -2
- package/docs/agents.md +14 -9
- package/docs/api.md +86 -12
- package/docs/ci.md +1 -1
- package/docs/computers.md +28 -17
- package/docs/configuration.md +14 -12
- package/docs/end-to-end.md +105 -0
- package/docs/errors.md +31 -5
- package/docs/getting-started.md +49 -26
- package/docs/llms.txt +6 -6
- package/docs/mobile.md +6 -0
- package/docs/web.md +1 -7
- package/package.json +7 -4
- package/docs/linux-computers-contract.md +0 -107
package/dist/computer-session.js
CHANGED
|
@@ -2,54 +2,69 @@ 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 { edgeBlockedError, responseDetail, responseErrorCode, responseRefusalReason, sessionViewerUrl } from './api/client.js';
|
|
6
6
|
import { trackSession, untrackSession } from './cleanup.js';
|
|
7
|
+
import { validateHostedTarget } from './config.js';
|
|
7
8
|
import { VerifyError, isVerifyError } from './errors.js';
|
|
8
|
-
import { pngSize } from './session.js';
|
|
9
|
+
import { DeviceSession, pngSize } from './session.js';
|
|
9
10
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
11
|
const STATES = new Set(['starting', 'active', 'closing', 'stopped', 'failed', 'owner_lost']);
|
|
11
12
|
const MAX_OUTPUT_BYTES = 1_048_576;
|
|
12
|
-
const
|
|
13
|
+
const COMPUTER_AVAILABILITY = 'Computers are early access. Ask support@revyl.ai about access.';
|
|
14
|
+
const COMPUTER_CAPACITY = 'Computer admission was refused because of queued work (including iOS) or the concurrency limit. Verify will not retry the start.';
|
|
13
15
|
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
16
|
export class ComputerSession {
|
|
15
17
|
client;
|
|
16
18
|
options;
|
|
17
|
-
|
|
19
|
+
stopOnClose;
|
|
20
|
+
platform;
|
|
18
21
|
target;
|
|
19
|
-
id
|
|
22
|
+
id;
|
|
20
23
|
viewerUrl;
|
|
21
24
|
steps = [];
|
|
22
25
|
lifetime = new AbortController();
|
|
23
26
|
closePromise;
|
|
24
|
-
|
|
27
|
+
expiresAtMs;
|
|
28
|
+
screenReadyUntilMs = 0;
|
|
29
|
+
children = new Set();
|
|
30
|
+
childStarts = new Set();
|
|
31
|
+
closing = false;
|
|
32
|
+
constructor(client, target, options, id = randomUUID(), stopOnClose = true) {
|
|
25
33
|
this.client = client;
|
|
26
34
|
this.options = options;
|
|
35
|
+
this.stopOnClose = stopOnClose;
|
|
36
|
+
this.id = id;
|
|
27
37
|
this.target = target.name;
|
|
28
|
-
this.
|
|
38
|
+
this.platform = target.platform;
|
|
39
|
+
this.viewerUrl = sessionViewerUrl(this.id, 'computer', options.appUrl);
|
|
29
40
|
}
|
|
30
41
|
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
42
|
const session = new ComputerSession(client, target, options);
|
|
34
43
|
const signal = AbortSignal.any([AbortSignal.timeout(target.startTimeout), session.lifetime.signal]);
|
|
44
|
+
let createAcknowledged = false;
|
|
35
45
|
trackSession(session);
|
|
36
46
|
try {
|
|
37
|
-
session.log(`starting
|
|
38
|
-
|
|
47
|
+
session.log(`starting computer ${session.viewerUrl}. ${COMPUTER_AVAILABILITY}`);
|
|
48
|
+
const response = await session.request('POST', '', { computer_session_id: session.id, os: target.platform === 'mac' ? 'macos' : 'linux' }, signal);
|
|
49
|
+
createAcknowledged = true;
|
|
50
|
+
let report = session.parseReport(response);
|
|
39
51
|
for (;;) {
|
|
40
52
|
if (report.state === 'active')
|
|
41
53
|
break;
|
|
42
|
-
if (report.state !== 'starting')
|
|
43
|
-
|
|
54
|
+
if (report.state !== 'starting') {
|
|
55
|
+
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}` : '';
|
|
56
|
+
throw new VerifyError('SESSION_START_FAILED', `The ${session.target} computer ended before it was ready (${report.state}).${reason} See ${session.viewerUrl}`);
|
|
57
|
+
}
|
|
44
58
|
await delay(2_000, undefined, { signal });
|
|
45
|
-
report = session.
|
|
59
|
+
report = session.parseReport(await session.request('GET', '/report', undefined, signal));
|
|
46
60
|
}
|
|
61
|
+
session.screenReadyUntilMs = Date.now() + 30_000;
|
|
47
62
|
session.log('ready');
|
|
48
63
|
return session;
|
|
49
64
|
}
|
|
50
65
|
catch (error) {
|
|
51
66
|
const timedOut = signal.aborted;
|
|
52
|
-
if (isVerifyError(error) && ['AUTH_REJECTED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT'].includes(error.code)) {
|
|
67
|
+
if (!createAcknowledged && isVerifyError(error) && ['AUTH_REJECTED', 'EDGE_BLOCKED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT', 'COMPUTER_LIMIT'].includes(error.code)) {
|
|
53
68
|
session.lifetime.abort();
|
|
54
69
|
untrackSession(session);
|
|
55
70
|
}
|
|
@@ -57,13 +72,84 @@ export class ComputerSession {
|
|
|
57
72
|
await session.close().catch(() => undefined);
|
|
58
73
|
}
|
|
59
74
|
if (timedOut)
|
|
60
|
-
throw new VerifyError('TIMEOUT', `The ${session.target} computer was not ready within ${target.startTimeout} ms. A possible cause is that no
|
|
75
|
+
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
76
|
throw error;
|
|
62
77
|
}
|
|
63
78
|
}
|
|
64
79
|
get closed() {
|
|
65
80
|
return this.closePromise !== undefined;
|
|
66
81
|
}
|
|
82
|
+
static async attach(client, computerId, options) {
|
|
83
|
+
if (typeof computerId !== 'string' || !UUID.test(computerId))
|
|
84
|
+
throw new VerifyError('INVALID_ARGUMENT', 'attach() requires a computer session UUID.');
|
|
85
|
+
const probe = new ComputerSession(client, { name: 'computer', platform: 'mac', startTimeout: 600_000 }, options, computerId);
|
|
86
|
+
const response = await probe.request('GET', '/report', undefined, AbortSignal.timeout(30_000));
|
|
87
|
+
const raw = parseComputerJson(response);
|
|
88
|
+
if (!raw || raw.computer_session_id !== computerId || !['macos', 'linux'].includes(raw.os ?? '') || raw.expires_at == null)
|
|
89
|
+
throw invalidResponse();
|
|
90
|
+
const session = new ComputerSession(client, { name: raw.os === 'macos' ? 'mac' : 'linux', platform: raw.os === 'macos' ? 'mac' : 'linux', startTimeout: 600_000 }, options, computerId, options.stopOnClose ?? true);
|
|
91
|
+
session.parseReport(response);
|
|
92
|
+
if (raw.state !== 'active' || (session.expiresAtMs !== undefined && Date.now() >= session.expiresAtMs))
|
|
93
|
+
throw new VerifyError('SESSION_ENDED', 'The computer is not active or has expired.');
|
|
94
|
+
if (session.stopOnClose)
|
|
95
|
+
trackSession(session);
|
|
96
|
+
return session;
|
|
97
|
+
}
|
|
98
|
+
async open(target) {
|
|
99
|
+
this.assertOpen();
|
|
100
|
+
if (this.expiresAtMs === undefined)
|
|
101
|
+
throw new VerifyError('API_ERROR', 'The computer report must include expiry before opening a hosted child.');
|
|
102
|
+
const resolved = validateHostedTarget(target);
|
|
103
|
+
if ((this.platform === 'mac' && resolved.platform === 'android') || (this.platform === 'linux' && resolved.platform === 'ios'))
|
|
104
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'This computer cannot host the requested platform.', { reason: 'computer_cannot_host_platform' });
|
|
105
|
+
const pending = DeviceSession.start(this.client, resolved, this.options, {
|
|
106
|
+
id: this.id,
|
|
107
|
+
signal: this.lifetime.signal,
|
|
108
|
+
expiresAt: this.expiresAt,
|
|
109
|
+
register: (session) => { this.children.add(session); },
|
|
110
|
+
});
|
|
111
|
+
this.childStarts.add(pending);
|
|
112
|
+
try {
|
|
113
|
+
return await pending;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
this.childStarts.delete(pending);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async report() {
|
|
120
|
+
const report = this.parseReport(await this.request('GET', '/report', undefined, AbortSignal.timeout(30_000)));
|
|
121
|
+
return { ...report };
|
|
122
|
+
}
|
|
123
|
+
navigate() {
|
|
124
|
+
return this.unavailable('navigate');
|
|
125
|
+
}
|
|
126
|
+
get expiresAt() {
|
|
127
|
+
return this.expiresAtMs === undefined ? undefined : new Date(this.expiresAtMs);
|
|
128
|
+
}
|
|
129
|
+
async recording() {
|
|
130
|
+
const signal = AbortSignal.timeout(30_000);
|
|
131
|
+
const report = this.parseReport(await this.request('GET', '/report', undefined, signal));
|
|
132
|
+
if (!['stopped', 'failed', 'owner_lost'].includes(report.state) || report.release !== 'confirmed') {
|
|
133
|
+
throw new VerifyError('SESSION_BUSY', `The computer recording can be requested only after terminal state and confirmed release. See ${this.viewerUrl}`);
|
|
134
|
+
}
|
|
135
|
+
const response = await this.request('GET', '/recording', undefined, signal);
|
|
136
|
+
if (response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
|
|
137
|
+
return null;
|
|
138
|
+
const raw = parseComputerJson(response);
|
|
139
|
+
if (!raw || typeof raw.url !== 'string' || !/^https:\/\//i.test(raw.url) || /[\x00-\x20\x7f]/.test(raw.url))
|
|
140
|
+
throw invalidResponse();
|
|
141
|
+
let url;
|
|
142
|
+
try {
|
|
143
|
+
url = new URL(raw.url);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw invalidResponse();
|
|
147
|
+
}
|
|
148
|
+
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)))
|
|
149
|
+
throw invalidResponse();
|
|
150
|
+
const startedAt = raw.started_at == null ? undefined : parseTimestamp(raw.started_at);
|
|
151
|
+
return { url: raw.url, ...(raw.duration_seconds != null ? { durationSeconds: raw.duration_seconds } : {}), ...(startedAt ? { startedAt } : {}) };
|
|
152
|
+
}
|
|
67
153
|
instruction() {
|
|
68
154
|
return this.unavailable('instruction');
|
|
69
155
|
}
|
|
@@ -72,7 +158,38 @@ export class ComputerSession {
|
|
|
72
158
|
}
|
|
73
159
|
async screenshot(options = {}) {
|
|
74
160
|
this.assertOpen();
|
|
75
|
-
const
|
|
161
|
+
const signal = this.observationSignal(30_000);
|
|
162
|
+
let retrySignal = signal;
|
|
163
|
+
let response;
|
|
164
|
+
let transient = false;
|
|
165
|
+
let backoffMs = 500;
|
|
166
|
+
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}`);
|
|
167
|
+
try {
|
|
168
|
+
for (;;) {
|
|
169
|
+
this.assertOpen();
|
|
170
|
+
response = await this.request('GET', '/screen/screenshot', undefined, retrySignal);
|
|
171
|
+
if (response.status >= 200 && response.status < 300)
|
|
172
|
+
break;
|
|
173
|
+
const remainingMs = this.screenReadyUntilMs - Date.now();
|
|
174
|
+
if (remainingMs <= 0)
|
|
175
|
+
throw notReady();
|
|
176
|
+
if (!transient)
|
|
177
|
+
retrySignal = AbortSignal.any([signal, AbortSignal.timeout(remainingMs)]);
|
|
178
|
+
transient = true;
|
|
179
|
+
await delay(Math.min(backoffMs, remainingMs), undefined, { signal: retrySignal });
|
|
180
|
+
if (Date.now() >= this.screenReadyUntilMs)
|
|
181
|
+
throw notReady();
|
|
182
|
+
backoffMs = Math.min(backoffMs * 2, 2_000);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (transient && !this.lifetime.signal.aborted && (this.expiresAtMs === undefined || Date.now() < this.expiresAtMs) && retrySignal.aborted && Date.now() >= this.screenReadyUntilMs)
|
|
187
|
+
throw notReady();
|
|
188
|
+
if (retrySignal.aborted)
|
|
189
|
+
throw new VerifyError('TIMEOUT', `Computer screenshot observation was cancelled or exceeded its deadline. See ${this.viewerUrl}`);
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
this.assertOpen();
|
|
76
193
|
const raw = parseComputerJson(response);
|
|
77
194
|
const bytes = decodeBytes(raw?.png, 128 * MAX_OUTPUT_BYTES);
|
|
78
195
|
const size = pngSize(bytes);
|
|
@@ -140,19 +257,35 @@ export class ComputerSession {
|
|
|
140
257
|
}
|
|
141
258
|
close() {
|
|
142
259
|
this.closePromise ??= (async () => {
|
|
260
|
+
this.closing = true;
|
|
143
261
|
this.lifetime.abort();
|
|
262
|
+
const childCleanup = (async () => {
|
|
263
|
+
await Promise.allSettled([...this.childStarts]);
|
|
264
|
+
return Promise.allSettled([...this.children].map((child) => child.close()));
|
|
265
|
+
})();
|
|
266
|
+
const childDeadline = new AbortController();
|
|
267
|
+
const childResults = await Promise.race([childCleanup, delay(5_000, undefined, { signal: childDeadline.signal })]);
|
|
268
|
+
childDeadline.abort();
|
|
144
269
|
try {
|
|
145
|
-
|
|
270
|
+
if (!this.stopOnClose) {
|
|
271
|
+
if (!childResults || childResults.some((result) => result.status === 'rejected'))
|
|
272
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Hosted child cleanup is unconfirmed; the attached computer was left running. See ${this.viewerUrl}`);
|
|
273
|
+
this.log('detached; computer left running');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const report = this.parseReport(await this.request('POST', '/stop', undefined, AbortSignal.timeout(310_000)));
|
|
146
277
|
if (report.release !== 'confirmed') {
|
|
147
278
|
throw new VerifyError('ACTION_UNCERTAIN', `Computer stop was requested, but physical release is unconfirmed. Inspect ${this.viewerUrl}`);
|
|
148
279
|
}
|
|
149
280
|
if (!['stopped', 'failed', 'owner_lost'].includes(report.state))
|
|
150
281
|
throw invalidResponse();
|
|
282
|
+
if (!childResults || childResults.some((result) => result.status === 'rejected'))
|
|
283
|
+
this.log('a child stop was unconfirmed before the parent was released');
|
|
151
284
|
this.log('closed');
|
|
152
285
|
}
|
|
153
286
|
catch (error) {
|
|
154
|
-
if (isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
|
|
155
|
-
const message = `${error.message} A possible cause is that no
|
|
287
|
+
if (this.stopOnClose && isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
|
|
288
|
+
const message = `${error.message} A possible cause is that no computer could be assigned. ${COMPUTER_AVAILABILITY}`;
|
|
156
289
|
this.log(`close failed (ACTION_UNCERTAIN); ${message}`);
|
|
157
290
|
throw new VerifyError('ACTION_UNCERTAIN', message, { cause: error });
|
|
158
291
|
}
|
|
@@ -172,10 +305,21 @@ export class ComputerSession {
|
|
|
172
305
|
this.assertOpen();
|
|
173
306
|
const requestId = randomUUID();
|
|
174
307
|
const operationId = computerOperationId(this.id, requestId);
|
|
175
|
-
const signal =
|
|
308
|
+
const signal = this.observationSignal(310_000);
|
|
309
|
+
let wireCommand = command;
|
|
310
|
+
if (command.kind === 'exec') {
|
|
311
|
+
const { argv, ...fields } = command;
|
|
312
|
+
wireCommand = { ...fields, argv_b64: argv.map(encodeWireText) };
|
|
313
|
+
}
|
|
314
|
+
else if (command.kind === 'file_read' || command.kind === 'file_write' || command.kind === 'file_stat') {
|
|
315
|
+
const { path, ...fields } = command;
|
|
316
|
+
wireCommand = { ...fields, path_b64: encodeWireText(path) };
|
|
317
|
+
}
|
|
176
318
|
try {
|
|
177
|
-
let response = await this.request('POST', '/operations', { computer_session_id: this.id, request_id: requestId, command }, signal);
|
|
319
|
+
let response = await this.request('POST', '/operations', { computer_session_id: this.id, request_id: requestId, command: wireCommand }, signal);
|
|
178
320
|
for (;;) {
|
|
321
|
+
if (signal.aborted)
|
|
322
|
+
throw signal.reason;
|
|
179
323
|
if (response.status !== 202) {
|
|
180
324
|
const body = parseComputerJson(response);
|
|
181
325
|
if (body?.computer_session_id !== this.id || body.operation_id !== operationId || !['recorded', 'pending', 'failed'].includes(body.evidence) || !body.execution)
|
|
@@ -224,7 +368,7 @@ export class ComputerSession {
|
|
|
224
368
|
const path = `/api/v1/computer-runs${suffix === '' ? '' : `/${this.id}${suffix}`}`;
|
|
225
369
|
let response;
|
|
226
370
|
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 });
|
|
371
|
+
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
372
|
}
|
|
229
373
|
catch (error) {
|
|
230
374
|
if (method === 'POST' || suffix.startsWith('/operations/'))
|
|
@@ -235,12 +379,32 @@ export class ComputerSession {
|
|
|
235
379
|
}
|
|
236
380
|
if (response.status >= 200 && response.status < 300)
|
|
237
381
|
return response;
|
|
238
|
-
const
|
|
239
|
-
if (
|
|
240
|
-
throw
|
|
382
|
+
const blocked = edgeBlockedError(response);
|
|
383
|
+
if (blocked)
|
|
384
|
+
throw blocked;
|
|
385
|
+
if (suffix === '/recording' && response.status === 404 && responseErrorCode(response) === 'recording_unavailable')
|
|
386
|
+
return response;
|
|
387
|
+
const detail = responseRefusalReason(response) ?? responseDetail(response);
|
|
388
|
+
const refusal = { reason: responseRefusalReason(response), httpStatus: response.status };
|
|
389
|
+
if (suffix === '/screen/screenshot') {
|
|
390
|
+
let raw = null;
|
|
391
|
+
try {
|
|
392
|
+
raw = JSON.parse(new TextDecoder().decode(response.body));
|
|
393
|
+
}
|
|
394
|
+
catch { }
|
|
395
|
+
const code = raw?.detail ?? raw?.message ?? raw?.error;
|
|
396
|
+
if ((code === 'screen_unavailable' && (response.status === 409 || response.status === 503)) || (code === 'screen_starting' && response.status === 409))
|
|
397
|
+
return response;
|
|
241
398
|
}
|
|
399
|
+
if (detail === 'computer_not_enabled' || (response.status === 503 && detail === 'runtime_unavailable')) {
|
|
400
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Revyl refused the computer request (${detail}). ${COMPUTER_AVAILABILITY} See ${this.viewerUrl}`, refusal);
|
|
401
|
+
}
|
|
402
|
+
if (response.status === 429 && detail === 'computer_limit')
|
|
403
|
+
throw new VerifyError('COMPUTER_LIMIT', `${COMPUTER_CAPACITY} See ${this.viewerUrl}`, refusal);
|
|
242
404
|
let code = 'API_ERROR';
|
|
243
|
-
if (
|
|
405
|
+
if (response.status === 401 || response.status === 403)
|
|
406
|
+
code = 'AUTH_REJECTED';
|
|
407
|
+
else if (detail === 'run_closed')
|
|
244
408
|
code = 'SESSION_ENDED';
|
|
245
409
|
else if (detail === 'inaccessible' || detail === 'invalid_handle')
|
|
246
410
|
code = 'NOT_FOUND';
|
|
@@ -248,9 +412,7 @@ export class ComputerSession {
|
|
|
248
412
|
code = 'ACTION_UNCERTAIN';
|
|
249
413
|
else if (detail === 'invalid_request' || response.status === 422)
|
|
250
414
|
code = 'INVALID_ARGUMENT';
|
|
251
|
-
else if (
|
|
252
|
-
code = 'AUTH_REJECTED';
|
|
253
|
-
else if (detail === 'runtime_unavailable' || detail === 'start_failed' || detail === 'computer_limit')
|
|
415
|
+
else if (detail === 'start_failed')
|
|
254
416
|
code = 'SESSION_START_FAILED';
|
|
255
417
|
else if (response.status === 409 || detail === 'operation_limit')
|
|
256
418
|
code = 'SESSION_BUSY';
|
|
@@ -259,20 +421,26 @@ export class ComputerSession {
|
|
|
259
421
|
else if (method === 'POST' && (response.status >= 500 || response.status < 400))
|
|
260
422
|
code = 'ACTION_UNCERTAIN';
|
|
261
423
|
const reason = detail && ERROR_CODES.has(detail) ? detail : `HTTP ${response.status}`;
|
|
262
|
-
throw new VerifyError(code, `Revyl refused the computer request (${reason}). See ${this.viewerUrl}
|
|
424
|
+
throw new VerifyError(code, `Revyl refused the computer request (${reason}). See ${this.viewerUrl}`, refusal);
|
|
263
425
|
}
|
|
264
|
-
|
|
426
|
+
parseReport(response) {
|
|
265
427
|
const report = parseComputerJson(response);
|
|
266
428
|
if (!report || report.computer_session_id !== this.id || !STATES.has(report.state))
|
|
267
429
|
throw invalidResponse();
|
|
430
|
+
if (report.expires_at != null)
|
|
431
|
+
this.expiresAtMs = parseTimestamp(report.expires_at).getTime();
|
|
268
432
|
return report;
|
|
269
433
|
}
|
|
270
434
|
assertOpen() {
|
|
271
|
-
if (this.closed)
|
|
272
|
-
throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed.`);
|
|
435
|
+
if (this.closing || this.closed || (this.expiresAtMs !== undefined && Date.now() >= this.expiresAtMs))
|
|
436
|
+
throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed or expired.`);
|
|
437
|
+
}
|
|
438
|
+
observationSignal(timeoutMs) {
|
|
439
|
+
const remainingMs = this.expiresAtMs === undefined ? timeoutMs : Math.min(timeoutMs, this.expiresAtMs - Date.now());
|
|
440
|
+
return AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(Math.max(0, remainingMs))]);
|
|
273
441
|
}
|
|
274
442
|
unavailable(method) {
|
|
275
|
-
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on
|
|
443
|
+
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on computers; use exec() and file operations. See ${this.viewerUrl}`));
|
|
276
444
|
}
|
|
277
445
|
log(line) {
|
|
278
446
|
this.options.log?.(`${this.target}: ${line}`);
|
|
@@ -291,6 +459,9 @@ function operationTimeout(options) {
|
|
|
291
459
|
throw new VerifyError('INVALID_ARGUMENT', 'Computer operation timeout must be an integer from 1 to 300000 ms.');
|
|
292
460
|
return timeout;
|
|
293
461
|
}
|
|
462
|
+
function encodeWireText(value) {
|
|
463
|
+
return Buffer.from(value, 'utf8').toString('base64').replaceAll('+', '-').replaceAll('/', '_');
|
|
464
|
+
}
|
|
294
465
|
function guestPath(path) {
|
|
295
466
|
if (typeof path !== 'string' || !path || Buffer.byteLength(path) > 1024 || path.split('/').some((part) => ['', '.', '..'].includes(part)) || /[\x00-\x1f\x7f\\:]/.test(path))
|
|
296
467
|
throw new VerifyError('INVALID_ARGUMENT', 'Computer paths must be workspace-relative, without empty, dot, parent, backslash, colon, or control-character components.');
|
|
@@ -312,6 +483,14 @@ function decodeBytes(value, maxBytes) {
|
|
|
312
483
|
function invalidResponse() {
|
|
313
484
|
return new VerifyError('API_ERROR', 'Revyl returned an invalid computer response.');
|
|
314
485
|
}
|
|
486
|
+
function parseTimestamp(value) {
|
|
487
|
+
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)))
|
|
488
|
+
throw invalidResponse();
|
|
489
|
+
const calendarTime = new Date(`${value.slice(0, 19)}Z`);
|
|
490
|
+
if (!Number.isFinite(calendarTime.getTime()) || calendarTime.toISOString().slice(0, 19) !== value.slice(0, 19))
|
|
491
|
+
throw invalidResponse();
|
|
492
|
+
return new Date(value);
|
|
493
|
+
}
|
|
315
494
|
function parseComputerJson(response) {
|
|
316
495
|
try {
|
|
317
496
|
return JSON.parse(new TextDecoder().decode(response.body));
|
package/dist/config.d.ts
CHANGED
|
@@ -46,12 +46,18 @@ 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;
|
|
53
53
|
}
|
|
54
54
|
export type TargetConfig = WebTargetConfig | MobileTargetConfig | ComputerTargetConfig;
|
|
55
|
+
export type HostedTargetConfig = (Omit<WebTargetConfig, 'app'> | Omit<MobileTargetConfig, 'app' | 'device'>) & {
|
|
56
|
+
app: {
|
|
57
|
+
appId: string;
|
|
58
|
+
buildId: string;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
55
61
|
export interface VerifyConfig {
|
|
56
62
|
/** Named targets that open() takes by name, such as open('web'). */
|
|
57
63
|
targets: Record<string, TargetConfig>;
|
|
@@ -85,4 +91,5 @@ export declare function loadConfig(cwd: string, explicitPath?: string): Promise<
|
|
|
85
91
|
export declare function validateConfig(raw: unknown, configFile: string): ResolvedConfig;
|
|
86
92
|
/** Validates a target passed straight to open(); it is named after its platform. */
|
|
87
93
|
export declare function validateTarget(raw: unknown): ResolvedTarget;
|
|
94
|
+
export declare function validateHostedTarget(raw: HostedTargetConfig): ResolvedDeviceTarget;
|
|
88
95
|
export {};
|
package/dist/config.js
CHANGED
|
@@ -71,7 +71,14 @@ export function validateTarget(raw) {
|
|
|
71
71
|
const platform = asRecord(raw, 'target', fail).platform;
|
|
72
72
|
return parseTarget(raw, typeof platform === 'string' ? platform : 'target', 'target', fail);
|
|
73
73
|
}
|
|
74
|
-
function
|
|
74
|
+
export function validateHostedTarget(raw) {
|
|
75
|
+
const fail = (path, problem) => { throw new VerifyError('INVALID_ARGUMENT', `computer.open(): ${path} ${problem}`); };
|
|
76
|
+
const target = parseTarget(raw, raw?.platform ?? 'target', 'target', fail, true);
|
|
77
|
+
if (target.platform === 'mac' || target.platform === 'linux')
|
|
78
|
+
fail('target.platform', 'must be web, ios, or android.');
|
|
79
|
+
return target;
|
|
80
|
+
}
|
|
81
|
+
function parseTarget(raw, name, path, fail, hosted = false) {
|
|
75
82
|
const target = asRecord(raw, path, fail);
|
|
76
83
|
const platform = target.platform;
|
|
77
84
|
if (typeof platform !== 'string' || !PLATFORMS.includes(platform)) {
|
|
@@ -88,6 +95,13 @@ function parseTarget(raw, name, path, fail) {
|
|
|
88
95
|
idleTimeoutSeconds: integer(target.idleTimeoutSeconds, `${path}.idleTimeoutSeconds`, 60, 3_600, 600, fail),
|
|
89
96
|
};
|
|
90
97
|
const app = asRecord(target.app, `${path}.app`, fail);
|
|
98
|
+
if (hosted) {
|
|
99
|
+
uuid(app.appId, `${path}.app.appId`, true, fail);
|
|
100
|
+
uuid(app.buildId, `${path}.app.buildId`, true, fail);
|
|
101
|
+
rejectUnknownKeys(app, ['appId', 'buildId'], `${path}.app`, fail);
|
|
102
|
+
if (target.device !== undefined)
|
|
103
|
+
fail(`${path}.device`, 'is fixed by the hosting computer.');
|
|
104
|
+
}
|
|
91
105
|
if (platform === 'web') {
|
|
92
106
|
rejectUnknownKeys(target, ['platform', 'app', 'viewport', 'signIn', 'startTimeout', 'idleTimeoutSeconds'], path, fail);
|
|
93
107
|
rejectUnknownKeys(app, ['appId', 'url', 'buildId', 'branch'], `${path}.app`, fail);
|
|
@@ -153,7 +167,7 @@ function parseTarget(raw, name, path, fail) {
|
|
|
153
167
|
fail(`${path}.app.artifactUrl`, 'must be an HTTPS URL.');
|
|
154
168
|
}
|
|
155
169
|
const sources = [app.appId, app.buildId, app.artifactUrl, app.bundleId].filter((value) => value !== undefined).length;
|
|
156
|
-
if (sources !== 1) {
|
|
170
|
+
if (!hosted && sources !== 1) {
|
|
157
171
|
fail(`${path}.app`, 'takes exactly one of appId, buildId, or artifactUrl to install an app, or bundleId to open one already on the device.');
|
|
158
172
|
}
|
|
159
173
|
let device;
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'AUTH_REJECTED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | 'NOT_AVAILABLE_ON_PLATFORM' | 'ACTION_FAILED' | 'ACTION_UNCERTAIN' | 'INSTRUCTION_FAILED' | 'VALIDATION_FAILED' | 'STEP_FAILED' | 'COMMAND_FAILED' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'TIMEOUT';
|
|
1
|
+
export type VerifyErrorCode = 'INVALID_ARGUMENT' | 'CONFIG_NOT_FOUND' | 'CONFIG_INVALID' | 'AUTH_MISSING' | 'COMPUTER_ID_MISSING' | 'AUTH_REJECTED' | 'EDGE_BLOCKED' | 'API_ERROR' | 'SESSION_START_FAILED' | 'SESSION_ENDED' | 'SESSION_BUSY' | 'COMPUTER_LIMIT' | 'NOT_AVAILABLE_ON_PLATFORM' | 'ACTION_FAILED' | 'ACTION_UNCERTAIN' | 'INSTRUCTION_FAILED' | 'VALIDATION_FAILED' | 'STEP_FAILED' | 'COMMAND_FAILED' | 'NOT_FOUND' | 'ACCESS_DENIED' | 'TIMEOUT';
|
|
2
|
+
export type RefusalReason = 'web_runtime_unavailable' | 'web_runtime_not_configured' | 'computer_limit' | 'runtime_unavailable' | 'computer_runtime_unavailable' | 'computer_at_capacity' | 'computer_not_running' | 'computer_cannot_host_platform' | 'computer_not_found' | 'web_build_required' | 'web_build_not_supported' | 'ios_build_required' | 'ios_build_not_supported' | 'android_build_required' | 'android_build_not_supported' | 'web_session_conflict' | 'ios_session_conflict' | 'android_session_conflict' | 'not_available_on_platform' | 'request_timeout' | 'protocol_error';
|
|
2
3
|
export declare class VerifyError extends Error {
|
|
3
4
|
readonly code: VerifyErrorCode;
|
|
5
|
+
readonly reason?: RefusalReason;
|
|
6
|
+
readonly httpStatus?: number;
|
|
4
7
|
constructor(code: VerifyErrorCode, message: string, options?: {
|
|
5
8
|
cause?: unknown;
|
|
9
|
+
reason?: RefusalReason;
|
|
10
|
+
httpStatus?: number;
|
|
6
11
|
});
|
|
7
12
|
}
|
|
8
13
|
export declare function isVerifyError(error: unknown): error is VerifyError;
|
package/dist/errors.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export class VerifyError extends Error {
|
|
2
2
|
code;
|
|
3
|
+
reason;
|
|
4
|
+
httpStatus;
|
|
3
5
|
constructor(code, message, options) {
|
|
4
6
|
super(message, options);
|
|
5
7
|
this.name = 'VerifyError';
|
|
6
8
|
this.code = code;
|
|
9
|
+
this.reason = options?.reason;
|
|
10
|
+
this.httpStatus = options?.httpStatus;
|
|
7
11
|
}
|
|
8
12
|
}
|
|
9
13
|
export function isVerifyError(error) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { open } from './open.js';
|
|
2
|
-
export type { OpenOptions } from './open.js';
|
|
3
|
-
export type { Session, StepOptions, StepResult, StepType, ValidationOptions, OperationOptions, ExecOptions, ExecResult, FileStat, ComputerProcess } from './session.js';
|
|
1
|
+
export { attach, currentComputer, open } from './open.js';
|
|
2
|
+
export type { AttachOptions, OpenOptions } from './open.js';
|
|
3
|
+
export type { OpenedSession, Session, StepOptions, StepResult, StepType, ValidationOptions, OperationOptions, ExecOptions, ExecResult, FileStat, ComputerProcess, ComputerRecording } from './session.js';
|
|
4
4
|
export { defineConfig } from './config.js';
|
|
5
|
-
export type { MobileAppConfig, MobileTargetConfig, ComputerTargetConfig, TargetConfig, VerifyConfig, WebAppConfig, WebTargetConfig } from './config.js';
|
|
5
|
+
export type { HostedTargetConfig, MobileAppConfig, MobileTargetConfig, ComputerTargetConfig, TargetConfig, VerifyConfig, WebAppConfig, WebTargetConfig } from './config.js';
|
|
6
6
|
export type { Platform, ComputerPlatform, MobilePlatform } from './api/types.js';
|
|
7
7
|
export { VerifyError, isVerifyError } from './errors.js';
|
|
8
|
-
export type { VerifyErrorCode } from './errors.js';
|
|
8
|
+
export type { RefusalReason, VerifyErrorCode } from './errors.js';
|
package/dist/index.js
CHANGED
package/dist/open.d.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { type TargetConfig } from './config.js';
|
|
2
|
-
import { type
|
|
2
|
+
import { type OpenedSession } from './session.js';
|
|
3
|
+
import { ComputerSession } from './computer-session.js';
|
|
3
4
|
export interface OpenOptions {
|
|
4
5
|
/** The config file to read target names from. Defaults to the nearest verify.config.ts at or above the current folder. */
|
|
5
6
|
config?: string;
|
|
6
7
|
/** Where progress lines go: the session link and each step's outcome. Defaults to stderr; false turns them off. */
|
|
7
8
|
log?: false | ((line: string) => void);
|
|
9
|
+
backendUrl?: string;
|
|
10
|
+
appUrl?: string;
|
|
8
11
|
}
|
|
12
|
+
export interface AttachOptions extends Pick<OpenOptions, 'log' | 'backendUrl' | 'appUrl'> {
|
|
13
|
+
stopOnClose?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function attach(computerId: string, options?: AttachOptions): Promise<ComputerSession>;
|
|
16
|
+
export declare function currentComputer(options?: AttachOptions): Promise<ComputerSession>;
|
|
9
17
|
/**
|
|
10
|
-
* Starts a Revyl cloud browser, device, or
|
|
18
|
+
* Starts a Revyl cloud browser, device, or computer. Mobile first-screen readiness is best-effort.
|
|
11
19
|
* Pass a target name from verify.config.ts, such as open('web'), or a target object.
|
|
12
20
|
*/
|
|
13
|
-
export declare function open(target: string | TargetConfig, options?: OpenOptions): Promise<
|
|
21
|
+
export declare function open(target: string | TargetConfig, options?: OpenOptions): Promise<OpenedSession>;
|
package/dist/open.js
CHANGED
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
import { RevylClient } from './api/client.js';
|
|
2
2
|
import { missingCredentialsMessage, resolveCredentials } from './auth.js';
|
|
3
|
+
import { signIn } from './cli/login.js';
|
|
3
4
|
import { DEFAULT_STEP_TIMEOUT_MS, loadConfig, validateTarget } from './config.js';
|
|
4
5
|
import { VerifyError } from './errors.js';
|
|
5
6
|
import { DeviceSession } from './session.js';
|
|
6
7
|
import { ComputerSession } from './computer-session.js';
|
|
8
|
+
const signIns = new Map();
|
|
9
|
+
async function credentialsForUse(options) {
|
|
10
|
+
const credentials = await resolveCredentials();
|
|
11
|
+
if (credentials)
|
|
12
|
+
return credentials;
|
|
13
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY || process.env.CI)
|
|
14
|
+
throw new VerifyError('AUTH_MISSING', missingCredentialsMessage());
|
|
15
|
+
const key = JSON.stringify([options.backendUrl, options.appUrl]);
|
|
16
|
+
let pending = signIns.get(key);
|
|
17
|
+
if (!pending) {
|
|
18
|
+
pending = signIn(options).finally(() => signIns.delete(key));
|
|
19
|
+
signIns.set(key, pending);
|
|
20
|
+
}
|
|
21
|
+
return pending;
|
|
22
|
+
}
|
|
23
|
+
export async function attach(computerId, options = {}) {
|
|
24
|
+
if (typeof computerId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(computerId))
|
|
25
|
+
throw new VerifyError('INVALID_ARGUMENT', 'attach() requires a computer session UUID.');
|
|
26
|
+
if (options.stopOnClose !== undefined && typeof options.stopOnClose !== 'boolean')
|
|
27
|
+
throw new VerifyError('INVALID_ARGUMENT', 'stopOnClose must be a boolean.');
|
|
28
|
+
const credentials = await credentialsForUse(options);
|
|
29
|
+
const log = options.log === false ? undefined : (options.log ?? ((line) => void process.stderr.write(`[verify] ${line}\n`)));
|
|
30
|
+
return ComputerSession.attach(new RevylClient({ token: credentials.token, backendUrl: options.backendUrl }), computerId, { stepTimeout: DEFAULT_STEP_TIMEOUT_MS, log, appUrl: options.appUrl, stopOnClose: options.stopOnClose });
|
|
31
|
+
}
|
|
32
|
+
export async function currentComputer(options = {}) {
|
|
33
|
+
const computerId = process.env.REVYL_COMPUTER_ID?.trim();
|
|
34
|
+
if (!computerId)
|
|
35
|
+
throw new VerifyError('COMPUTER_ID_MISSING', 'No current Revyl computer is available. Run this job on a revyl-macos runner.');
|
|
36
|
+
return attach(computerId, { ...options, stopOnClose: options.stopOnClose === undefined ? false : options.stopOnClose });
|
|
37
|
+
}
|
|
7
38
|
/**
|
|
8
|
-
* Starts a Revyl cloud browser, device, or
|
|
39
|
+
* Starts a Revyl cloud browser, device, or computer. Mobile first-screen readiness is best-effort.
|
|
9
40
|
* Pass a target name from verify.config.ts, such as open('web'), or a target object.
|
|
10
41
|
*/
|
|
11
42
|
export async function open(target, options = {}) {
|
|
@@ -22,15 +53,10 @@ export async function open(target, options = {}) {
|
|
|
22
53
|
else {
|
|
23
54
|
resolved = validateTarget(target);
|
|
24
55
|
}
|
|
25
|
-
|
|
26
|
-
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'Linux computers are planned, but are not available in Verify yet. See docs/computers.md.');
|
|
27
|
-
}
|
|
28
|
-
const credentials = await resolveCredentials();
|
|
29
|
-
if (!credentials)
|
|
30
|
-
throw new VerifyError('AUTH_MISSING', missingCredentialsMessage());
|
|
56
|
+
const credentials = await credentialsForUse(options);
|
|
31
57
|
const log = options.log === false ? undefined : (options.log ?? ((line) => void process.stderr.write(`[verify] ${line}\n`)));
|
|
32
|
-
const client = new RevylClient({ token: credentials.token });
|
|
33
|
-
if (resolved.platform === 'mac')
|
|
34
|
-
return ComputerSession.start(client, resolved, { stepTimeout, log });
|
|
35
|
-
return DeviceSession.start(client, resolved, { stepTimeout, log });
|
|
58
|
+
const client = new RevylClient({ token: credentials.token, backendUrl: options.backendUrl });
|
|
59
|
+
if (resolved.platform === 'mac' || resolved.platform === 'linux')
|
|
60
|
+
return ComputerSession.start(client, resolved, { stepTimeout, log, appUrl: options.appUrl });
|
|
61
|
+
return DeviceSession.start(client, resolved, { stepTimeout, log, appUrl: options.appUrl });
|
|
36
62
|
}
|
|
@@ -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
|
+
}
|