appium-xcuitest-driver 10.16.2 → 10.17.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.
@@ -8,7 +8,7 @@ import WebSocket from 'ws';
8
8
  import { SafariConsoleLog } from '../device/log/safari-console-log';
9
9
  import { SafariNetworkLog } from '../device/log/safari-network-log';
10
10
  import { toLogEntry } from '../device/log/helpers';
11
- import { NATIVE_WIN } from '../utils';
11
+ import { NATIVE_WIN, isIos18OrNewer } from '../utils';
12
12
  import { BIDI_EVENT_NAME } from './bidi/constants';
13
13
  import { makeLogEntryAddedEvent } from './bidi/models';
14
14
  import type {XCUITestDriver} from '../driver';
@@ -131,6 +131,7 @@ export async function startLogCapture(this: XCUITestDriver): Promise<boolean> {
131
131
  sim: this.device as Simulator,
132
132
  udid: this.isRealDevice() ? this.opts.udid : undefined,
133
133
  log: this.log,
134
+ useRemoteXPC: this.isRealDevice() && isIos18OrNewer(this.opts),
134
135
  }), {
135
136
  type: 'crashlog',
136
137
  }
@@ -0,0 +1,216 @@
1
+ import {getRemoteXPCServices} from './remotexpc-utils';
2
+ import {log} from '../logger';
3
+ import type {
4
+ CrashReportsService as RemoteXPCCrashReportsService,
5
+ RemoteXpcConnection,
6
+ } from 'appium-ios-remotexpc';
7
+ import type {Pyidevice as PyideviceClient} from './clients/py-ios-device-client';
8
+ import {Pyidevice} from './clients/py-ios-device-client';
9
+
10
+ const CRASH_REPORT_EXTENSIONS = ['.ips'];
11
+ const MAX_FILES_IN_ERROR = 10;
12
+
13
+ /**
14
+ * Unified Crash Reports Client
15
+ *
16
+ * Provides a unified interface for crash report operations on iOS devices,
17
+ * automatically handling the differences between iOS < 18 (py-ios-device via Pyidevice)
18
+ * and iOS 18+ (appium-ios-remotexpc).
19
+ */
20
+ export class CrashReportsClient {
21
+ private readonly service: RemoteXPCCrashReportsService | PyideviceClient;
22
+ private readonly remoteXPCConnection?: RemoteXpcConnection;
23
+
24
+ private constructor(
25
+ service: RemoteXPCCrashReportsService | PyideviceClient,
26
+ remoteXPCConnection?: RemoteXpcConnection
27
+ ) {
28
+ this.service = service;
29
+ this.remoteXPCConnection = remoteXPCConnection;
30
+ }
31
+
32
+ //#region Public Static Methods
33
+
34
+ /**
35
+ * Create a crash reports client for device
36
+ *
37
+ * @param udid - Device UDID
38
+ * @param useRemoteXPC - Whether to use remotexpc
39
+ * @returns CrashReportsClient instance
40
+ */
41
+ static async create(udid: string, useRemoteXPC: boolean): Promise<CrashReportsClient> {
42
+ if (useRemoteXPC) {
43
+ const client = await CrashReportsClient.withRemoteXpcConnection(async () => {
44
+ const Services = await getRemoteXPCServices();
45
+ const {crashReportsService, remoteXPC} = await Services.startCrashReportsService(udid);
46
+ return {
47
+ service: crashReportsService,
48
+ connection: remoteXPC,
49
+ };
50
+ });
51
+ if (client) {
52
+ return client;
53
+ }
54
+ }
55
+
56
+ // Fallback to Pyidevice
57
+ const pyideviceClient = new Pyidevice({udid, log});
58
+ return new CrashReportsClient(pyideviceClient);
59
+ }
60
+
61
+ //#endregion
62
+
63
+ //#region Public Instance Methods
64
+
65
+ /**
66
+ * Check if the crash reports tool exists
67
+ *
68
+ * @param isStrict - If true, throws an error when tool is not found
69
+ * @returns True if the tool exists, false otherwise
70
+ */
71
+ async assertExists(isStrict: boolean = true): Promise<boolean> {
72
+ if (this.isRemoteXPC) {
73
+ // RemoteXPC is already connected, so it exists
74
+ return true;
75
+ }
76
+
77
+ // Pyidevice: check if binary exists
78
+ return await this.pyideviceClient.assertExists(isStrict);
79
+ }
80
+
81
+ /**
82
+ * List crash report files on the device
83
+ *
84
+ * @returns Array of crash report file names (e.g., ['crash1.ips', 'crash2.crash'])
85
+ */
86
+ async listCrashes(): Promise<string[]> {
87
+ if (!this.isRemoteXPC) {
88
+ return await this.pyideviceClient.listCrashes();
89
+ }
90
+
91
+ // RemoteXPC: ls returns full paths, filter and extract filenames
92
+ const allFiles = await this.remoteXPCCrashReportsService.ls('/', -1);
93
+ return allFiles
94
+ .filter((filePath) =>
95
+ CRASH_REPORT_EXTENSIONS.some((ext) => filePath.endsWith(ext))
96
+ )
97
+ .map((filePath) => {
98
+ const parts = filePath.split('/');
99
+ return parts[parts.length - 1];
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Export a crash report file from the device to local directory
105
+ *
106
+ * @param name - Name of the crash report file (e.g., 'crash.ips')
107
+ * @param dstFolder - Local destination folder path
108
+ */
109
+ async exportCrash(name: string, dstFolder: string): Promise<void> {
110
+ if (!this.isRemoteXPC) {
111
+ return await this.pyideviceClient.exportCrash(name, dstFolder);
112
+ }
113
+
114
+ // RemoteXPC: need to find full path first, then pull
115
+ const allFiles = await this.remoteXPCCrashReportsService.ls('/', -1);
116
+ const fullPath = allFiles.find((p) => p.endsWith(`/${name}`) || p === `/${name}`);
117
+
118
+ if (!fullPath) {
119
+ const filesList = allFiles.slice(0, MAX_FILES_IN_ERROR).join(', ');
120
+ const hasMore = allFiles.length > MAX_FILES_IN_ERROR;
121
+ throw new Error(
122
+ `Crash report '${name}' not found on device. ` +
123
+ `Available files: ${filesList}${hasMore ? `, ... and ${allFiles.length - MAX_FILES_IN_ERROR} more` : ''}`
124
+ );
125
+ }
126
+
127
+ await this.remoteXPCCrashReportsService.pull(dstFolder, fullPath);
128
+ }
129
+
130
+ /**
131
+ * Close the crash reports service and release resources
132
+ *
133
+ * Only RemoteXPC clients need explicit cleanup; Pyidevice is stateless
134
+ */
135
+ async close(): Promise<void> {
136
+ if (!this.isRemoteXPC) {
137
+ return;
138
+ }
139
+
140
+ this.remoteXPCCrashReportsService.close();
141
+
142
+ if (this.remoteXPCConnection) {
143
+ try {
144
+ await this.remoteXPCConnection.close();
145
+ } catch (err) {
146
+ log.warn(
147
+ `Error closing RemoteXPC connection for crash reports: ${(err as Error).message}`
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ //#endregion
154
+
155
+ //#region Private Methods
156
+
157
+ /**
158
+ * Check if this client is using RemoteXPC
159
+ */
160
+ private get isRemoteXPC(): boolean {
161
+ return !!this.remoteXPCConnection;
162
+ }
163
+
164
+ /**
165
+ * Helper to safely execute remoteXPC operations with connection cleanup
166
+ * @param operation - Async operation that returns service and connection
167
+ * @returns CrashReportsClient on success, null on failure
168
+ */
169
+ private static async withRemoteXpcConnection(
170
+ operation: () => Promise<{
171
+ service: RemoteXPCCrashReportsService;
172
+ connection: RemoteXpcConnection;
173
+ }>
174
+ ): Promise<CrashReportsClient | null> {
175
+ let remoteXPCConnection: RemoteXpcConnection | undefined;
176
+ let succeeded = false;
177
+ try {
178
+ const {service, connection} = await operation();
179
+ remoteXPCConnection = connection;
180
+ const client = new CrashReportsClient(service, remoteXPCConnection);
181
+ succeeded = true;
182
+ return client;
183
+ } catch (err: any) {
184
+ log.error(
185
+ `Failed to create crash reports client via RemoteXPC: ${err.message}. ` +
186
+ `Falling back to py-ios-device (pyidevice)`
187
+ );
188
+ return null;
189
+ } finally {
190
+ // Only close connection if we failed (if succeeded, the client owns it)
191
+ if (remoteXPCConnection && !succeeded) {
192
+ try {
193
+ await remoteXPCConnection.close();
194
+ } catch {
195
+ // Ignore cleanup errors
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Get service as RemoteXPC crash reports service
203
+ */
204
+ private get remoteXPCCrashReportsService(): RemoteXPCCrashReportsService {
205
+ return this.service as RemoteXPCCrashReportsService;
206
+ }
207
+
208
+ /**
209
+ * Get service as Pyidevice client
210
+ */
211
+ private get pyideviceClient(): PyideviceClient {
212
+ return this.service as PyideviceClient;
213
+ }
214
+
215
+ //#endregion
216
+ }
@@ -2,11 +2,10 @@ import {fs, tempDir, util} from 'appium/support';
2
2
  import B from 'bluebird';
3
3
  import path from 'path';
4
4
  import _ from 'lodash';
5
- import {Pyidevice} from '../clients/py-ios-device-client';
5
+ import {CrashReportsClient} from '../crash-reports-client';
6
6
  import {IOSLog} from './ios-log';
7
7
  import { toLogEntry, grepFile } from './helpers';
8
8
  import type { AppiumLogger } from '@appium/types';
9
- import type { BaseDeviceClient } from '../clients/base-device-client';
10
9
  import type { Simulator } from 'appium-ios-simulator';
11
10
  import type { LogEntry } from '../../commands/types';
12
11
 
@@ -24,11 +23,14 @@ export interface IOSCrashLogOptions {
24
23
  /** Simulator instance */
25
24
  sim?: Simulator;
26
25
  log: AppiumLogger;
26
+ /** Whether to use RemoteXPC for crash reports (iOS 18+). */
27
+ useRemoteXPC?: boolean;
27
28
  }
28
29
 
29
30
  export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
30
31
  private readonly _udid: string | undefined;
31
- private readonly _realDeviceClient: BaseDeviceClient | null;
32
+ private readonly _useRemoteXPC: boolean;
33
+ private _realDeviceClient: CrashReportsClient | null;
32
34
  private readonly _logDir: string | null;
33
35
  private readonly _sim: Simulator | undefined;
34
36
  private _recentCrashFiles: string[];
@@ -41,12 +43,8 @@ export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
41
43
  });
42
44
  this._udid = opts.udid;
43
45
  this._sim = opts.sim;
44
- this._realDeviceClient = this._isRealDevice()
45
- ? new Pyidevice({
46
- udid: this._udid as string,
47
- log: opts.log,
48
- })
49
- : null;
46
+ this._useRemoteXPC = opts.useRemoteXPC ?? false;
47
+ this._realDeviceClient = null;
50
48
  this._logDir = this._isRealDevice()
51
49
  ? null
52
50
  : path.resolve(process.env.HOME || '/', 'Library', 'Logs', 'DiagnosticReports');
@@ -61,6 +59,11 @@ export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
61
59
 
62
60
  override async stopCapture(): Promise<void> {
63
61
  this._started = false;
62
+ // Clean up the client connection
63
+ if (this._realDeviceClient) {
64
+ await this._realDeviceClient.close();
65
+ this._realDeviceClient = null;
66
+ }
64
67
  }
65
68
 
66
69
  override get isCapturing(): boolean {
@@ -97,11 +100,11 @@ export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
97
100
  if (this._isRealDevice()) {
98
101
  const fileName = filePath;
99
102
  try {
100
- await (this._realDeviceClient as BaseDeviceClient).exportCrash(fileName, tmpRoot);
103
+ await (this._realDeviceClient as CrashReportsClient).exportCrash(fileName, tmpRoot);
101
104
  } catch (e) {
102
105
  this.log.warn(
103
106
  `Cannot export the crash report '${fileName}'. Skipping it. ` +
104
- `Original error: ${e.message}`,
107
+ `Original error: ${(e as Error).message}`,
105
108
  );
106
109
  return;
107
110
  }
@@ -117,8 +120,20 @@ export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
117
120
 
118
121
  private async _gatherFromRealDevice(strict: boolean): Promise<string[]> {
119
122
  if (!this._realDeviceClient) {
120
- return [];
123
+ try {
124
+ this._realDeviceClient = await CrashReportsClient.create(
125
+ this._udid as string,
126
+ this._useRemoteXPC
127
+ );
128
+ } catch (err) {
129
+ this.log.error(
130
+ `Failed to create crash reports client: ${(err as Error).message}. ` +
131
+ `Skipping crash logs collection for real devices.`
132
+ );
133
+ return [];
134
+ }
121
135
  }
136
+
122
137
  if (!await this._realDeviceClient.assertExists(strict)) {
123
138
  this.log.info(
124
139
  `The ${_.toLower(this._realDeviceClient.constructor.name)} tool is not present in PATH. ` +
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "appium-xcuitest-driver",
3
- "version": "10.16.2",
3
+ "version": "10.17.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "appium-xcuitest-driver",
9
- "version": "10.16.2",
9
+ "version": "10.17.0",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@appium/strongbox": "^1.0.0-rc.1",
@@ -752,9 +752,9 @@
752
752
  }
753
753
  },
754
754
  "node_modules/appium-remote-debugger": {
755
- "version": "15.2.9",
756
- "resolved": "https://registry.npmjs.org/appium-remote-debugger/-/appium-remote-debugger-15.2.9.tgz",
757
- "integrity": "sha512-yscA+KsxDF0gTSy3fo8pa9kxa+to/PIL428+khDqUTNGjmDssgD0fD2/JH0bcQ5+j1BzlEk6avW7U+O3jvzXmA==",
755
+ "version": "15.2.10",
756
+ "resolved": "https://registry.npmjs.org/appium-remote-debugger/-/appium-remote-debugger-15.2.10.tgz",
757
+ "integrity": "sha512-1Ez76NmzuiyF8tWMTgczgTidAF0HIV8PvWVjaxxjnQTuuwqAMpZ9bPv+iWwETAhPlC5vL/SSb7dU73HEki1vGw==",
758
758
  "license": "Apache-2.0",
759
759
  "dependencies": {
760
760
  "@appium/base-driver": "^10.0.0-rc.1",
@@ -1022,9 +1022,9 @@
1022
1022
  "license": "MIT"
1023
1023
  },
1024
1024
  "node_modules/asyncbox": {
1025
- "version": "4.0.1",
1026
- "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-4.0.1.tgz",
1027
- "integrity": "sha512-JtbRZ6JWq1eT0mq/Eg2yObjnX9+80QcYQXDYyLxeNcbu2jHjbV18De2eyn69GTWbbLvDm52Pp/4SFDtte3q/bQ==",
1025
+ "version": "4.1.1",
1026
+ "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-4.1.1.tgz",
1027
+ "integrity": "sha512-j2O7uJ2UmPM9ok++6nVcGC+w1KreiAM2Cjw4IKpX0cgKV621sZeftfGxzPBWGfOsit+hIKIjpAHvppGdXxfDcg==",
1028
1028
  "license": "Apache-2.0",
1029
1029
  "dependencies": {
1030
1030
  "bluebird": "^3.5.1",
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "xcuitest",
9
9
  "xctest"
10
10
  ],
11
- "version": "10.16.2",
11
+ "version": "10.17.0",
12
12
  "author": "Appium Contributors",
13
13
  "license": "Apache-2.0",
14
14
  "repository": {