UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

221 lines (200 loc) 7.59 kB
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;