appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
125 lines (110 loc) • 4.22 kB
text/typescript
import {getRemoteXPCServices, wrapRemoteXPCConnectionError} from './remotexpc-utils';
import {log} from '../logger';
import type {CrashReportsService as RemoteXPCCrashReportsService} from 'appium-ios-remotexpc';
const CRASH_REPORT_EXTENSIONS = ['.ips'];
const MAX_FILES_IN_ERROR = 10;
/**
* Lists and exports device crash reports (`.ips`) on real hardware over RemoteXPC.
*
* Requires **iOS/tvOS 18+** and the optional **`appium-ios-remotexpc`** package.
* Used by {@link IOSCrashLog} for BiDi / `crashlog` collection on real devices.
*/
export class CrashReportsClient {
private readonly crashReportsService: RemoteXPCCrashReportsService;
private constructor(crashReportsService: RemoteXPCCrashReportsService) {
this.crashReportsService = crashReportsService;
}
/**
* Opens a RemoteXPC crash-reports service for the given UDID.
*
* @param udid - Real device UDID
* @param useRemoteXPC - Must be `true`; callers derive this from `isIos18OrNewer` / session options
* @throws {Error} If `useRemoteXPC` is false, or RemoteXPC setup fails
*/
static async create(udid: string, useRemoteXPC: boolean): Promise<CrashReportsClient> {
if (!useRemoteXPC) {
throw new Error(
'Real device crash report access requires iOS/tvOS 18 or newer with the appium-ios-remotexpc ' +
'package installed.',
);
}
try {
const Services = await getRemoteXPCServices();
const crashReportsService = await Services.startCrashReportsService(udid);
return new CrashReportsClient(crashReportsService);
} catch (err: any) {
throw wrapRemoteXPCConnectionError(
err,
'Failed to create crash reports client via RemoteXPC',
);
}
}
/**
* @returns Basenames of crash report files on the device (e.g. `MyApp-2024-01-01-120000.ips`)
*/
async listCrashes(): Promise<string[]> {
const allFiles = await this._listCrashReportPaths();
return allFiles.map((filePath) => {
const parts = filePath.split('/');
return parts[parts.length - 1];
});
}
/**
* Pulls a single crash report off the device into a local folder.
*
* @param name - Crash file basename as returned by {@link CrashReportsClient.listCrashes}
* @param dstFolder - Existing local directory to write into
* @throws {Error} If the named report is not found on the device
*/
async exportCrash(name: string, dstFolder: string): Promise<void> {
const allFiles = await this._listCrashReportPaths();
const fullPath = allFiles.find((p) => p.endsWith(`/${name}`) || p === `/${name}`);
if (!fullPath) {
const filesList = allFiles.slice(0, MAX_FILES_IN_ERROR).join(', ');
const hasMore = allFiles.length > MAX_FILES_IN_ERROR;
throw new Error(
`Crash report '${name}' not found on device. ` +
`Available files: ${filesList}${hasMore ? `, ... and ${allFiles.length - MAX_FILES_IN_ERROR} more` : ''}`,
);
}
await this.crashReportsService.pull(dstFolder, fullPath);
}
/**
* Tears down the crash-reports service.
*/
async close(): Promise<void> {
try {
this.crashReportsService.close();
} catch (err) {
log.warn(`Error closing crash reports service: ${(err as Error).message}`);
}
}
/**
* Walk the crash-reports tree and collect `.ips` paths without listing the full tree upfront.
*/
private async _listCrashReportPaths(): Promise<string[]> {
const results: string[] = [];
await this._collectCrashReportPaths('/', results);
return results;
}
private async _collectCrashReportPaths(dirPath: string, results: string[]): Promise<void> {
let children: string[];
try {
children = await this.crashReportsService.ls(dirPath, 1);
} catch {
return;
}
for (const entryPath of children) {
const basename = entryPath.split('/').pop() ?? entryPath;
if (CRASH_REPORT_EXTENSIONS.some((ext) => basename.endsWith(ext))) {
results.push(entryPath);
continue;
}
try {
await this._collectCrashReportPaths(entryPath, results);
} catch {
// Skip entries we can't access or that aren't directories
}
}
}
}