@limrun/api 0.23.2 → 0.24.1
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 +20 -0
- package/client.d.mts +4 -0
- package/client.d.mts.map +1 -1
- package/client.d.ts +4 -0
- package/client.d.ts.map +1 -1
- package/client.js +3 -0
- package/client.js.map +1 -1
- package/client.mjs +3 -0
- package/client.mjs.map +1 -1
- package/exec-client.d.mts +3 -2
- package/exec-client.d.mts.map +1 -1
- package/exec-client.d.ts +3 -2
- package/exec-client.d.ts.map +1 -1
- package/exec-client.js +10 -3
- package/exec-client.js.map +1 -1
- package/exec-client.mjs +10 -3
- package/exec-client.mjs.map +1 -1
- package/folder-sync.d.mts.map +1 -1
- package/folder-sync.d.ts.map +1 -1
- package/folder-sync.js +5 -2
- package/folder-sync.js.map +1 -1
- package/folder-sync.mjs +5 -2
- package/folder-sync.mjs.map +1 -1
- package/index.d.mts +1 -1
- package/index.d.mts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +1 -3
- package/index.js.map +1 -1
- package/index.mjs +0 -1
- package/index.mjs.map +1 -1
- package/instance-client.d.mts +31 -4
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts +31 -4
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +61 -2
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +60 -2
- package/instance-client.mjs.map +1 -1
- package/internal/download-file.d.mts +2 -0
- package/internal/download-file.d.mts.map +1 -0
- package/internal/download-file.d.ts +2 -0
- package/internal/download-file.d.ts.map +1 -0
- package/internal/download-file.js +35 -0
- package/internal/download-file.js.map +1 -0
- package/internal/download-file.mjs +31 -0
- package/internal/download-file.mjs.map +1 -0
- package/internal/proxy-transport.d.mts +14 -0
- package/internal/proxy-transport.d.mts.map +1 -0
- package/internal/proxy-transport.d.ts +14 -0
- package/internal/proxy-transport.d.ts.map +1 -0
- package/internal/proxy-transport.js +59 -0
- package/internal/proxy-transport.js.map +1 -0
- package/internal/proxy-transport.mjs +56 -0
- package/internal/proxy-transport.mjs.map +1 -0
- package/internal/shims.d.mts.map +1 -1
- package/internal/shims.d.ts.map +1 -1
- package/internal/shims.js +2 -1
- package/internal/shims.js.map +1 -1
- package/internal/shims.mjs +2 -1
- package/internal/shims.mjs.map +1 -1
- package/internal/tslib.js +4 -4
- package/internal/utils/env.js +2 -2
- package/internal/utils/env.js.map +1 -1
- package/internal/utils/env.mjs +2 -2
- package/internal/utils/env.mjs.map +1 -1
- package/ios-client.d.mts +13 -1
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +13 -1
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +44 -65
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +42 -63
- package/ios-client.mjs.map +1 -1
- package/package.json +4 -11
- package/resources/android-instances.d.mts +5 -4
- package/resources/android-instances.d.mts.map +1 -1
- package/resources/android-instances.d.ts +5 -4
- package/resources/android-instances.d.ts.map +1 -1
- package/resources/assets-helpers.d.mts.map +1 -1
- package/resources/assets-helpers.d.ts.map +1 -1
- package/resources/assets-helpers.js +2 -1
- package/resources/assets-helpers.js.map +1 -1
- package/resources/assets-helpers.mjs +2 -1
- package/resources/assets-helpers.mjs.map +1 -1
- package/resources/index.d.mts +2 -0
- package/resources/index.d.mts.map +1 -1
- package/resources/index.d.ts +2 -0
- package/resources/index.d.ts.map +1 -1
- package/resources/index.js +3 -1
- package/resources/index.js.map +1 -1
- package/resources/index.mjs +1 -0
- package/resources/index.mjs.map +1 -1
- package/resources/ios-instances.d.mts +4 -4
- package/resources/ios-instances.d.ts +4 -4
- package/resources/xcode-instances-helpers.d.mts +76 -0
- package/resources/xcode-instances-helpers.d.mts.map +1 -0
- package/resources/xcode-instances-helpers.d.ts +76 -0
- package/resources/xcode-instances-helpers.d.ts.map +1 -0
- package/resources/xcode-instances-helpers.js +150 -0
- package/resources/xcode-instances-helpers.js.map +1 -0
- package/resources/xcode-instances-helpers.mjs +145 -0
- package/resources/xcode-instances-helpers.mjs.map +1 -0
- package/resources/xcode-instances.d.mts +122 -0
- package/resources/xcode-instances.d.mts.map +1 -0
- package/resources/xcode-instances.d.ts +122 -0
- package/resources/xcode-instances.d.ts.map +1 -0
- package/resources/xcode-instances.js +40 -0
- package/resources/xcode-instances.js.map +1 -0
- package/resources/xcode-instances.mjs +36 -0
- package/resources/xcode-instances.mjs.map +1 -0
- package/src/client.ts +27 -0
- package/src/exec-client.ts +12 -5
- package/src/folder-sync.ts +15 -12
- package/src/index.ts +6 -9
- package/src/instance-client.ts +107 -8
- package/src/internal/download-file.ts +33 -0
- package/src/internal/proxy-transport.ts +69 -0
- package/src/internal/shims.ts +2 -1
- package/src/internal/utils/env.ts +2 -2
- package/src/ios-client.ts +45 -67
- package/src/resources/android-instances.ts +6 -4
- package/src/resources/assets-helpers.ts +2 -1
- package/src/resources/index.ts +13 -0
- package/src/resources/ios-instances.ts +4 -4
- package/src/resources/xcode-instances-helpers.ts +228 -0
- package/src/resources/xcode-instances.ts +177 -0
- package/src/tunnel.ts +5 -0
- package/src/version.ts +1 -1
- package/tunnel.d.mts.map +1 -1
- package/tunnel.d.ts.map +1 -1
- package/tunnel.js +5 -0
- package/tunnel.js.map +1 -1
- package/tunnel.mjs +5 -0
- package/tunnel.mjs.map +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/sandbox-client.d.mts +0 -129
- package/sandbox-client.d.mts.map +0 -1
- package/sandbox-client.d.ts +0 -129
- package/sandbox-client.d.ts.map +0 -1
- package/sandbox-client.js +0 -158
- package/sandbox-client.js.map +0 -1
- package/sandbox-client.mjs +0 -154
- package/sandbox-client.mjs.map +0 -1
- package/src/sandbox-client.ts +0 -277
package/src/instance-client.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { WebSocket, Data } from 'ws';
|
|
2
2
|
import { exec } from 'node:child_process';
|
|
3
3
|
|
|
4
|
+
import { downloadFileToLocalPath } from './internal/download-file';
|
|
5
|
+
import { nodeProxyTransport } from './internal/proxy-transport';
|
|
4
6
|
import { startTcpTunnel, isNonRetryableError } from './tunnel';
|
|
5
7
|
import type { Tunnel } from './tunnel';
|
|
6
8
|
|
|
9
|
+
const ANDROID_RECORDING_PATH = '/data/local/tmp/recordings/video_recording.mp4';
|
|
10
|
+
const ANDROID_SIGNALING_PATH = '/ws';
|
|
11
|
+
|
|
7
12
|
/**
|
|
8
13
|
* Connection state of the instance client
|
|
9
14
|
*/
|
|
@@ -14,6 +19,19 @@ export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'rec
|
|
|
14
19
|
*/
|
|
15
20
|
export type ConnectionStateCallback = (state: ConnectionState) => void;
|
|
16
21
|
|
|
22
|
+
function deriveEndpointWebSocketUrl(apiUrl: string): string {
|
|
23
|
+
const parsed = new URL(apiUrl);
|
|
24
|
+
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
25
|
+
parsed.search = '';
|
|
26
|
+
parsed.hash = '';
|
|
27
|
+
parsed.pathname = `${parsed.pathname.replace(/\/$/, '')}${ANDROID_SIGNALING_PATH}`;
|
|
28
|
+
return parsed.toString().replace(/\/$/, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildDownloadUrl(apiUrl: string): string {
|
|
32
|
+
return `${apiUrl}/files?path=${encodeURIComponent(ANDROID_RECORDING_PATH)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
/**
|
|
18
36
|
* A client for interacting with a Limbar instance
|
|
19
37
|
*/
|
|
@@ -64,6 +82,19 @@ export type InstanceClient = {
|
|
|
64
82
|
* Open a URL/deeplink on Android.
|
|
65
83
|
*/
|
|
66
84
|
openUrl: (url: string) => Promise<OpenUrlResult>;
|
|
85
|
+
/**
|
|
86
|
+
* Start recording device video. Use stopRecording() to finish the recording.
|
|
87
|
+
* When provided, `quality` must be one of `5`, `6`, `7`, `8`, `9`, or `10`.
|
|
88
|
+
* The server default is `5`.
|
|
89
|
+
*/
|
|
90
|
+
startRecording: (options?: { quality?: RecordingQuality }) => Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Stop the active server-side recording.
|
|
93
|
+
* If `saveTo.presignedUrl` is provided, the server uploads the completed file there before resolving.
|
|
94
|
+
* If `saveTo.localPath` is provided, the client downloads the completed file to that path.
|
|
95
|
+
* Returns a download URL for the completed recording.
|
|
96
|
+
*/
|
|
97
|
+
stopRecording: (saveTo: { presignedUrl?: string; localPath?: string }) => Promise<string>;
|
|
67
98
|
/**
|
|
68
99
|
* Disconnect from the Limbar instance
|
|
69
100
|
*/
|
|
@@ -99,6 +130,14 @@ export type InstanceClient = {
|
|
|
99
130
|
export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
|
|
100
131
|
|
|
101
132
|
export type ScrollDirection = 'up' | 'down' | 'left' | 'right';
|
|
133
|
+
export enum RecordingQuality {
|
|
134
|
+
Q5 = 5,
|
|
135
|
+
Q6 = 6,
|
|
136
|
+
Q7 = 7,
|
|
137
|
+
Q8 = 8,
|
|
138
|
+
Q9 = 9,
|
|
139
|
+
Q10 = 10,
|
|
140
|
+
}
|
|
102
141
|
|
|
103
142
|
export type AndroidSelector = {
|
|
104
143
|
resourceId?: string;
|
|
@@ -151,13 +190,14 @@ export type AndroidElementNode = {
|
|
|
151
190
|
*/
|
|
152
191
|
export type InstanceClientOptions = {
|
|
153
192
|
/**
|
|
154
|
-
*
|
|
193
|
+
* HTTP base URL for the Android daemon. WebSocket control is derived from it
|
|
194
|
+
* using the `/ws` path, and recording downloads use the same base URL.
|
|
155
195
|
*/
|
|
156
|
-
|
|
196
|
+
apiUrl: string;
|
|
157
197
|
/**
|
|
158
|
-
* The URL of the
|
|
198
|
+
* The URL of the ADB WebSocket endpoint.
|
|
159
199
|
*/
|
|
160
|
-
|
|
200
|
+
adbUrl?: string;
|
|
161
201
|
/**
|
|
162
202
|
* The token to use for the WebSocket connections.
|
|
163
203
|
*/
|
|
@@ -234,6 +274,8 @@ export type OpenUrlResult = {
|
|
|
234
274
|
url: string;
|
|
235
275
|
};
|
|
236
276
|
|
|
277
|
+
type EmptyCommandResult = Record<string, never>;
|
|
278
|
+
|
|
237
279
|
type ScreenshotErrorResponse = {
|
|
238
280
|
type: 'screenshotError';
|
|
239
281
|
message: string;
|
|
@@ -321,6 +363,20 @@ type OpenUrlResultMessage = {
|
|
|
321
363
|
error?: CommandError;
|
|
322
364
|
};
|
|
323
365
|
|
|
366
|
+
type StartVideoRecordingResultMessage = {
|
|
367
|
+
type: 'startRecordingResult';
|
|
368
|
+
id: string;
|
|
369
|
+
payload?: EmptyCommandResult;
|
|
370
|
+
error?: CommandError;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
type StopVideoRecordingResultMessage = {
|
|
374
|
+
type: 'stopRecordingResult';
|
|
375
|
+
id: string;
|
|
376
|
+
payload?: EmptyCommandResult;
|
|
377
|
+
error?: CommandError;
|
|
378
|
+
};
|
|
379
|
+
|
|
324
380
|
type KnownCommandResultMessage =
|
|
325
381
|
| ScreenshotResultMessage
|
|
326
382
|
| GetElementTreeResultMessage
|
|
@@ -330,7 +386,9 @@ type KnownCommandResultMessage =
|
|
|
330
386
|
| PressKeyResultMessage
|
|
331
387
|
| ScrollScreenResultMessage
|
|
332
388
|
| ScrollElementResultMessage
|
|
333
|
-
| OpenUrlResultMessage
|
|
389
|
+
| OpenUrlResultMessage
|
|
390
|
+
| StartVideoRecordingResultMessage
|
|
391
|
+
| StopVideoRecordingResultMessage;
|
|
334
392
|
|
|
335
393
|
type ServerMessage =
|
|
336
394
|
| ScreenshotResponse
|
|
@@ -349,6 +407,8 @@ type CommandRequestMap = {
|
|
|
349
407
|
scrollScreen: { direction: ScrollDirection; amount?: number };
|
|
350
408
|
scrollElement: AndroidElementTarget & { direction: ScrollDirection; amount?: number };
|
|
351
409
|
openUrl: { url: string };
|
|
410
|
+
startRecording: { quality?: RecordingQuality };
|
|
411
|
+
stopRecording: { upload?: { presignedUrl: string } };
|
|
352
412
|
};
|
|
353
413
|
|
|
354
414
|
type CommandResultMap = {
|
|
@@ -361,6 +421,8 @@ type CommandResultMap = {
|
|
|
361
421
|
scrollScreen: ScrollResult;
|
|
362
422
|
scrollElement: ScrollResult;
|
|
363
423
|
openUrl: OpenUrlResult;
|
|
424
|
+
startRecording: EmptyCommandResult;
|
|
425
|
+
stopRecording: EmptyCommandResult;
|
|
364
426
|
};
|
|
365
427
|
|
|
366
428
|
type PendingRequest<T> = {
|
|
@@ -375,7 +437,9 @@ type PendingRequest<T> = {
|
|
|
375
437
|
* @returns An InstanceClient for controlling the instance
|
|
376
438
|
*/
|
|
377
439
|
export async function createInstanceClient(options: InstanceClientOptions): Promise<InstanceClient> {
|
|
378
|
-
const
|
|
440
|
+
const endpointWebSocketUrl = deriveEndpointWebSocketUrl(options.apiUrl);
|
|
441
|
+
const serverAddress = `${endpointWebSocketUrl}?token=${options.token}`;
|
|
442
|
+
const recordingApiUrl = options.apiUrl;
|
|
379
443
|
const logLevel = options.logLevel ?? 'info';
|
|
380
444
|
const maxReconnectAttempts = options.maxReconnectAttempts ?? 6;
|
|
381
445
|
const reconnectDelay = options.reconnectDelay ?? 1000;
|
|
@@ -387,7 +451,6 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
387
451
|
let reconnectTimeout: NodeJS.Timeout | undefined;
|
|
388
452
|
let intentionalDisconnect = false;
|
|
389
453
|
let lastError: string | undefined;
|
|
390
|
-
|
|
391
454
|
const pendingRequests: Map<string, PendingRequest<unknown>> = new Map();
|
|
392
455
|
const pendingAssetRequestsByUrl: Map<string, Array<PendingRequest<void>>> = new Map();
|
|
393
456
|
|
|
@@ -521,6 +584,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
521
584
|
case 'scrollScreenResult':
|
|
522
585
|
case 'scrollElementResult':
|
|
523
586
|
case 'openUrlResult':
|
|
587
|
+
case 'startRecordingResult':
|
|
588
|
+
case 'stopRecordingResult':
|
|
524
589
|
return 'id' in message && typeof message.id === 'string';
|
|
525
590
|
default:
|
|
526
591
|
return false;
|
|
@@ -597,7 +662,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
597
662
|
cleanup();
|
|
598
663
|
updateConnectionState('connecting');
|
|
599
664
|
|
|
600
|
-
|
|
665
|
+
const proxyAgent = nodeProxyTransport.getWebSocketAgent(serverAddress);
|
|
666
|
+
ws = new WebSocket(serverAddress, proxyAgent ? { agent: proxyAgent } : {});
|
|
601
667
|
|
|
602
668
|
ws.on('message', (data: Data) => {
|
|
603
669
|
let message: ServerMessage;
|
|
@@ -757,6 +823,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
757
823
|
scrollScreen,
|
|
758
824
|
scrollElement,
|
|
759
825
|
openUrl,
|
|
826
|
+
startRecording,
|
|
827
|
+
stopRecording,
|
|
760
828
|
disconnect,
|
|
761
829
|
startAdbTunnel,
|
|
762
830
|
sendAsset,
|
|
@@ -846,6 +914,34 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
846
914
|
};
|
|
847
915
|
};
|
|
848
916
|
|
|
917
|
+
const startRecording = async (recordingOptions?: { quality?: RecordingQuality }): Promise<void> => {
|
|
918
|
+
const request: CommandRequestMap['startRecording'] = {};
|
|
919
|
+
if (recordingOptions?.quality !== undefined) {
|
|
920
|
+
if (
|
|
921
|
+
!Number.isInteger(recordingOptions.quality) ||
|
|
922
|
+
recordingOptions.quality < 5 ||
|
|
923
|
+
recordingOptions.quality > 10
|
|
924
|
+
) {
|
|
925
|
+
throw new Error('quality must be one of: 5, 6, 7, 8, 9, 10');
|
|
926
|
+
}
|
|
927
|
+
request.quality = recordingOptions.quality;
|
|
928
|
+
}
|
|
929
|
+
await sendRequest('startRecording', request);
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
const stopRecording = async (saveTo: { presignedUrl?: string; localPath?: string }): Promise<string> => {
|
|
933
|
+
const request: CommandRequestMap['stopRecording'] = {};
|
|
934
|
+
if (saveTo.presignedUrl) {
|
|
935
|
+
request.upload = { presignedUrl: saveTo.presignedUrl };
|
|
936
|
+
}
|
|
937
|
+
await sendRequest('stopRecording', request);
|
|
938
|
+
const downloadUrl = buildDownloadUrl(recordingApiUrl);
|
|
939
|
+
if (saveTo.localPath) {
|
|
940
|
+
await downloadFileToLocalPath(downloadUrl, options.token, saveTo.localPath);
|
|
941
|
+
}
|
|
942
|
+
return downloadUrl;
|
|
943
|
+
};
|
|
944
|
+
|
|
849
945
|
const disconnect = (): void => {
|
|
850
946
|
intentionalDisconnect = true;
|
|
851
947
|
cleanup();
|
|
@@ -870,6 +966,9 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
870
966
|
* client to it.
|
|
871
967
|
*/
|
|
872
968
|
const startAdbTunnel = async (): Promise<Tunnel> => {
|
|
969
|
+
if (!options.adbUrl) {
|
|
970
|
+
throw new Error('adbUrl is required to start an ADB tunnel.');
|
|
971
|
+
}
|
|
873
972
|
const tunnel = await startTcpTunnel(options.adbUrl, options.token, '127.0.0.1', 0, {
|
|
874
973
|
maxReconnectAttempts,
|
|
875
974
|
reconnectDelay,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Readable } from 'stream';
|
|
4
|
+
import { pipeline } from 'stream/promises';
|
|
5
|
+
|
|
6
|
+
import { nodeProxyTransport } from './proxy-transport';
|
|
7
|
+
|
|
8
|
+
export async function downloadFileToLocalPath(url: string, token: string, localPath: string): Promise<void> {
|
|
9
|
+
const maxRetries = 3;
|
|
10
|
+
|
|
11
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
12
|
+
const response = await nodeProxyTransport.fetch(url, {
|
|
13
|
+
method: 'GET',
|
|
14
|
+
headers: {
|
|
15
|
+
Authorization: `Bearer ${token}`,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
const errorBody = await response.text();
|
|
20
|
+
const isRetriable = response.status >= 500 && response.status < 600;
|
|
21
|
+
if (isRetriable && attempt < maxRetries) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`Download failed: ${response.status} ${errorBody}`);
|
|
25
|
+
}
|
|
26
|
+
if (!response.body) {
|
|
27
|
+
throw new Error('Download failed: response body is missing');
|
|
28
|
+
}
|
|
29
|
+
await fs.promises.mkdir(path.dirname(localPath), { recursive: true });
|
|
30
|
+
await pipeline(Readable.fromWeb(response.body as any), fs.createWriteStream(localPath));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Agent as HttpAgent } from 'http';
|
|
2
|
+
|
|
3
|
+
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
4
|
+
import { getProxyForUrl } from 'proxy-from-env';
|
|
5
|
+
import { EnvHttpProxyAgent } from 'undici';
|
|
6
|
+
|
|
7
|
+
import type { Fetch } from './builtin-types';
|
|
8
|
+
|
|
9
|
+
class NodeProxyTransport {
|
|
10
|
+
private envHttpProxyAgent: EnvHttpProxyAgent | undefined;
|
|
11
|
+
private websocketAgents = new Map<string, HttpAgent>();
|
|
12
|
+
|
|
13
|
+
fetch: Fetch = async (input, init) => {
|
|
14
|
+
if (!this.hasProxyEnv()) {
|
|
15
|
+
return fetch(input, init);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (fetch as any)(input, {
|
|
19
|
+
...(init ?? {}),
|
|
20
|
+
dispatcher: this.getEnvHttpProxyAgent(),
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
getWebSocketAgent(url: string): HttpAgent | undefined {
|
|
25
|
+
if (!this.hasProxyEnv()) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const proxyUrl = getProxyForUrl(this.getWebSocketProxyLookupUrl(url));
|
|
30
|
+
if (!proxyUrl) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let agent = this.websocketAgents.get(proxyUrl);
|
|
35
|
+
if (!agent) {
|
|
36
|
+
const createdAgent = new HttpsProxyAgent(proxyUrl);
|
|
37
|
+
this.websocketAgents.set(proxyUrl, createdAgent);
|
|
38
|
+
agent = createdAgent;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return agent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private getEnvHttpProxyAgent(): EnvHttpProxyAgent {
|
|
45
|
+
this.envHttpProxyAgent ??= new EnvHttpProxyAgent();
|
|
46
|
+
return this.envHttpProxyAgent;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private hasProxyEnv(): boolean {
|
|
50
|
+
if (typeof process === 'undefined' || !process.versions?.node) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const env = process.env;
|
|
55
|
+
return !!(env['http_proxy'] || env['HTTP_PROXY'] || env['https_proxy'] || env['HTTPS_PROXY']);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private getWebSocketProxyLookupUrl(url: string): string {
|
|
59
|
+
const lookupUrl = new URL(url);
|
|
60
|
+
if (lookupUrl.protocol === 'ws:') {
|
|
61
|
+
lookupUrl.protocol = 'http:';
|
|
62
|
+
} else if (lookupUrl.protocol === 'wss:') {
|
|
63
|
+
lookupUrl.protocol = 'https:';
|
|
64
|
+
}
|
|
65
|
+
return lookupUrl.toString();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const nodeProxyTransport = new NodeProxyTransport();
|
package/src/internal/shims.ts
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Fetch } from './builtin-types';
|
|
11
|
+
import { nodeProxyTransport } from './proxy-transport';
|
|
11
12
|
import type { ReadableStream } from './shim-types';
|
|
12
13
|
|
|
13
14
|
export function getDefaultFetch(): Fetch {
|
|
14
15
|
if (typeof fetch !== 'undefined') {
|
|
15
|
-
return fetch
|
|
16
|
+
return nodeProxyTransport.fetch;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
throw new Error(
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export const readEnv = (env: string): string | undefined => {
|
|
11
11
|
if (typeof (globalThis as any).process !== 'undefined') {
|
|
12
|
-
return (globalThis as any).process.env?.[env]?.trim()
|
|
12
|
+
return (globalThis as any).process.env?.[env]?.trim() || undefined;
|
|
13
13
|
}
|
|
14
14
|
if (typeof (globalThis as any).Deno !== 'undefined') {
|
|
15
|
-
return (globalThis as any).Deno.env?.get?.(env)?.trim();
|
|
15
|
+
return (globalThis as any).Deno.env?.get?.(env)?.trim() || undefined;
|
|
16
16
|
}
|
|
17
17
|
return undefined;
|
|
18
18
|
};
|
package/src/ios-client.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { WebSocket, Data } from 'ws';
|
|
2
|
-
import fs from 'fs';
|
|
3
1
|
import os from 'os';
|
|
4
|
-
import path from 'path';
|
|
5
2
|
import crypto from 'crypto';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { WebSocket, Data } from 'ws';
|
|
6
6
|
import { EventEmitter } from 'events';
|
|
7
|
-
import { Readable } from 'stream';
|
|
8
|
-
import { pipeline } from 'stream/promises';
|
|
9
7
|
import { isNonRetryableError } from './tunnel';
|
|
10
8
|
import { type SyncFolderResult, type FolderSyncOptions, syncFolder } from './folder-sync';
|
|
11
9
|
import { createIgnoreFn } from './folder-sync-ignore';
|
|
10
|
+
import { downloadFileToLocalPath } from './internal/download-file';
|
|
11
|
+
import { nodeProxyTransport } from './internal/proxy-transport';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Connection state of the instance client
|
|
@@ -20,44 +20,10 @@ export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'rec
|
|
|
20
20
|
*/
|
|
21
21
|
export type ConnectionStateCallback = (state: ConnectionState) => void;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
const rand = Math.random().toString(36).slice(2, 5).padEnd(3, '0');
|
|
25
|
-
const now = new Date();
|
|
26
|
-
const formattedDate = now.toISOString().replace(/[-:]/g, '_').replace('T', '_').replace(/\..+/, '');
|
|
23
|
+
const ACTIVE_RECORDING_FILENAME = 'recording.mp4';
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function downloadFileToLocalPath(url: string, token: string, localPath: string): Promise<void> {
|
|
33
|
-
const maxRetries = 3;
|
|
34
|
-
|
|
35
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
36
|
-
const response = await fetch(url, {
|
|
37
|
-
method: 'GET',
|
|
38
|
-
headers: {
|
|
39
|
-
Authorization: `Bearer ${token}`,
|
|
40
|
-
},
|
|
41
|
-
});
|
|
42
|
-
if (!response.ok) {
|
|
43
|
-
const errorBody = await response.text();
|
|
44
|
-
const isRetriable = response.status >= 500 && response.status < 600;
|
|
45
|
-
if (isRetriable && attempt < maxRetries) {
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
throw new Error(`Download failed: ${response.status} ${errorBody}`);
|
|
49
|
-
}
|
|
50
|
-
if (!response.body) {
|
|
51
|
-
throw new Error('Download failed: response body is missing');
|
|
52
|
-
}
|
|
53
|
-
await fs.promises.mkdir(path.dirname(localPath), { recursive: true });
|
|
54
|
-
await pipeline(Readable.fromWeb(response.body as any), fs.createWriteStream(localPath));
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function buildDownloadUrl(apiUrl: string, filename: string): string {
|
|
60
|
-
return `${apiUrl}/files?name=${encodeURIComponent(filename)}`;
|
|
25
|
+
function buildDownloadUrl(apiUrl: string): string {
|
|
26
|
+
return `${apiUrl}/files?name=${encodeURIComponent(ACTIVE_RECORDING_FILENAME)}`;
|
|
61
27
|
}
|
|
62
28
|
|
|
63
29
|
/**
|
|
@@ -343,8 +309,10 @@ export type InstanceClient = {
|
|
|
343
309
|
|
|
344
310
|
/**
|
|
345
311
|
* Start recording simulator video. Use stopRecording() to stop the recording.
|
|
312
|
+
* When provided, `quality` must be one of `5`, `6`, `7`, `8`, `9`, or `10`.
|
|
313
|
+
* The server default is `5`.
|
|
346
314
|
*/
|
|
347
|
-
startRecording: () => Promise<void>;
|
|
315
|
+
startRecording: (options?: { quality?: RecordingQuality }) => Promise<void>;
|
|
348
316
|
|
|
349
317
|
/**
|
|
350
318
|
* Stop the active recording for this client instance.
|
|
@@ -512,6 +480,15 @@ export type InstanceClient = {
|
|
|
512
480
|
*/
|
|
513
481
|
export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
|
|
514
482
|
|
|
483
|
+
export enum RecordingQuality {
|
|
484
|
+
Q5 = 5,
|
|
485
|
+
Q6 = 6,
|
|
486
|
+
Q7 = 7,
|
|
487
|
+
Q8 = 8,
|
|
488
|
+
Q9 = 9,
|
|
489
|
+
Q10 = 10,
|
|
490
|
+
}
|
|
491
|
+
|
|
515
492
|
/**
|
|
516
493
|
* Configuration options for creating an iOS client
|
|
517
494
|
*/
|
|
@@ -849,7 +826,8 @@ export class LogStream extends EventEmitter {
|
|
|
849
826
|
|
|
850
827
|
/** @internal - Establish the dedicated WebSocket connection */
|
|
851
828
|
private _connect(): void {
|
|
852
|
-
|
|
829
|
+
const proxyAgent = nodeProxyTransport.getWebSocketAgent(this.wsUrl);
|
|
830
|
+
this.ws = new WebSocket(this.wsUrl, proxyAgent ? { agent: proxyAgent } : {});
|
|
853
831
|
|
|
854
832
|
this.ws.on('open', () => {
|
|
855
833
|
if (this.stopped) {
|
|
@@ -920,7 +898,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
920
898
|
let reconnectTimeout: NodeJS.Timeout | undefined;
|
|
921
899
|
let intentionalDisconnect = false;
|
|
922
900
|
let lastError: string | undefined;
|
|
923
|
-
let
|
|
901
|
+
let hasActiveRecording = false;
|
|
924
902
|
|
|
925
903
|
// Centralized pending requests map - handles all request/response patterns
|
|
926
904
|
const pendingRequests: Map<string, PendingRequest<any>> = new Map();
|
|
@@ -1100,9 +1078,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1100
1078
|
bundleId: msg.bundleId || '',
|
|
1101
1079
|
}),
|
|
1102
1080
|
startVideoRecordingResult: () => undefined,
|
|
1103
|
-
stopVideoRecordingResult: (
|
|
1104
|
-
filename: msg.filename || '',
|
|
1105
|
-
}),
|
|
1081
|
+
stopVideoRecordingResult: () => undefined,
|
|
1106
1082
|
setOrientationResult: () => undefined,
|
|
1107
1083
|
scrollResult: () => undefined,
|
|
1108
1084
|
xcrunResult: (msg): CommandResult => ({
|
|
@@ -1121,7 +1097,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1121
1097
|
cleanup();
|
|
1122
1098
|
updateConnectionState('connecting');
|
|
1123
1099
|
|
|
1124
|
-
|
|
1100
|
+
const proxyAgent = nodeProxyTransport.getWebSocketAgent(endpointWebSocketUrl);
|
|
1101
|
+
ws = new WebSocket(endpointWebSocketUrl, proxyAgent ? { agent: proxyAgent } : {});
|
|
1125
1102
|
|
|
1126
1103
|
ws.on('message', (data: Data) => {
|
|
1127
1104
|
let message: ServerResponse;
|
|
@@ -1418,41 +1395,42 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1418
1395
|
});
|
|
1419
1396
|
};
|
|
1420
1397
|
|
|
1421
|
-
const startRecording = async (): Promise<void> => {
|
|
1422
|
-
if (
|
|
1423
|
-
throw new Error(
|
|
1398
|
+
const startRecording = async (opts?: { quality?: RecordingQuality }): Promise<void> => {
|
|
1399
|
+
if (hasActiveRecording) {
|
|
1400
|
+
throw new Error('A recording is already active for this client');
|
|
1424
1401
|
}
|
|
1425
|
-
|
|
1426
|
-
activeRecordingFilename = finalFilename;
|
|
1402
|
+
hasActiveRecording = true;
|
|
1427
1403
|
try {
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1404
|
+
const request: { quality?: RecordingQuality } = {};
|
|
1405
|
+
if (opts?.quality !== undefined) {
|
|
1406
|
+
if (!Number.isInteger(opts.quality) || opts.quality < 5 || opts.quality > 10) {
|
|
1407
|
+
throw new Error('quality must be one of: 5, 6, 7, 8, 9, 10');
|
|
1408
|
+
}
|
|
1409
|
+
request.quality = opts.quality;
|
|
1432
1410
|
}
|
|
1411
|
+
await sendRequest<void>('startVideoRecording', request);
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
hasActiveRecording = false;
|
|
1433
1414
|
throw error;
|
|
1434
1415
|
}
|
|
1435
1416
|
};
|
|
1436
1417
|
|
|
1437
1418
|
const stopRecording = async (saveTo: { presignedUrl?: string; localPath?: string }): Promise<string> => {
|
|
1438
|
-
|
|
1439
|
-
if (!filename) {
|
|
1419
|
+
if (!hasActiveRecording) {
|
|
1440
1420
|
throw new Error('No active recording for this client. Call startRecording() first.');
|
|
1441
1421
|
}
|
|
1442
|
-
|
|
1443
|
-
filename,
|
|
1422
|
+
await sendRequest<void>('stopVideoRecording', {
|
|
1444
1423
|
upload: saveTo.presignedUrl ? { presignedUrl: saveTo.presignedUrl } : undefined,
|
|
1445
1424
|
});
|
|
1446
|
-
const
|
|
1447
|
-
const downloadUrl = buildDownloadUrl(options.apiUrl, finalFilename);
|
|
1425
|
+
const downloadUrl = buildDownloadUrl(options.apiUrl);
|
|
1448
1426
|
if (saveTo.localPath) {
|
|
1449
1427
|
try {
|
|
1450
1428
|
await downloadFileToLocalPath(downloadUrl, options.token, saveTo.localPath);
|
|
1451
1429
|
} finally {
|
|
1452
|
-
|
|
1430
|
+
hasActiveRecording = false;
|
|
1453
1431
|
}
|
|
1454
1432
|
} else {
|
|
1455
|
-
|
|
1433
|
+
hasActiveRecording = false;
|
|
1456
1434
|
}
|
|
1457
1435
|
return downloadUrl;
|
|
1458
1436
|
};
|
|
@@ -1594,7 +1572,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1594
1572
|
try {
|
|
1595
1573
|
// Node's fetch (undici) supports streaming request bodies but TS DOM types may not include
|
|
1596
1574
|
// `duplex` and may not accept Node ReadStreams as BodyInit in some configs.
|
|
1597
|
-
const response = await fetch(uploadUrl, {
|
|
1575
|
+
const response = await nodeProxyTransport.fetch(uploadUrl, {
|
|
1598
1576
|
method: 'PUT',
|
|
1599
1577
|
headers: {
|
|
1600
1578
|
'Content-Type': 'application/octet-stream',
|
|
@@ -72,8 +72,8 @@ export namespace AndroidInstance {
|
|
|
72
72
|
export interface Spec {
|
|
73
73
|
/**
|
|
74
74
|
* After how many minutes of inactivity should the instance be terminated. Example
|
|
75
|
-
* values 1m, 10m, 3h. Default is 3m. Providing "0"
|
|
76
|
-
*
|
|
75
|
+
* values 1m, 10m, 3h. Default is 3m. Providing "0" uses the organization's default
|
|
76
|
+
* inactivity timeout.
|
|
77
77
|
*/
|
|
78
78
|
inactivityTimeout: string;
|
|
79
79
|
|
|
@@ -97,6 +97,8 @@ export namespace AndroidInstance {
|
|
|
97
97
|
|
|
98
98
|
adbWebSocketUrl?: string;
|
|
99
99
|
|
|
100
|
+
apiUrl?: string;
|
|
101
|
+
|
|
100
102
|
endpointWebSocketUrl?: string;
|
|
101
103
|
|
|
102
104
|
errorMessage?: string;
|
|
@@ -162,8 +164,8 @@ export namespace AndroidInstanceCreateParams {
|
|
|
162
164
|
|
|
163
165
|
/**
|
|
164
166
|
* After how many minutes of inactivity should the instance be terminated. Example
|
|
165
|
-
* values 1m, 10m, 3h. Default is 3m. Providing "0"
|
|
166
|
-
*
|
|
167
|
+
* values 1m, 10m, 3h. Default is 3m. Providing "0" uses the organization's default
|
|
168
|
+
* inactivity timeout.
|
|
167
169
|
*/
|
|
168
170
|
inactivityTimeout?: string;
|
|
169
171
|
|
|
@@ -3,6 +3,7 @@ import { createHash } from 'crypto';
|
|
|
3
3
|
import { promises as fs } from 'fs';
|
|
4
4
|
|
|
5
5
|
import { RequestOptions } from '../internal/request-options';
|
|
6
|
+
import { nodeProxyTransport } from '../internal/proxy-transport';
|
|
6
7
|
import { Assets as GeneratedAssets } from './assets';
|
|
7
8
|
|
|
8
9
|
export interface AssetGetOrUploadParams {
|
|
@@ -45,7 +46,7 @@ export class Assets extends GeneratedAssets {
|
|
|
45
46
|
md5: creationResponse.md5,
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
|
-
const uploadResponse = await fetch(creationResponse.signedUploadUrl, {
|
|
49
|
+
const uploadResponse = await nodeProxyTransport.fetch(creationResponse.signedUploadUrl, {
|
|
49
50
|
headers: {
|
|
50
51
|
'Content-Length': data.length.toString(),
|
|
51
52
|
'Content-Type': 'application/octet-stream',
|
package/src/resources/index.ts
CHANGED
|
@@ -22,5 +22,18 @@ export {
|
|
|
22
22
|
type IosInstanceListParams,
|
|
23
23
|
type IosInstancesItems,
|
|
24
24
|
} from './ios-instances';
|
|
25
|
+
export {
|
|
26
|
+
type XcodeInstance,
|
|
27
|
+
type XcodeInstanceCreateParams,
|
|
28
|
+
type XcodeInstanceListParams,
|
|
29
|
+
type XcodeInstancesItems,
|
|
30
|
+
} from './xcode-instances';
|
|
25
31
|
|
|
26
32
|
export { Assets, AssetGetOrUploadParams, AssetGetOrUploadResponse } from './assets-helpers';
|
|
33
|
+
export {
|
|
34
|
+
XcodeInstances,
|
|
35
|
+
type XcodeCreateClientParams,
|
|
36
|
+
type XcodeClient,
|
|
37
|
+
type XcodeProjectConfig,
|
|
38
|
+
type XcodeBuildOptions,
|
|
39
|
+
} from './xcode-instances-helpers';
|
|
@@ -72,8 +72,8 @@ export namespace IosInstance {
|
|
|
72
72
|
export interface Spec {
|
|
73
73
|
/**
|
|
74
74
|
* After how many minutes of inactivity should the instance be terminated. Example
|
|
75
|
-
* values 1m, 10m, 3h. Default is 3m. Providing "0"
|
|
76
|
-
*
|
|
75
|
+
* values 1m, 10m, 3h. Default is 3m. Providing "0" uses the organization's default
|
|
76
|
+
* inactivity timeout.
|
|
77
77
|
*/
|
|
78
78
|
inactivityTimeout: string;
|
|
79
79
|
|
|
@@ -162,8 +162,8 @@ export namespace IosInstanceCreateParams {
|
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
164
|
* After how many minutes of inactivity should the instance be terminated. Example
|
|
165
|
-
* values 1m, 10m, 3h. Default is 3m. Providing "0"
|
|
166
|
-
*
|
|
165
|
+
* values 1m, 10m, 3h. Default is 3m. Providing "0" uses the organization's default
|
|
166
|
+
* inactivity timeout.
|
|
167
167
|
*/
|
|
168
168
|
inactivityTimeout?: string;
|
|
169
169
|
|