@limrun/api 0.48.3 → 0.49.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 +8 -0
- package/client.d.mts +6 -3
- package/client.d.mts.map +1 -1
- package/client.d.ts +6 -3
- package/client.d.ts.map +1 -1
- package/client.js +4 -4
- package/client.js.map +1 -1
- package/client.mjs +2 -2
- package/client.mjs.map +1 -1
- package/destination-tunnel-dialer.d.mts +30 -2
- package/destination-tunnel-dialer.d.mts.map +1 -1
- package/destination-tunnel-dialer.d.ts +30 -2
- package/destination-tunnel-dialer.d.ts.map +1 -1
- package/destination-tunnel-dialer.js +349 -65
- package/destination-tunnel-dialer.js.map +1 -1
- package/destination-tunnel-dialer.mjs +345 -64
- package/destination-tunnel-dialer.mjs.map +1 -1
- package/destination-tunnel-har.d.mts +23 -0
- package/destination-tunnel-har.d.mts.map +1 -0
- package/destination-tunnel-har.d.ts +23 -0
- package/destination-tunnel-har.d.ts.map +1 -0
- package/destination-tunnel-har.js +164 -0
- package/destination-tunnel-har.js.map +1 -0
- package/destination-tunnel-har.mjs +160 -0
- package/destination-tunnel-har.mjs.map +1 -0
- package/destination-tunnel-inspection.d.mts +71 -0
- package/destination-tunnel-inspection.d.mts.map +1 -0
- package/destination-tunnel-inspection.d.ts +71 -0
- package/destination-tunnel-inspection.d.ts.map +1 -0
- package/destination-tunnel-inspection.js +267 -0
- package/destination-tunnel-inspection.js.map +1 -0
- package/destination-tunnel-inspection.mjs +261 -0
- package/destination-tunnel-inspection.mjs.map +1 -0
- package/destination-tunnel.d.mts +119 -14
- package/destination-tunnel.d.mts.map +1 -1
- package/destination-tunnel.d.ts +119 -14
- package/destination-tunnel.d.ts.map +1 -1
- package/destination-tunnel.js +407 -98
- package/destination-tunnel.js.map +1 -1
- package/destination-tunnel.mjs +374 -73
- package/destination-tunnel.mjs.map +1 -1
- package/exec-client.d.mts +1 -0
- package/exec-client.d.mts.map +1 -1
- package/exec-client.d.ts +1 -0
- package/exec-client.d.ts.map +1 -1
- package/exec-client.js.map +1 -1
- package/exec-client.mjs.map +1 -1
- package/index.d.mts +6 -0
- package/index.d.mts.map +1 -1
- package/index.d.ts +6 -0
- package/index.d.ts.map +1 -1
- package/index.js +23 -2
- package/index.js.map +1 -1
- package/index.mjs +5 -0
- package/index.mjs.map +1 -1
- package/instance-client.d.mts +105 -1
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts +105 -1
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +83 -1
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +83 -1
- package/instance-client.mjs.map +1 -1
- package/internal/destination-tunnel-management.d.mts +4 -3
- package/internal/destination-tunnel-management.d.mts.map +1 -1
- package/internal/destination-tunnel-management.d.ts +4 -3
- package/internal/destination-tunnel-management.d.ts.map +1 -1
- package/internal/destination-tunnel-management.js +64 -46
- package/internal/destination-tunnel-management.js.map +1 -1
- package/internal/destination-tunnel-management.mjs +55 -37
- package/internal/destination-tunnel-management.mjs.map +1 -1
- package/internal/destination-tunnel-url.d.mts +2 -0
- package/internal/destination-tunnel-url.d.mts.map +1 -1
- package/internal/destination-tunnel-url.d.ts +2 -0
- package/internal/destination-tunnel-url.d.ts.map +1 -1
- package/internal/destination-tunnel-url.js +45 -5
- package/internal/destination-tunnel-url.js.map +1 -1
- package/internal/destination-tunnel-url.mjs +44 -5
- package/internal/destination-tunnel-url.mjs.map +1 -1
- package/internal/destination-tunnel-wire-reader.d.mts +23 -0
- package/internal/destination-tunnel-wire-reader.d.mts.map +1 -0
- package/internal/destination-tunnel-wire-reader.d.ts +23 -0
- package/internal/destination-tunnel-wire-reader.d.ts.map +1 -0
- package/internal/destination-tunnel-wire-reader.js +110 -0
- package/internal/destination-tunnel-wire-reader.js.map +1 -0
- package/internal/destination-tunnel-wire-reader.mjs +93 -0
- package/internal/destination-tunnel-wire-reader.mjs.map +1 -0
- package/internal/persist-option.d.mts +14 -0
- package/internal/persist-option.d.mts.map +1 -0
- package/internal/persist-option.d.ts +14 -0
- package/internal/persist-option.d.ts.map +1 -0
- package/internal/persist-option.js +18 -0
- package/internal/persist-option.js.map +1 -0
- package/internal/persist-option.mjs +15 -0
- package/internal/persist-option.mjs.map +1 -0
- package/internal/proxy-transport.d.mts +27 -0
- package/internal/proxy-transport.d.mts.map +1 -1
- package/internal/proxy-transport.d.ts +27 -0
- package/internal/proxy-transport.d.ts.map +1 -1
- package/internal/proxy-transport.js +206 -0
- package/internal/proxy-transport.js.map +1 -1
- package/internal/proxy-transport.mjs +205 -0
- package/internal/proxy-transport.mjs.map +1 -1
- package/internal/session-stream.d.mts +39 -0
- package/internal/session-stream.d.mts.map +1 -0
- package/internal/session-stream.d.ts +39 -0
- package/internal/session-stream.d.ts.map +1 -0
- package/internal/session-stream.js +144 -0
- package/internal/session-stream.js.map +1 -0
- package/internal/session-stream.mjs +139 -0
- package/internal/session-stream.mjs.map +1 -0
- package/internal/tslib.js +4 -4
- package/ios-client.d.mts +61 -4
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +61 -4
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +48 -2
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +48 -2
- package/ios-client.mjs.map +1 -1
- package/package.json +22 -1
- package/resources/android-instances-helpers.d.mts +25 -0
- package/resources/android-instances-helpers.d.mts.map +1 -0
- package/resources/android-instances-helpers.d.ts +25 -0
- package/resources/android-instances-helpers.d.ts.map +1 -0
- package/resources/android-instances-helpers.js +37 -0
- package/resources/android-instances-helpers.js.map +1 -0
- package/resources/android-instances-helpers.mjs +33 -0
- package/resources/android-instances-helpers.mjs.map +1 -0
- package/resources/index.d.mts +5 -2
- package/resources/index.d.mts.map +1 -1
- package/resources/index.d.ts +5 -2
- package/resources/index.d.ts.map +1 -1
- package/resources/index.js +5 -5
- package/resources/index.js.map +1 -1
- package/resources/index.mjs +2 -2
- package/resources/index.mjs.map +1 -1
- package/resources/ios-instances-helpers.d.mts +25 -0
- package/resources/ios-instances-helpers.d.mts.map +1 -0
- package/resources/ios-instances-helpers.d.ts +25 -0
- package/resources/ios-instances-helpers.d.ts.map +1 -0
- package/resources/ios-instances-helpers.js +37 -0
- package/resources/ios-instances-helpers.js.map +1 -0
- package/resources/ios-instances-helpers.mjs +33 -0
- package/resources/ios-instances-helpers.mjs.map +1 -0
- package/resources/session-artifacts.d.mts +70 -0
- package/resources/session-artifacts.d.mts.map +1 -0
- package/resources/session-artifacts.d.ts +70 -0
- package/resources/session-artifacts.d.ts.map +1 -0
- package/resources/session-artifacts.js +8 -0
- package/resources/session-artifacts.js.map +1 -0
- package/resources/session-artifacts.mjs +7 -0
- package/resources/session-artifacts.mjs.map +1 -0
- package/resources/xcode-instances-helpers.d.mts +9 -0
- package/resources/xcode-instances-helpers.d.mts.map +1 -1
- package/resources/xcode-instances-helpers.d.ts +9 -0
- package/resources/xcode-instances-helpers.d.ts.map +1 -1
- package/resources/xcode-instances-helpers.js.map +1 -1
- package/resources/xcode-instances-helpers.mjs.map +1 -1
- package/src/client.ts +4 -2
- package/src/destination-tunnel-dialer.ts +403 -64
- package/src/destination-tunnel-har.ts +200 -0
- package/src/destination-tunnel-inspection.ts +367 -0
- package/src/destination-tunnel.ts +510 -88
- package/src/exec-client.ts +1 -0
- package/src/index.ts +52 -0
- package/src/instance-client.ts +271 -5
- package/src/internal/destination-tunnel-management.ts +67 -43
- package/src/internal/destination-tunnel-url.ts +61 -5
- package/src/internal/destination-tunnel-wire-reader.ts +109 -0
- package/src/internal/persist-option.ts +26 -0
- package/src/internal/proxy-transport.ts +248 -0
- package/src/internal/session-stream.ts +157 -0
- package/src/ios-client.ts +127 -8
- package/src/resources/android-instances-helpers.ts +45 -0
- package/src/resources/index.ts +9 -2
- package/src/resources/ios-instances-helpers.ts +45 -0
- package/src/resources/session-artifacts.ts +95 -0
- package/src/resources/xcode-instances-helpers.ts +9 -0
- package/src/version.ts +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
package/src/exec-client.ts
CHANGED
|
@@ -96,6 +96,7 @@ export type XcodeBuildExecRequest = {
|
|
|
96
96
|
apiKeyId: string;
|
|
97
97
|
apiIssuerId: string;
|
|
98
98
|
apiPrivateKeyBase64: string;
|
|
99
|
+
entitlements?: Record<string, string>;
|
|
99
100
|
};
|
|
100
101
|
/** Wire name retained for compatibility with the limbuild exec API. */
|
|
101
102
|
testflight?: AppStoreUploadConfig;
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,50 @@ export { Limrun, type ClientOptions } from './client';
|
|
|
8
8
|
export { PagePromise } from './core/pagination';
|
|
9
9
|
export * from './instance-client';
|
|
10
10
|
export * as Ios from './ios-client';
|
|
11
|
+
export {
|
|
12
|
+
isTerminalDestinationTunnelError,
|
|
13
|
+
type DestinationTcpTunnel,
|
|
14
|
+
type DestinationTcpTunnelOptions,
|
|
15
|
+
} from './destination-tunnel-dialer';
|
|
16
|
+
export {
|
|
17
|
+
decodeDestinationTunnelInspectionSSEEvent,
|
|
18
|
+
deriveDestinationTunnelInspectionURL,
|
|
19
|
+
startDestinationTunnelInspectionStream,
|
|
20
|
+
type DestinationTunnelInspectionBodyEvent,
|
|
21
|
+
type DestinationTunnelInspectionComplete,
|
|
22
|
+
type DestinationTunnelInspectionErrorCallback,
|
|
23
|
+
type DestinationTunnelInspectionEvent,
|
|
24
|
+
type DestinationTunnelInspectionEventCallback,
|
|
25
|
+
type DestinationTunnelInspectionExtension,
|
|
26
|
+
type DestinationTunnelInspectionGap,
|
|
27
|
+
type DestinationTunnelInspectionMetadataEvent,
|
|
28
|
+
type DestinationTunnelInspectionStream,
|
|
29
|
+
type DestinationTunnelInspectionStreamOptions,
|
|
30
|
+
} from './destination-tunnel-inspection';
|
|
31
|
+
export {
|
|
32
|
+
DestinationTunnelHARAssembler,
|
|
33
|
+
type DestinationTunnelHAREntry,
|
|
34
|
+
type DestinationTunnelHARExtension,
|
|
35
|
+
} from './destination-tunnel-har';
|
|
36
|
+
export {
|
|
37
|
+
validateDestinationTunnelSelectors,
|
|
38
|
+
normalizeDestinationTunnelInspection,
|
|
39
|
+
disabledDestinationTunnelInspection,
|
|
40
|
+
destinationTunnelConfigHash,
|
|
41
|
+
DestinationTunnelSelectorError,
|
|
42
|
+
DESTINATION_TUNNEL_DEFAULT_MAX_BODY_BYTES,
|
|
43
|
+
DESTINATION_TUNNEL_DEFAULT_TTL_SECONDS,
|
|
44
|
+
DESTINATION_TUNNEL_DEFAULT_WINDOW,
|
|
45
|
+
DESTINATION_TUNNEL_MAX_BODY_BYTES,
|
|
46
|
+
DESTINATION_TUNNEL_MAX_TTL_SECONDS,
|
|
47
|
+
type DestinationTunnelInspectionConfig,
|
|
48
|
+
type DestinationTunnelTransportRequest,
|
|
49
|
+
type DestinationTunnelTransportResult,
|
|
50
|
+
type DestinationTunnelSelectors,
|
|
51
|
+
type DestinationTunnelSelectorReport,
|
|
52
|
+
type DestinationTunnelSelectorErrorCode,
|
|
53
|
+
type DestinationTunnelBindReport,
|
|
54
|
+
} from './destination-tunnel';
|
|
11
55
|
export {
|
|
12
56
|
startHttpProxy,
|
|
13
57
|
startForwardHttpProxy,
|
|
@@ -87,6 +131,14 @@ export {
|
|
|
87
131
|
type GradleBuildLog,
|
|
88
132
|
} from './resources/gradle-instances-helpers';
|
|
89
133
|
export { type AssetUploadOptions } from './resources/daemon-client-shared';
|
|
134
|
+
export {
|
|
135
|
+
type SessionArtifact,
|
|
136
|
+
type SessionArtifactKind,
|
|
137
|
+
type SessionLogLine,
|
|
138
|
+
type SessionEvent,
|
|
139
|
+
type SessionEventType,
|
|
140
|
+
} from './resources/session-artifacts';
|
|
141
|
+
export { SessionStreamLostError } from './internal/session-stream';
|
|
90
142
|
export {
|
|
91
143
|
LIMRUN_DIR,
|
|
92
144
|
TRY_IMPORT_LINE,
|
package/src/instance-client.ts
CHANGED
|
@@ -11,10 +11,45 @@ import { nodeProxyTransport } from './internal/proxy-transport';
|
|
|
11
11
|
import { startTcpTunnel, isNonRetryableError } from './tunnel';
|
|
12
12
|
import type { Tunnel } from './tunnel';
|
|
13
13
|
import { syncFolder, type FolderSyncOptions, type SyncFolderResult } from './folder-sync';
|
|
14
|
+
import { startDestinationTcpTunnel, type DestinationTcpTunnel } from './destination-tunnel-dialer';
|
|
15
|
+
import type { DestinationTunnelInspectionConfig, DestinationTunnelSelectors } from './destination-tunnel';
|
|
16
|
+
import type {
|
|
17
|
+
DestinationTunnelInspectionErrorCallback,
|
|
18
|
+
DestinationTunnelInspectionEventCallback,
|
|
19
|
+
} from './destination-tunnel-inspection';
|
|
20
|
+
import {
|
|
21
|
+
getDestinationTunnelStatus,
|
|
22
|
+
stopDestinationTunnel,
|
|
23
|
+
type DestinationTunnelStatus,
|
|
24
|
+
} from './internal/destination-tunnel-management';
|
|
25
|
+
import { deriveDestinationTunnelURL } from './internal/destination-tunnel-url';
|
|
26
|
+
import { persistFields, type PersistOption } from './internal/persist-option';
|
|
27
|
+
import { streamSessionEntries } from './internal/session-stream';
|
|
28
|
+
import type { SessionLogLine, SessionEvent } from './resources/session-artifacts';
|
|
29
|
+
|
|
30
|
+
export { type PersistOption } from './internal/persist-option';
|
|
14
31
|
|
|
15
32
|
const ANDROID_RECORDING_PATH = '/data/local/tmp/recordings/video_recording.mp4';
|
|
16
33
|
const ANDROID_SIGNALING_PATH = '/ws';
|
|
17
34
|
|
|
35
|
+
/** Transparent destination tunnel from the Android instance to this machine. */
|
|
36
|
+
export type DestinationTunnel = DestinationTcpTunnel;
|
|
37
|
+
export type DestinationTunnelOptions = {
|
|
38
|
+
/** Exact endpoint and domain selector values interpreted by the Android tunnel server. */
|
|
39
|
+
selectors: DestinationTunnelSelectors;
|
|
40
|
+
/** Inspection settings negotiated with the Android tunnel server. */
|
|
41
|
+
inspection?: Partial<DestinationTunnelInspectionConfig>;
|
|
42
|
+
/** Called for each validated inspection metadata or body event. */
|
|
43
|
+
onInspectionEvent?: DestinationTunnelInspectionEventCallback;
|
|
44
|
+
/** Called when the independent inspection stream fails or reconnects. */
|
|
45
|
+
onInspectionError?: DestinationTunnelInspectionErrorCallback;
|
|
46
|
+
/** Per-flow receive window in bytes. Defaults to 1 MiB. */
|
|
47
|
+
window?: number;
|
|
48
|
+
/** Controls tunnel logging verbosity. Defaults to the instance client's log level. */
|
|
49
|
+
logLevel?: LogLevel;
|
|
50
|
+
};
|
|
51
|
+
export type { DestinationTunnelStatus } from './internal/destination-tunnel-management';
|
|
52
|
+
|
|
18
53
|
/**
|
|
19
54
|
* Connection state of the instance client
|
|
20
55
|
*/
|
|
@@ -134,6 +169,12 @@ export type InstanceClient = {
|
|
|
134
169
|
* @param packageName Package name of the app to terminate
|
|
135
170
|
*/
|
|
136
171
|
terminateApp: (packageName: string) => Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* List the launchable apps installed on the instance, each with its package
|
|
174
|
+
* name (as `bundleId`), display name, install type (`user` or `system`),
|
|
175
|
+
* and, when available, a small base64 PNG icon.
|
|
176
|
+
*/
|
|
177
|
+
listApps: () => Promise<InstalledApp[]>;
|
|
137
178
|
/**
|
|
138
179
|
* Watch an app's exit/crash without launching it through {@link launchApp},
|
|
139
180
|
* e.g. for apps that are already running or will be opened via a deeplink
|
|
@@ -246,8 +287,11 @@ export type InstanceClient = {
|
|
|
246
287
|
* Start recording device video. Use stopRecording() to finish the recording.
|
|
247
288
|
* When provided, `quality` must be one of `5`, `6`, `7`, `8`, `9`, or `10`.
|
|
248
289
|
* The server default is `5`.
|
|
290
|
+
* With `persist`, the completed recording is uploaded to Limrun's bucket
|
|
291
|
+
* when the recording stops or the instance terminates; list it with
|
|
292
|
+
* `androidInstances.listRecordings`.
|
|
249
293
|
*/
|
|
250
|
-
startRecording: (options?: { quality?: RecordingQuality }) => Promise<void>;
|
|
294
|
+
startRecording: (options?: { quality?: RecordingQuality; persist?: PersistOption }) => Promise<void>;
|
|
251
295
|
/**
|
|
252
296
|
* Stop the active server-side recording.
|
|
253
297
|
* If `saveTo.presignedUrl` is provided, the server uploads the completed file there before resolving.
|
|
@@ -255,6 +299,48 @@ export type InstanceClient = {
|
|
|
255
299
|
* Returns a download URL for the completed recording.
|
|
256
300
|
*/
|
|
257
301
|
stopRecording: (saveTo: { presignedUrl?: string; localPath?: string }) => Promise<string>;
|
|
302
|
+
/**
|
|
303
|
+
* Start capturing an app's logcat output (one JSONL object per line) for
|
|
304
|
+
* the given package name. One app log capture runs at a time; the server
|
|
305
|
+
* rejects a second start. With `persist`, the capture is uploaded to
|
|
306
|
+
* Limrun's bucket when it stops or the instance terminates; list it with
|
|
307
|
+
* `androidInstances.listAppLogs`.
|
|
308
|
+
*/
|
|
309
|
+
startAppLogCapture: (options: { bundleId: string; persist?: PersistOption }) => Promise<void>;
|
|
310
|
+
/** Stop the active app log capture. */
|
|
311
|
+
stopAppLogCapture: () => Promise<void>;
|
|
312
|
+
/**
|
|
313
|
+
* Start capturing the coalesced event log of user and agent actions (one
|
|
314
|
+
* JSONL object per line). One event capture runs at a time; the server
|
|
315
|
+
* rejects a second start. With `persist`, the capture is uploaded to
|
|
316
|
+
* Limrun's bucket when it stops or the instance terminates; list it with
|
|
317
|
+
* `androidInstances.listEvents`.
|
|
318
|
+
*/
|
|
319
|
+
startEventCapture: (options?: { persist?: PersistOption }) => Promise<void>;
|
|
320
|
+
/** Stop the active event capture. */
|
|
321
|
+
stopEventCapture: () => Promise<void>;
|
|
322
|
+
/**
|
|
323
|
+
* Tail the live app log stream fed by {@link startAppLogCapture}. On
|
|
324
|
+
* connect the instance replays its recent buffer (up to 1000 lines), then
|
|
325
|
+
* delivers lines as they are captured; a dropped connection reconnects
|
|
326
|
+
* and resumes exactly where it left off, so every line is delivered once.
|
|
327
|
+
* The stream is independent of this client's websocket and survives
|
|
328
|
+
* {@link disconnect}; `onError` fires at most once, after the stream has
|
|
329
|
+
* kept failing for several minutes (e.g. the instance was deleted), and
|
|
330
|
+
* the stream is closed. Returns a function that closes the stream.
|
|
331
|
+
*/
|
|
332
|
+
streamAppLogCapture: (handlers: {
|
|
333
|
+
onLine: (line: SessionLogLine) => void;
|
|
334
|
+
onError?: (error: Error) => void;
|
|
335
|
+
}) => () => void;
|
|
336
|
+
/**
|
|
337
|
+
* Tail the live coalesced event log fed by {@link startEventCapture}.
|
|
338
|
+
* Same delivery and lifecycle semantics as {@link streamAppLogCapture}.
|
|
339
|
+
*/
|
|
340
|
+
streamEventCapture: (handlers: {
|
|
341
|
+
onEvent: (event: SessionEvent) => void;
|
|
342
|
+
onError?: (error: Error) => void;
|
|
343
|
+
}) => () => void;
|
|
258
344
|
/** Send an application-level keepAlive message on the control websocket. */
|
|
259
345
|
keepAlive: () => void;
|
|
260
346
|
/**
|
|
@@ -267,6 +353,22 @@ export type InstanceClient = {
|
|
|
267
353
|
* Returns the local TCP port and a cleanup function.
|
|
268
354
|
*/
|
|
269
355
|
startAdbTunnel: () => Promise<Tunnel>;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Transparently route declared Android TCP destinations through this
|
|
359
|
+
* machine. Exact `localhost` routes are also reachable on-device as
|
|
360
|
+
* `10.0.2.2:<port>`, following the emulator convention.
|
|
361
|
+
*
|
|
362
|
+
* The caller owns the returned tunnel and must close it. Disconnecting this
|
|
363
|
+
* instance client does not close the tunnel.
|
|
364
|
+
*/
|
|
365
|
+
startTunnel: (options: DestinationTunnelOptions) => Promise<DestinationTunnel>;
|
|
366
|
+
|
|
367
|
+
/** Get the active destination tunnel and most recent terminal failure. */
|
|
368
|
+
getTunnelStatus: () => Promise<DestinationTunnelStatus>;
|
|
369
|
+
|
|
370
|
+
/** Stop the active destination tunnel only when its ID matches `tunnelId`. */
|
|
371
|
+
stopTunnel: (tunnelId: string) => Promise<void>;
|
|
270
372
|
/**
|
|
271
373
|
* Send an asset URL to the instance. The instance will download the asset
|
|
272
374
|
* and process it (currently APK install is supported). Resolves on success,
|
|
@@ -517,6 +619,18 @@ export type LaunchAppResult = {
|
|
|
517
619
|
packageName: string;
|
|
518
620
|
};
|
|
519
621
|
|
|
622
|
+
/**
|
|
623
|
+
* One entry of {@link InstanceClient.listApps}. The package name is carried
|
|
624
|
+
* as `bundleId` so the shape matches iOS's listApps.
|
|
625
|
+
*/
|
|
626
|
+
export type InstalledApp = {
|
|
627
|
+
bundleId: string;
|
|
628
|
+
name: string;
|
|
629
|
+
installType: string;
|
|
630
|
+
/** App icon as a base64-encoded 64px PNG; absent when the runtime could not extract one. */
|
|
631
|
+
icon?: string;
|
|
632
|
+
};
|
|
633
|
+
|
|
520
634
|
/** Handle for an app watch registered via {@link InstanceClient.watchApp}. */
|
|
521
635
|
export type AppWatch = {
|
|
522
636
|
/** Identifier of this watch on the server. */
|
|
@@ -703,6 +817,13 @@ type WatchAppResultMessage = {
|
|
|
703
817
|
error?: CommandError;
|
|
704
818
|
};
|
|
705
819
|
|
|
820
|
+
type ListAppsResultMessage = {
|
|
821
|
+
type: 'listAppsResult';
|
|
822
|
+
id: string;
|
|
823
|
+
payload?: { apps?: InstalledApp[] };
|
|
824
|
+
error?: CommandError;
|
|
825
|
+
};
|
|
826
|
+
|
|
706
827
|
type UnwatchAppResultMessage = {
|
|
707
828
|
type: 'unwatchAppResult';
|
|
708
829
|
id: string;
|
|
@@ -770,6 +891,34 @@ type StopVideoRecordingResultMessage = {
|
|
|
770
891
|
error?: CommandError;
|
|
771
892
|
};
|
|
772
893
|
|
|
894
|
+
type StartAppLogCaptureResultMessage = {
|
|
895
|
+
type: 'startAppLogCaptureResult';
|
|
896
|
+
id: string;
|
|
897
|
+
payload?: EmptyCommandResult;
|
|
898
|
+
error?: CommandError;
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
type StopAppLogCaptureResultMessage = {
|
|
902
|
+
type: 'stopAppLogCaptureResult';
|
|
903
|
+
id: string;
|
|
904
|
+
payload?: EmptyCommandResult;
|
|
905
|
+
error?: CommandError;
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
type StartEventCaptureResultMessage = {
|
|
909
|
+
type: 'startEventCaptureResult';
|
|
910
|
+
id: string;
|
|
911
|
+
payload?: EmptyCommandResult;
|
|
912
|
+
error?: CommandError;
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
type StopEventCaptureResultMessage = {
|
|
916
|
+
type: 'stopEventCaptureResult';
|
|
917
|
+
id: string;
|
|
918
|
+
payload?: EmptyCommandResult;
|
|
919
|
+
error?: CommandError;
|
|
920
|
+
};
|
|
921
|
+
|
|
773
922
|
type KnownCommandResultMessage =
|
|
774
923
|
| ScreenshotResultMessage
|
|
775
924
|
| GetElementTreeResultMessage
|
|
@@ -784,12 +933,17 @@ type KnownCommandResultMessage =
|
|
|
784
933
|
| TerminateAppResultMessage
|
|
785
934
|
| WatchAppResultMessage
|
|
786
935
|
| UnwatchAppResultMessage
|
|
936
|
+
| ListAppsResultMessage
|
|
787
937
|
| PlayOnMicrophoneResultMessage
|
|
788
938
|
| CameraControlResultMessage
|
|
789
939
|
| AdbShellResultMessage
|
|
790
940
|
| SetWifiBandwidthResultMessage
|
|
791
941
|
| StartVideoRecordingResultMessage
|
|
792
|
-
| StopVideoRecordingResultMessage
|
|
942
|
+
| StopVideoRecordingResultMessage
|
|
943
|
+
| StartAppLogCaptureResultMessage
|
|
944
|
+
| StopAppLogCaptureResultMessage
|
|
945
|
+
| StartEventCaptureResultMessage
|
|
946
|
+
| StopEventCaptureResultMessage;
|
|
793
947
|
|
|
794
948
|
type ServerMessage =
|
|
795
949
|
| ScreenshotResponse
|
|
@@ -812,12 +966,17 @@ type CommandRequestMap = {
|
|
|
812
966
|
terminateApp: { packageName: string };
|
|
813
967
|
watchApp: { packageName: string; execId: string };
|
|
814
968
|
unwatchApp: { execId: string };
|
|
969
|
+
listApps: {};
|
|
815
970
|
playOnMicrophone: { path: string; once?: boolean };
|
|
816
971
|
cameraControl: { action: 'setSource'; source: 'video'; arg: string; loop?: boolean } | { action: 'reset' };
|
|
817
972
|
adbShell: { command: string; timeoutMs?: number };
|
|
818
973
|
setWifiBandwidth: WifiBandwidthOptions;
|
|
819
|
-
startRecording: { quality?: RecordingQuality };
|
|
974
|
+
startRecording: { quality?: RecordingQuality; persist?: boolean; ttlSeconds?: number };
|
|
820
975
|
stopRecording: { upload?: { presignedUrl: string } };
|
|
976
|
+
startAppLogCapture: { bundleId: string; persist?: boolean; ttlSeconds?: number };
|
|
977
|
+
stopAppLogCapture: {};
|
|
978
|
+
startEventCapture: { persist?: boolean; ttlSeconds?: number };
|
|
979
|
+
stopEventCapture: {};
|
|
821
980
|
};
|
|
822
981
|
|
|
823
982
|
type CommandResultMap = {
|
|
@@ -834,12 +993,17 @@ type CommandResultMap = {
|
|
|
834
993
|
terminateApp: { packageName?: string };
|
|
835
994
|
watchApp: { packageName?: string };
|
|
836
995
|
unwatchApp: EmptyCommandResult;
|
|
996
|
+
listApps: { apps?: InstalledApp[] };
|
|
837
997
|
playOnMicrophone: PlayOnMicrophoneResult;
|
|
838
998
|
cameraControl: EmptyCommandResult;
|
|
839
999
|
adbShell: AdbShellResultMessage;
|
|
840
1000
|
setWifiBandwidth: EmptyCommandResult;
|
|
841
1001
|
startRecording: EmptyCommandResult;
|
|
842
1002
|
stopRecording: EmptyCommandResult;
|
|
1003
|
+
startAppLogCapture: EmptyCommandResult;
|
|
1004
|
+
stopAppLogCapture: EmptyCommandResult;
|
|
1005
|
+
startEventCapture: EmptyCommandResult;
|
|
1006
|
+
stopEventCapture: EmptyCommandResult;
|
|
843
1007
|
};
|
|
844
1008
|
|
|
845
1009
|
type PendingRequest<T> = {
|
|
@@ -1015,12 +1179,17 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1015
1179
|
case 'terminateAppResult':
|
|
1016
1180
|
case 'watchAppResult':
|
|
1017
1181
|
case 'unwatchAppResult':
|
|
1182
|
+
case 'listAppsResult':
|
|
1018
1183
|
case 'playOnMicrophoneResult':
|
|
1019
1184
|
case 'cameraControlResult':
|
|
1020
1185
|
case 'adbShellResult':
|
|
1021
1186
|
case 'setWifiBandwidthResult':
|
|
1022
1187
|
case 'startRecordingResult':
|
|
1023
1188
|
case 'stopRecordingResult':
|
|
1189
|
+
case 'startAppLogCaptureResult':
|
|
1190
|
+
case 'stopAppLogCaptureResult':
|
|
1191
|
+
case 'startEventCaptureResult':
|
|
1192
|
+
case 'stopEventCaptureResult':
|
|
1024
1193
|
return 'id' in message && typeof message.id === 'string';
|
|
1025
1194
|
default:
|
|
1026
1195
|
return false;
|
|
@@ -1287,6 +1456,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1287
1456
|
openUrl,
|
|
1288
1457
|
launchApp,
|
|
1289
1458
|
terminateApp,
|
|
1459
|
+
listApps,
|
|
1290
1460
|
watchApp,
|
|
1291
1461
|
playOnMicrophone,
|
|
1292
1462
|
pushFile,
|
|
@@ -1297,9 +1467,18 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1297
1467
|
setWifiBandwidth,
|
|
1298
1468
|
startRecording,
|
|
1299
1469
|
stopRecording,
|
|
1470
|
+
startAppLogCapture,
|
|
1471
|
+
stopAppLogCapture,
|
|
1472
|
+
startEventCapture,
|
|
1473
|
+
stopEventCapture,
|
|
1474
|
+
streamAppLogCapture,
|
|
1475
|
+
streamEventCapture,
|
|
1300
1476
|
keepAlive,
|
|
1301
1477
|
disconnect,
|
|
1302
1478
|
startAdbTunnel,
|
|
1479
|
+
startTunnel,
|
|
1480
|
+
getTunnelStatus,
|
|
1481
|
+
stopTunnel,
|
|
1303
1482
|
sendAsset,
|
|
1304
1483
|
syncApp,
|
|
1305
1484
|
getConnectionState,
|
|
@@ -1435,6 +1614,11 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1435
1614
|
await sendRequest('terminateApp', { packageName });
|
|
1436
1615
|
};
|
|
1437
1616
|
|
|
1617
|
+
const listApps = async (): Promise<InstalledApp[]> => {
|
|
1618
|
+
const result = await sendRequest('listApps', {});
|
|
1619
|
+
return result.apps ?? [];
|
|
1620
|
+
};
|
|
1621
|
+
|
|
1438
1622
|
const watchApp = async (packageName: string, onExit: LaunchAppExitCallback): Promise<AppWatch> => {
|
|
1439
1623
|
const { execId } = await withExitCallback('watch', onExit, (execId) =>
|
|
1440
1624
|
sendRequest('watchApp', { packageName, execId }),
|
|
@@ -1551,8 +1735,13 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1551
1735
|
await sendRequest('setWifiBandwidth', request);
|
|
1552
1736
|
};
|
|
1553
1737
|
|
|
1554
|
-
const startRecording = async (recordingOptions?: {
|
|
1555
|
-
|
|
1738
|
+
const startRecording = async (recordingOptions?: {
|
|
1739
|
+
quality?: RecordingQuality;
|
|
1740
|
+
persist?: PersistOption;
|
|
1741
|
+
}): Promise<void> => {
|
|
1742
|
+
const request: CommandRequestMap['startRecording'] = {
|
|
1743
|
+
...persistFields(recordingOptions?.persist),
|
|
1744
|
+
};
|
|
1556
1745
|
if (recordingOptions?.quality !== undefined) {
|
|
1557
1746
|
if (
|
|
1558
1747
|
!Number.isInteger(recordingOptions.quality) ||
|
|
@@ -1579,6 +1768,53 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1579
1768
|
return downloadUrl;
|
|
1580
1769
|
};
|
|
1581
1770
|
|
|
1771
|
+
const startAppLogCapture = async (captureOptions: {
|
|
1772
|
+
bundleId: string;
|
|
1773
|
+
persist?: PersistOption;
|
|
1774
|
+
}): Promise<void> => {
|
|
1775
|
+
if (!captureOptions.bundleId) {
|
|
1776
|
+
throw new Error('bundleId must be a non-empty string');
|
|
1777
|
+
}
|
|
1778
|
+
await sendRequest('startAppLogCapture', {
|
|
1779
|
+
bundleId: captureOptions.bundleId,
|
|
1780
|
+
...persistFields(captureOptions.persist),
|
|
1781
|
+
});
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
const stopAppLogCapture = async (): Promise<void> => {
|
|
1785
|
+
await sendRequest('stopAppLogCapture', {});
|
|
1786
|
+
};
|
|
1787
|
+
|
|
1788
|
+
const startEventCapture = async (captureOptions?: { persist?: PersistOption }): Promise<void> => {
|
|
1789
|
+
await sendRequest('startEventCapture', { ...persistFields(captureOptions?.persist) });
|
|
1790
|
+
};
|
|
1791
|
+
|
|
1792
|
+
const stopEventCapture = async (): Promise<void> => {
|
|
1793
|
+
await sendRequest('stopEventCapture', {});
|
|
1794
|
+
};
|
|
1795
|
+
|
|
1796
|
+
const streamAppLogCapture = (handlers: {
|
|
1797
|
+
onLine: (line: SessionLogLine) => void;
|
|
1798
|
+
onError?: (error: Error) => void;
|
|
1799
|
+
}): (() => void) =>
|
|
1800
|
+
streamSessionEntries<SessionLogLine>({
|
|
1801
|
+
url: `${options.apiUrl}/session/applogs/events`,
|
|
1802
|
+
token: options.token,
|
|
1803
|
+
onEntry: handlers.onLine,
|
|
1804
|
+
onError: handlers.onError,
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
const streamEventCapture = (handlers: {
|
|
1808
|
+
onEvent: (event: SessionEvent) => void;
|
|
1809
|
+
onError?: (error: Error) => void;
|
|
1810
|
+
}): (() => void) =>
|
|
1811
|
+
streamSessionEntries<SessionEvent>({
|
|
1812
|
+
url: `${options.apiUrl}/session/events/events`,
|
|
1813
|
+
token: options.token,
|
|
1814
|
+
onEntry: handlers.onEvent,
|
|
1815
|
+
onError: handlers.onError,
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1582
1818
|
const keepAlive = (): void => {
|
|
1583
1819
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
1584
1820
|
return;
|
|
@@ -1643,6 +1879,36 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1643
1879
|
return tunnel;
|
|
1644
1880
|
};
|
|
1645
1881
|
|
|
1882
|
+
const requireAdbUrl = (): string => {
|
|
1883
|
+
if (!options.adbUrl) {
|
|
1884
|
+
throw new Error('adbUrl is required to manage a destination tunnel.');
|
|
1885
|
+
}
|
|
1886
|
+
return options.adbUrl;
|
|
1887
|
+
};
|
|
1888
|
+
|
|
1889
|
+
const startTunnel = async (tunnelOptions: DestinationTunnelOptions): Promise<DestinationTunnel> => {
|
|
1890
|
+
return startDestinationTcpTunnel(deriveDestinationTunnelURL(requireAdbUrl()), options.token, {
|
|
1891
|
+
selectors: tunnelOptions.selectors,
|
|
1892
|
+
inspection: {
|
|
1893
|
+
enabled: true,
|
|
1894
|
+
captureBodies: false,
|
|
1895
|
+
...(tunnelOptions.inspection ?? {}),
|
|
1896
|
+
},
|
|
1897
|
+
...(tunnelOptions.onInspectionEvent ? { onInspectionEvent: tunnelOptions.onInspectionEvent } : {}),
|
|
1898
|
+
...(tunnelOptions.onInspectionError ? { onInspectionError: tunnelOptions.onInspectionError } : {}),
|
|
1899
|
+
...(tunnelOptions.window === undefined ? {} : { window: tunnelOptions.window }),
|
|
1900
|
+
logLevel: tunnelOptions.logLevel ?? logLevel,
|
|
1901
|
+
});
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
const getTunnelStatus = async (): Promise<DestinationTunnelStatus> => {
|
|
1905
|
+
return getDestinationTunnelStatus(requireAdbUrl(), options.token);
|
|
1906
|
+
};
|
|
1907
|
+
|
|
1908
|
+
const stopTunnel = async (tunnelId: string): Promise<void> => {
|
|
1909
|
+
await stopDestinationTunnel(requireAdbUrl(), options.token, tunnelId);
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1646
1912
|
const sendAsset = async (url: string, timeoutMs?: number): Promise<void> => {
|
|
1647
1913
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
1648
1914
|
return Promise.reject(new Error('WebSocket is not connected or connection is not open.'));
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { nodeProxyTransport } from './proxy-transport';
|
|
2
2
|
import { deriveDestinationTunnelStatusURL, deriveDestinationTunnelStopURL } from './destination-tunnel-url';
|
|
3
|
-
import {
|
|
3
|
+
import { readArray, readNonEmptyString, readRecord, readString } from './destination-tunnel-wire-reader';
|
|
4
|
+
import {
|
|
5
|
+
normalizeDestinationTunnelInspection,
|
|
6
|
+
type DestinationTunnelBindReport,
|
|
7
|
+
type DestinationTunnelInspectionConfig,
|
|
8
|
+
type DestinationTunnelSelectorReport,
|
|
9
|
+
} from '../destination-tunnel';
|
|
4
10
|
|
|
5
11
|
export interface DestinationTunnelStatus {
|
|
6
12
|
active?: {
|
|
7
13
|
tunnelId: string;
|
|
8
14
|
state: 'starting' | 'ready' | 'stopping';
|
|
9
|
-
|
|
15
|
+
selectors: DestinationTunnelSelectorReport[];
|
|
16
|
+
inspection: DestinationTunnelInspectionConfig;
|
|
10
17
|
};
|
|
11
18
|
lastFailure?: {
|
|
12
19
|
tunnelId: string;
|
|
@@ -15,7 +22,7 @@ export interface DestinationTunnelStatus {
|
|
|
15
22
|
lastDialFailure?: {
|
|
16
23
|
tunnelId: string;
|
|
17
24
|
connectionId: number;
|
|
18
|
-
|
|
25
|
+
selectorId: string;
|
|
19
26
|
reason: string;
|
|
20
27
|
osCode?: string;
|
|
21
28
|
};
|
|
@@ -74,19 +81,67 @@ function readActiveTunnel(value: unknown): NonNullable<DestinationTunnelStatus['
|
|
|
74
81
|
return {
|
|
75
82
|
tunnelId: readNonEmptyString(active, 'tunnelId'),
|
|
76
83
|
state,
|
|
77
|
-
|
|
84
|
+
selectors: readArray(active, 'selectors').map((selector, index) =>
|
|
85
|
+
readSelectorReport(selector, `selector-${index + 1}`),
|
|
86
|
+
),
|
|
87
|
+
inspection: readInspection(active),
|
|
78
88
|
};
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
function
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
function readInspection(active: Record<string, unknown>): DestinationTunnelInspectionConfig {
|
|
92
|
+
const inspection = readRecord(active['inspection'], 'inspection');
|
|
93
|
+
const enabled = inspection['enabled'];
|
|
94
|
+
const captureBodies = inspection['captureBodies'];
|
|
95
|
+
const persist = inspection['persist'];
|
|
96
|
+
if (typeof enabled !== 'boolean' || typeof captureBodies !== 'boolean' || typeof persist !== 'boolean') {
|
|
97
|
+
throw new Error('inspection enabled, captureBodies, and persist must be booleans');
|
|
98
|
+
}
|
|
99
|
+
const maxBodyBytes = inspection['maxBodyBytes'];
|
|
100
|
+
const ttlSeconds = inspection['ttlSeconds'];
|
|
101
|
+
if (typeof maxBodyBytes !== 'number' || typeof ttlSeconds !== 'number') {
|
|
102
|
+
throw new Error('inspection maxBodyBytes and ttlSeconds must be numbers');
|
|
103
|
+
}
|
|
104
|
+
return normalizeDestinationTunnelInspection({
|
|
105
|
+
enabled,
|
|
106
|
+
captureBodies,
|
|
107
|
+
maxBodyBytes,
|
|
108
|
+
persist,
|
|
109
|
+
ttlSeconds,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readSelectorReport(value: unknown, expectedId: string): DestinationTunnelSelectorReport {
|
|
114
|
+
const report = readRecord(value, 'tunnel selector');
|
|
115
|
+
const id = readNonEmptyString(report, 'id');
|
|
116
|
+
if (id !== expectedId) {
|
|
117
|
+
throw new Error(`invalid tunnel selector id ${id}`);
|
|
118
|
+
}
|
|
119
|
+
const kind = readString(report, 'kind');
|
|
120
|
+
if (kind !== 'route' && kind !== 'domain') {
|
|
121
|
+
throw new Error(`invalid tunnel selector kind ${kind}`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
id,
|
|
125
|
+
kind,
|
|
126
|
+
value: readNonEmptyString(report, 'value'),
|
|
127
|
+
...(report['binds'] === undefined ? {} : { binds: readArray(report, 'binds').map(readBindReport) }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readBindReport(value: unknown): DestinationTunnelBindReport {
|
|
132
|
+
const bind = readRecord(value, 'tunnel bind report');
|
|
133
|
+
const status = readString(bind, 'status');
|
|
134
|
+
if (status !== 'ok' && status !== 'conflict' && status !== 'error') {
|
|
135
|
+
throw new Error(`invalid tunnel bind status ${status}`);
|
|
136
|
+
}
|
|
137
|
+
const osCode = bind['osCode'];
|
|
138
|
+
if (osCode !== undefined && typeof osCode !== 'string') {
|
|
139
|
+
throw new Error('tunnel bind osCode must be a string');
|
|
86
140
|
}
|
|
87
141
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
142
|
+
address: readNonEmptyString(bind, 'address'),
|
|
143
|
+
status,
|
|
144
|
+
...(osCode === undefined ? {} : { osCode }),
|
|
90
145
|
};
|
|
91
146
|
}
|
|
92
147
|
|
|
@@ -116,39 +171,8 @@ function readTunnelDialFailure(value: unknown): NonNullable<DestinationTunnelSta
|
|
|
116
171
|
return {
|
|
117
172
|
tunnelId: readNonEmptyString(failure, 'tunnelId'),
|
|
118
173
|
connectionId,
|
|
119
|
-
|
|
174
|
+
selectorId: readNonEmptyString(failure, 'selectorId'),
|
|
120
175
|
reason: readNonEmptyString(failure, 'reason'),
|
|
121
176
|
...(osCode === undefined ? {} : { osCode }),
|
|
122
177
|
};
|
|
123
178
|
}
|
|
124
|
-
|
|
125
|
-
function readRecord(value: unknown, name: string): Record<string, unknown> {
|
|
126
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
127
|
-
throw new Error(`${name} must be an object`);
|
|
128
|
-
}
|
|
129
|
-
return value as Record<string, unknown>;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function readArray(record: Record<string, unknown>, key: string): unknown[] {
|
|
133
|
-
const value = record[key];
|
|
134
|
-
if (!Array.isArray(value)) {
|
|
135
|
-
throw new Error(`${key} must be an array`);
|
|
136
|
-
}
|
|
137
|
-
return value;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function readString(record: Record<string, unknown>, key: string): string {
|
|
141
|
-
const value = record[key];
|
|
142
|
-
if (typeof value !== 'string') {
|
|
143
|
-
throw new Error(`${key} must be a string`);
|
|
144
|
-
}
|
|
145
|
-
return value;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function readNonEmptyString(record: Record<string, unknown>, key: string): string {
|
|
149
|
-
const value = readString(record, key);
|
|
150
|
-
if (value.length === 0) {
|
|
151
|
-
throw new Error(`${key} must not be empty`);
|
|
152
|
-
}
|
|
153
|
-
return value;
|
|
154
|
-
}
|