appium-xcuitest-driver 10.14.13 → 10.15.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.
@@ -0,0 +1,503 @@
1
+ import type {Readable} from 'stream';
2
+ import {Readable as ReadableStream} from 'stream';
3
+ import {pipeline} from 'stream/promises';
4
+ import path from 'path';
5
+ import _ from 'lodash';
6
+ import B from 'bluebird';
7
+ import {fs, mkdirp} from 'appium/support';
8
+ import {services} from 'appium-ios-device';
9
+ import type {AfcService as IOSDeviceAfcService} from 'appium-ios-device';
10
+ import {getRemoteXPCServices} from './remotexpc-utils';
11
+ import {log} from '../logger';
12
+ import type {
13
+ AfcService as RemoteXPCAfcService,
14
+ RemoteXpcConnection,
15
+ } from 'appium-ios-remotexpc';
16
+ import {IO_TIMEOUT_MS, MAX_IO_CHUNK_SIZE} from './real-device-management';
17
+
18
+
19
+ /**
20
+ * Options for pulling files/folders
21
+ */
22
+ export interface AfcPullOptions {
23
+ recursive?: boolean;
24
+ overwrite?: boolean;
25
+ onEntry?: (remotePath: string, localPath: string, isDirectory: boolean) => Promise<void>;
26
+ }
27
+
28
+ /**
29
+ * Options for creating an AFC client for app container access
30
+ */
31
+ export interface CreateForAppOptions {
32
+ containerType?: string | null;
33
+ skipDocumentsCheck?: boolean;
34
+ }
35
+
36
+ /**
37
+ * Unified AFC Client
38
+ *
39
+ * Provides a unified interface for file operations on iOS devices,
40
+ * automatically handling the differences between iOS < 18 (appium-ios-device)
41
+ * and iOS 18 and above (appium-ios-remotexpc).
42
+ */
43
+ export class AfcClient {
44
+ private readonly service: RemoteXPCAfcService | IOSDeviceAfcService;
45
+ private readonly remoteXPCConnection?: RemoteXpcConnection;
46
+
47
+ private constructor(
48
+ service: RemoteXPCAfcService | IOSDeviceAfcService,
49
+ remoteXPCConnection?: RemoteXpcConnection
50
+ ) {
51
+ this.service = service;
52
+ this.remoteXPCConnection = remoteXPCConnection;
53
+ }
54
+
55
+ //#region Public Methods
56
+
57
+ /**
58
+ * Create an AFC client for device
59
+ *
60
+ * @param udid - Device UDID
61
+ * @param useRemoteXPC - Whether to use remotexpc (use isIos18OrNewer(opts) to determine)
62
+ * @returns AFC client instance
63
+ */
64
+ static async createForDevice(udid: string, useRemoteXPC: boolean): Promise<AfcClient> {
65
+ if (useRemoteXPC) {
66
+ const client = await AfcClient.withRemoteXpcConnection(async () => {
67
+ const Services = await getRemoteXPCServices();
68
+ const connectionResult = await Services.createRemoteXPCConnection(udid);
69
+ const afcService = await Services.startAfcService(udid);
70
+ return {
71
+ service: afcService,
72
+ connection: connectionResult.remoteXPC,
73
+ };
74
+ });
75
+ if (client) {
76
+ return client;
77
+ }
78
+ }
79
+
80
+ const afcService = await services.startAfcService(udid);
81
+ return new AfcClient(afcService);
82
+ }
83
+
84
+ /**
85
+ * Create an AFC client for app container access
86
+ *
87
+ * @param udid - Device UDID
88
+ * @param bundleId - App bundle identifier
89
+ * @param useRemoteXPC - Whether to use remotexpc (use isIos18OrNewer(opts) to determine)
90
+ * @param options - Optional configuration for container access
91
+ * @returns AFC client instance
92
+ */
93
+ static async createForApp(
94
+ udid: string,
95
+ bundleId: string,
96
+ useRemoteXPC: boolean,
97
+ options?: CreateForAppOptions
98
+ ): Promise<AfcClient> {
99
+ const {containerType = null, skipDocumentsCheck = false} = options ?? {};
100
+ const isDocuments = !skipDocumentsCheck && containerType?.toLowerCase() === 'documents';
101
+
102
+ if (useRemoteXPC) {
103
+ const client = await AfcClient.withRemoteXpcConnection(async () => {
104
+ const Services = await getRemoteXPCServices();
105
+ const connectionResult = await Services.createRemoteXPCConnection(udid);
106
+ const {houseArrestService, remoteXPC: houseArrestRemoteXPC} = await Services.startHouseArrestService(udid);
107
+ const afcService = isDocuments
108
+ ? await houseArrestService.vendDocuments(bundleId)
109
+ : await houseArrestService.vendContainer(bundleId);
110
+ // Use the remoteXPC from house arrest service if available, otherwise use the one from connection
111
+ const connection = houseArrestRemoteXPC ?? connectionResult.remoteXPC;
112
+ return {
113
+ service: afcService,
114
+ connection,
115
+ };
116
+ });
117
+ if (client) {
118
+ return client;
119
+ }
120
+ }
121
+
122
+ const houseArrestService = await services.startHouseArrestService(udid);
123
+ const afcService = isDocuments
124
+ ? await houseArrestService.vendDocuments(bundleId)
125
+ : await houseArrestService.vendContainer(bundleId);
126
+ return new AfcClient(afcService);
127
+ }
128
+
129
+ /**
130
+ * Check if a path is a directory
131
+ */
132
+ async isDirectory(path: string): Promise<boolean> {
133
+ if (this.isRemoteXPC) {
134
+ return await this.remoteXPCAfcService.isdir(path);
135
+ }
136
+ const fileInfo = await this.iosDeviceAfcService.getFileInfo(path);
137
+ return fileInfo.isDirectory();
138
+ }
139
+
140
+ /**
141
+ * List directory contents
142
+ */
143
+ async listDirectory(path: string): Promise<string[]> {
144
+ if (this.isRemoteXPC) {
145
+ return await this.remoteXPCAfcService.listdir(path);
146
+ }
147
+ return await this.iosDeviceAfcService.listDirectory(path);
148
+ }
149
+
150
+ /**
151
+ * Create a directory
152
+ */
153
+ async createDirectory(path: string): Promise<void> {
154
+ if (this.isRemoteXPC) {
155
+ await this.remoteXPCAfcService.mkdir(path);
156
+ } else {
157
+ await this.iosDeviceAfcService.createDirectory(path);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Delete a directory or file
163
+ */
164
+ async deleteDirectory(path: string): Promise<void> {
165
+ if (this.isRemoteXPC) {
166
+ await this.remoteXPCAfcService.rm(path, true);
167
+ } else {
168
+ await this.iosDeviceAfcService.deleteDirectory(path);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Get file contents as a buffer
174
+ */
175
+ async getFileContents(path: string): Promise<Buffer> {
176
+ if (this.isRemoteXPC) {
177
+ return await this.remoteXPCAfcService.getFileContents(path);
178
+ }
179
+
180
+ // For ios-device, use stream-based approach
181
+ const stream = await this.iosDeviceAfcService.createReadStream(path, {
182
+ autoDestroy: true,
183
+ });
184
+ const buffers: Buffer[] = [];
185
+ return new Promise((resolve, reject) => {
186
+ stream.on('data', (data: Buffer) => buffers.push(data));
187
+ stream.on('end', () => resolve(Buffer.concat(buffers)));
188
+ stream.on('error', reject);
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Set file contents from a buffer
194
+ */
195
+ async setFileContents(path: string, data: Buffer): Promise<void> {
196
+ if (this.isRemoteXPC) {
197
+ await this.remoteXPCAfcService.setFileContents(path, data);
198
+ return;
199
+ }
200
+ // For ios-device, convert buffer to stream and use writeFromStream
201
+ const bufferStream = ReadableStream.from([data]);
202
+ return await this.writeFromStream(path, bufferStream);
203
+ }
204
+
205
+ /**
206
+ * Write file contents from a readable stream
207
+ */
208
+ async writeFromStream(path: string, stream: Readable): Promise<void> {
209
+ if (this.isRemoteXPC) {
210
+ await this.remoteXPCAfcService.writeFromStream(path, stream);
211
+ return;
212
+ }
213
+
214
+ const writeStream = await this.iosDeviceAfcService.createWriteStream(path, {
215
+ autoDestroy: true,
216
+ });
217
+
218
+ writeStream.on('finish', () => {
219
+ if (typeof writeStream.destroy === 'function') {
220
+ writeStream.destroy();
221
+ }
222
+ });
223
+
224
+ return new Promise((resolve, reject) => {
225
+ writeStream.on('close', resolve);
226
+ const onError = (e: Error) => {
227
+ stream.unpipe(writeStream);
228
+ reject(e);
229
+ };
230
+ writeStream.on('error', onError);
231
+ stream.on('error', onError);
232
+ stream.pipe(writeStream);
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Pull files/folders from device to local filesystem.
238
+ * Uses the appropriate mechanism (walkDir for ios-device, pull for remotexpc).
239
+ *
240
+ * @param remotePath - Remote path on the device (file or directory)
241
+ * @param localPath - Local destination path
242
+ * @param options - Pull options (recursive, overwrite, onEntry)
243
+ */
244
+ async pull(
245
+ remotePath: string,
246
+ localPath: string,
247
+ options: AfcPullOptions = {}
248
+ ): Promise<void> {
249
+ if (this.isRemoteXPC) {
250
+ // RemoteXPC expects 'callback' property, so map onEntry -> callback
251
+ const remoteXpcOptions = {
252
+ ...options,
253
+ callback: options.onEntry,
254
+ };
255
+ delete remoteXpcOptions.onEntry;
256
+ await this.remoteXPCAfcService.pull(remotePath, localPath, remoteXpcOptions);
257
+ } else {
258
+ await this.pullWithWalkDir(remotePath, localPath, options);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Close the AFC service connection and remoteXPC connection if present
264
+ */
265
+ async close(): Promise<void> {
266
+ this.service.close();
267
+ if (this.remoteXPCConnection) {
268
+ try {
269
+ await this.remoteXPCConnection.close();
270
+ } catch {}
271
+ }
272
+ }
273
+
274
+ //#endregion
275
+
276
+ //#region Private Methods
277
+
278
+ /**
279
+ * Check if this client is using RemoteXPC
280
+ */
281
+ private get isRemoteXPC(): boolean {
282
+ return !!this.remoteXPCConnection;
283
+ }
284
+
285
+ /**
286
+ * Helper to safely execute remoteXPC operations with connection cleanup
287
+ * @param operation - Async operation that returns an AfcClient
288
+ * @returns AfcClient on success, null on failure
289
+ */
290
+ private static async withRemoteXpcConnection<T extends RemoteXPCAfcService | IOSDeviceAfcService>(
291
+ operation: () => Promise<{service: T; connection: RemoteXpcConnection}>
292
+ ): Promise<AfcClient | null> {
293
+ let remoteXPCConnection: RemoteXpcConnection | undefined;
294
+ let succeeded = false;
295
+ try {
296
+ const {service, connection} = await operation();
297
+ remoteXPCConnection = connection;
298
+ const client = new AfcClient(service, remoteXPCConnection);
299
+ succeeded = true;
300
+ return client;
301
+ } catch (err: any) {
302
+ log.error(`Failed to create AFC client via RemoteXPC: ${err.message}, falling back to appium-ios-device`);
303
+ return null;
304
+ } finally {
305
+ // Only close connection if we failed (if succeeded, the client owns it)
306
+ if (remoteXPCConnection && !succeeded) {
307
+ try {
308
+ await remoteXPCConnection.close();
309
+ } catch {
310
+ // Ignore cleanup errors
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Get service as RemoteXPC AFC service
318
+ */
319
+ private get remoteXPCAfcService(): RemoteXPCAfcService {
320
+ return this.service as RemoteXPCAfcService;
321
+ }
322
+
323
+ /**
324
+ * Get service as iOS Device AFC service
325
+ */
326
+ private get iosDeviceAfcService(): IOSDeviceAfcService {
327
+ return this.service as IOSDeviceAfcService;
328
+ }
329
+
330
+ /**
331
+ * Create a read stream for a file (internal use only).
332
+ */
333
+ private async createReadStream(remotePath: string, options?: {autoDestroy?: boolean}): Promise<Readable> {
334
+ if (this.isRemoteXPC) {
335
+ // Use readToStream which returns a streaming Readable
336
+ return await this.remoteXPCAfcService.readToStream(remotePath);
337
+ }
338
+ return await this.iosDeviceAfcService.createReadStream(remotePath, options);
339
+ }
340
+
341
+ /**
342
+ * Internal implementation of pull for ios-device using walkDir.
343
+ * Walks the remote directory tree and pulls files to local filesystem.
344
+ */
345
+ private async pullWithWalkDir(
346
+ remotePath: string,
347
+ localPath: string,
348
+ options: AfcPullOptions
349
+ ): Promise<void> {
350
+ const {recursive = false, overwrite = true, onEntry} = options;
351
+
352
+ const isDir = await this.isDirectory(remotePath);
353
+
354
+ if (!isDir) {
355
+ // Single file pull
356
+ const localFilePath = (await this.isLocalDirectory(localPath))
357
+ ? path.join(localPath, path.posix.basename(remotePath))
358
+ : localPath;
359
+
360
+ await this.checkOverwrite(localFilePath, overwrite);
361
+ await this.pullSingleFile(remotePath, localFilePath);
362
+
363
+ if (onEntry) {
364
+ await onEntry(remotePath, localFilePath, false);
365
+ }
366
+ return;
367
+ }
368
+
369
+ // Directory pull requires recursive option
370
+ if (!recursive) {
371
+ throw new Error(
372
+ `Cannot pull directory '${remotePath}' without recursive option. Set recursive: true to pull directories.`
373
+ );
374
+ }
375
+
376
+ // Determine local root directory
377
+ const localDstIsDirectory = await this.isLocalDirectory(localPath);
378
+ const localRootDir = localDstIsDirectory
379
+ ? path.join(localPath, path.posix.basename(remotePath))
380
+ : localPath;
381
+
382
+ // Create the root directory
383
+ await mkdirp(localRootDir);
384
+
385
+ if (onEntry) {
386
+ await onEntry(remotePath, localRootDir, true);
387
+ }
388
+
389
+ const pullPromises: B<void>[] = [];
390
+
391
+ // Walk the remote directory and pull files in parallel
392
+ await this.iosDeviceAfcService.walkDir(remotePath, true, async (entryPath: string, isDirectory: boolean) => {
393
+ // Calculate relative path from remote root
394
+ const relativePath = entryPath.startsWith(remotePath + '/')
395
+ ? entryPath.slice(remotePath.length + 1)
396
+ : entryPath.slice(remotePath.length);
397
+ const localEntryPath = path.join(localRootDir, relativePath);
398
+
399
+ if (isDirectory) {
400
+ await mkdirp(localEntryPath);
401
+ if (onEntry) {
402
+ await onEntry(entryPath, localEntryPath, true);
403
+ }
404
+ } else {
405
+ await this.checkOverwrite(localEntryPath, overwrite);
406
+
407
+ // Ensure parent directory exists
408
+ const parentDir = path.dirname(localEntryPath);
409
+ await mkdirp(parentDir);
410
+
411
+ // Start async file pull (non-blocking)
412
+ const readStream = await this.iosDeviceAfcService.createReadStream(entryPath, {
413
+ autoDestroy: true,
414
+ });
415
+ const writeStream = fs.createWriteStream(localEntryPath, {autoClose: true});
416
+
417
+ pullPromises.push(
418
+ new B<void>((resolve) => {
419
+ writeStream.on('close', async () => {
420
+ // Invoke onEntry callback after successful pull
421
+ if (onEntry) {
422
+ try {
423
+ await onEntry(entryPath, localEntryPath, false);
424
+ } catch (err: any) {
425
+ log.warn(`onEntry callback failed for '${entryPath}': ${err.message}`);
426
+ }
427
+ }
428
+ resolve();
429
+ });
430
+ const onStreamingError = (e: Error) => {
431
+ readStream.unpipe(writeStream);
432
+ log.warn(
433
+ `Cannot pull '${entryPath}' to '${localEntryPath}'. ` +
434
+ `The file will be skipped. Original error: ${e.message}`
435
+ );
436
+ resolve();
437
+ };
438
+ writeStream.on('error', onStreamingError);
439
+ readStream.on('error', onStreamingError);
440
+ }).timeout(IO_TIMEOUT_MS)
441
+ );
442
+ readStream.pipe(writeStream);
443
+
444
+ if (pullPromises.length >= MAX_IO_CHUNK_SIZE) {
445
+ await B.any(pullPromises);
446
+ for (let i = pullPromises.length - 1; i >= 0; i--) {
447
+ if (pullPromises[i].isFulfilled()) {
448
+ pullPromises.splice(i, 1);
449
+ }
450
+ }
451
+ }
452
+ }
453
+ });
454
+
455
+ // Wait for remaining files to be pulled
456
+ if (!_.isEmpty(pullPromises)) {
457
+ await B.all(pullPromises);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Check if local file exists and should not be overwritten.
463
+ * Throws an error if the file exists and overwrite is false.
464
+ *
465
+ * @param localPath - Local file path to check
466
+ * @param overwrite - Whether to allow overwriting existing files
467
+ */
468
+ private async checkOverwrite(localPath: string, overwrite: boolean): Promise<void> {
469
+ if (!overwrite && await fs.exists(localPath)) {
470
+ throw new Error(`Local file already exists: ${localPath}`);
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Pull a single file from device to local filesystem using streams.
476
+ * This method only works for ios-device.
477
+ *
478
+ * @param remotePath - Remote file path
479
+ * @param localPath - Local destination path
480
+ */
481
+ private async pullSingleFile(remotePath: string, localPath: string): Promise<void> {
482
+ const readStream = await this.iosDeviceAfcService.createReadStream(remotePath, {
483
+ autoDestroy: true,
484
+ });
485
+ const writeStream = fs.createWriteStream(localPath, {autoClose: true});
486
+
487
+ await pipeline(readStream, writeStream);
488
+ }
489
+
490
+ /**
491
+ * Check if a local path exists and is a directory.
492
+ */
493
+ private async isLocalDirectory(localPath: string): Promise<boolean> {
494
+ try {
495
+ const stats = await fs.stat(localPath);
496
+ return stats.isDirectory();
497
+ } catch {
498
+ return false;
499
+ }
500
+ }
501
+
502
+ //#endregion
503
+ }