appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
221 lines (200 loc) • 7.59 kB
text/typescript
import {fs, tempDir, util} from 'appium/support';
import {asyncfilter} from 'asyncbox';
import path from 'node:path';
import _ from 'lodash';
import {CrashReportsClient} from '../crash-reports-client';
import {IOSLog} from './ios-log';
import {toLogEntry, grepFile} from './helpers';
import type {AppiumLogger} from '@appium/types';
import type {Simulator} from 'appium-ios-simulator';
import type {LogEntry} from '../../commands/types';
// The file format has been changed from '.crash' to '.ips' since Monterey.
const CRASH_REPORTS_GLOB_PATTERN = '**/*.@(crash|ips)';
// The size of a single diagnostic report might be hundreds of kilobytes.
// Thus we do not want to store too many items in the memory at once.
const MAX_RECENT_ITEMS = 20;
/**
* Options for {@link IOSCrashLog}.
*/
export interface IOSCrashLogOptions {
/** UDID of a real device (omit with `sim` for Simulator crash logs). */
udid?: string;
/** Simulator instance; required for simulator-side collection. */
sim?: Simulator;
log: AppiumLogger;
/**
* For real devices: must reflect **iOS/tvOS 18+** so {@link CrashReportsClient} can be used.
* Typically matches `isIos18OrNewer` from the active session.
*/
useRemoteXPC?: boolean;
}
type TSerializedEntry = [string, number];
/**
* Collects iOS/tvOS crash logs for BiDi `log.entryAdded` / classic log APIs.
*
* - **Simulator:** reads `~/Library/Logs/DiagnosticReports` and filters by simulator UDID.
* - **Real device:** uses RemoteXPC (`appium-ios-remotexpc`) when `useRemoteXPC` is true; if the client
* cannot be created, collection is skipped and errors are logged (no session failure).
*/
export class IOSCrashLog extends IOSLog<TSerializedEntry, TSerializedEntry> {
private readonly _udid: string | undefined;
private readonly _useRemoteXPC: boolean;
private _realDeviceClient: CrashReportsClient | null;
private readonly _logDir: string | null;
private readonly _sim: Simulator | undefined;
private _recentCrashFiles: string[];
private _started: boolean;
/**
* @param opts - Provide `udid` for a real device or `sim` for a Simulator (mutually exclusive by usage).
*/
constructor(opts: IOSCrashLogOptions) {
super({
log: opts.log,
maxBufferSize: MAX_RECENT_ITEMS,
});
this._udid = opts.udid;
this._sim = opts.sim;
this._useRemoteXPC = opts.useRemoteXPC ?? false;
this._realDeviceClient = null;
this._logDir = this._isRealDevice()
? null
: path.resolve(process.env.HOME || '/', 'Library', 'Logs', 'DiagnosticReports');
this._recentCrashFiles = [];
this._started = false;
}
override get isCapturing(): boolean {
return this._started;
}
/** Records the current crash file snapshot so only new reports appear in {@link IOSCrashLog.getLogs}. */
override async startCapture(): Promise<void> {
this._recentCrashFiles = await this._listCrashFiles();
this._started = true;
}
/** Stops polling and closes any real-device {@link CrashReportsClient}. */
override async stopCapture(): Promise<void> {
this._started = false;
// Clean up the client connection
if (this._realDeviceClient) {
await this._realDeviceClient.close();
this._realDeviceClient = null;
}
}
/**
* @returns New crash log entries since the last successful poll (bounded by {@link MAX_RECENT_ITEMS}).
*/
override async getLogs(): Promise<LogEntry[]> {
const crashFiles = (await this._listCrashFiles()).slice(-MAX_RECENT_ITEMS);
const diffFiles = _.difference(crashFiles, this._recentCrashFiles);
if (_.isEmpty(diffFiles)) {
return [];
}
this.log.debug(`Found ${util.pluralize('fresh crash report', diffFiles.length, true)}`);
await this._serializeCrashes(diffFiles);
this._recentCrashFiles = crashFiles;
return super.getLogs();
}
protected override _serializeEntry(value: TSerializedEntry): TSerializedEntry {
return value;
}
protected override _deserializeEntry(value: TSerializedEntry): LogEntry {
const [message, timestamp] = value;
return toLogEntry(message, timestamp);
}
/** Reads crash file contents and {@link IOSLog.broadcast}s them as `[text, mtime]` tuples. */
private async _serializeCrashes(paths: string[]): Promise<void> {
const tmpRoot = await tempDir.openDir();
try {
for (const filePath of paths) {
let fullPath = filePath;
if (this._isRealDevice()) {
const fileName = filePath;
try {
await (this._realDeviceClient as CrashReportsClient).exportCrash(fileName, tmpRoot);
} catch (e) {
this.log.warn(
`Cannot export the crash report '${fileName}'. Skipping it. ` +
`Original error: ${(e as Error).message}`,
);
return;
}
fullPath = path.join(tmpRoot, fileName);
}
const {ctime} = await fs.stat(fullPath);
this.broadcast([await fs.readFile(fullPath, 'utf8'), ctime.getTime()]);
}
} finally {
await fs.rimraf(tmpRoot);
}
}
/**
* Lazily creates a {@link CrashReportsClient} and lists `.ips` basenames on the device.
*
* @returns Empty array if RemoteXPC setup or listing fails (logged). The client is reset after
* listing errors so a later poll can recreate it. Never throws to callers.
*/
private async _gatherFromRealDevice(): Promise<string[]> {
if (!this._realDeviceClient) {
try {
this._realDeviceClient = await CrashReportsClient.create(
this._udid as string,
this._useRemoteXPC,
);
} catch (err) {
this.log.error(
`Failed to create crash reports client: ${(err as Error).message}. ` +
`Skipping crash logs collection for real devices.`,
);
return [];
}
}
try {
return await this._realDeviceClient.listCrashes();
} catch (err) {
this.log.error(
`Failed to list crash reports on device: ${(err as Error).message}. ` +
`Skipping this poll; the next poll will attempt to reconnect.`,
);
const client = this._realDeviceClient;
this._realDeviceClient = null;
if (client) {
try {
await client.close();
} catch {
// ignore secondary teardown errors
}
}
return [];
}
}
/** Glob diagnostic reports and keep files whose content references the simulator UDID. */
private async _gatherFromSimulator(): Promise<string[]> {
if (!this._logDir || !this._sim || !(await fs.exists(this._logDir))) {
this.log.debug(`Crash reports root '${this._logDir}' does not exist. Got nothing to gather.`);
return [];
}
const foundFiles = await fs.glob(CRASH_REPORTS_GLOB_PATTERN, {
cwd: this._logDir,
absolute: true,
});
const simUdid = (this._sim as Simulator).udid;
// For Simulator only include files, that contain current UDID
return await asyncfilter(foundFiles, async (filePath) => {
try {
return await grepFile(filePath, simUdid, {caseInsensitive: true});
} catch (err) {
this.log.warn(err);
return false;
}
});
}
/** Dispatches to real-device RemoteXPC listing or simulator filesystem globbing. */
private async _listCrashFiles(): Promise<string[]> {
return this._isRealDevice()
? await this._gatherFromRealDevice()
: await this._gatherFromSimulator();
}
private _isRealDevice(): boolean {
return Boolean(this._udid);
}
}
export default IOSCrashLog;